← 套装首页 Claude Code 0.2.8 源码走读

我把 Claude Code 0.2.8 的源码顺着读了一遍

《claude-code-agent-kernel》白皮书 · 卷一:源码走读卷


还原缺口声明。 本文分析的源码树还原自公开安装包中携带的 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。凡受缺口影响、属于推断而非直读的结论,正文均已就地注明。


目录


第 1 章 这份代码是什么,我打算怎么读

这份仓库是 Anthropic 早期公开安装包里带出来的 TypeScript。README 写的版本是 Research Preview 0.2.8,git 里只有 4 个提交,远程是 TeFuirnever 从 leeyeel/claude-code-sourcemap 叉出来的。src/ 里有效业务代码大约 25979 行。旁边还有一份 23MB 的 cli.mjs 和 Yoga 的 wasm,那是打包产物,正文只谈 src/。

本文是对着一棵还原树的走读笔记,给刚入门、要自己写终端 Agent 的人看。它不是用户研究,也不是对现行 Claude Code 的评测。我没有在本机把 0.2.8 跑起来,没有打通权限路径,也没有把 2.x 源码和这棵树做逐文件 diff。行号对着这次阅读时的文件。src/Tool.ts 在还原里不存在,后文写到的工具接口是从 satisfies Tool 和调用点反推的。文中若写到 2.x 的 Skills、沙箱、自动压缩,材料来自后续官方文档和公开泄露分析,本仓库里没有那些文件。

2026 年 3 月还有另一次更出名的泄露,对应 npm 上的 2.1.88,大约 1900 个文件。两件事不要混。0.2.8 是产品刚能给人用的那一版。后来的 Skills、Hooks、操作系统沙箱、自动压缩上下文,当时都还没有。

相对已经流传的泄露评论,这篇多核对了几处公开文章很少写到的实现。Grep 实际跑 rg -li,只回文件名。Architect 的 isEnabled() 写死 false。Edit 必须先读且只改唯一一处。SAFE_COMMANDS 只有八条整句。这些句子都能回这份 src/。

这份源码里的 Agent 就是一个循环。模型回一段结构化内容,里面可以夹着名叫 tool_use 的块,每个块带工具名和参数。程序按名字找到对应函数,跑完把结果写成 tool_result,再连同整段对话发给模型。模型再决定下一步。content 里不再出现 tool_use,这一轮结束。循环本身不规划任务,也不判断这条命令安不安全。规划在模型那边,安全在循环外面那层权限函数里。

flowchart LR U[人在终端打字] --> PI[processUserInput] PI -->|普通句子| Q[query 递归生成器] PI -->|感叹号 bash / 本地斜杠| LOCAL[本地跑完就停] Q --> API[querySonnet] API --> M{content 里有 tool_use?} M -->|没有| DONE[这一轮结束] M -->|有| GATE[Zod / validateInput / canUseTool] GATE -->|通过| CALL[tool.call] GATE -->|拒绝或失败| TR[写成 is_error 的 tool_result] CALL --> TR TR --> Q

图 1 一次输入如何进入循环。省略了 binary feedback、MCP 连接和斜杠命令的本地 JSX。数据来自 query.ts、messages.tsx 的 processUserInput。

我读的时候一直在对同一条路径。你在项目目录里敲 claude,等终端里出现输入框,打一句“登录有 bug,帮我修”,然后看程序怎么走到改文件、跑测试、再问你要不要执行 git commit。下面按这条路径写。文件路径和行号都对着这份还原。有一处对不上,我会写明是还原缺文件,还是我自己没看懂。第一次碰到 tool_use、生成器、ephemeral 这类词,我会停下来用这份代码解释它在干什么。

对回源码 · 本章引用的主要位置


第 2 章 你敲下 claude 之后,模型暂时还看不见任何文件

入口在 src/entrypoints/cli.tsx,1043 行,第一行 shebang 是

#!/usr/bin/env -S node --no-warnings=ExperimentalWarning --enable-source-maps

它按 Node 启动,同时又防着 Bun 在 Windows 上把纯副作用的 import 树摇掉。第 9 到 10 行专门 import * as dontcare from '@anthropic-ai/sdk/shims/node',再立刻 Object.keys(dontcare)。上方第 5 到 8 行的 XXX: 注释写得很直,不加这行,Bun 在 Win32 上会把这个模块删掉。0.2.8 已经按两套打包器在跑,入口必须迁就两边的癖好。

main 先调 enableConfigs()。在这之前如果有模块在 import 阶段去读 ~/.claude.json,会直接抛错。配置坏了也进不了对话,屏幕上是 InvalidConfigDialog,人可以退出手修,也可以用默认值覆盖,两个选择都会结束当前进程。

stdin 在这里会分家。管道进来的内容被当成用户的第一句 prompt,不是键盘。所以非 TTY、又不是 mcp 子命令时,程序先把 stdin 读完,再在 POSIX 上重开 /dev/tty 给 Ink 当输入。claude mcp serve 必须排除在外,因为它的协议就走这一对标准输入输出,吸走就等于把自己掐死。

交互路径才会弹出设置屏。没选过主题,或者 hasCompletedOnboarding 还是 false,先走 Onboarding。随后是 Trust Dialog。它检查的是当前目录有没有被点过头。账号登录已经在 onboarding 里做过了。checkHasTrustDialogAccepted() 会沿着父目录往上爬,子项目开在已经点过信任的 monorepo 里不用再点一次。家目录是例外,点继续也不写盘,下次还问。

Trust 接受之后,cli.tsx 第 153 行调用 grantReadPermissionForOriginalDir()。setup() 第 185 行又无条件再授一次。这两次授的都是读权,写权此时还没有。授完才去预取 getContext()。顺序不能倒。倒了的话,目录树、git status、README 可能已经拼进要发给模型的系统提示,人还没点过头。

配置写在一份文件里,默认 ~/.claude.json。主题、OAuth、onboarding 标记跟着人走。信任标记、prompt 历史、allowedTools、上次会话费用跟着绝对路径分桶,存在同一个 JSON 的 projects 字典里。预览期这样省事,不用教用户写 gitignore,也不会出现同事把信任标记提交进仓库。坏处也很具体,这份文件一坏,全局和所有项目一起倒。saveGlobalConfig 写回时还要再从磁盘读一遍 projects,免得覆盖掉另一条路径刚记下的历史。

--print 和 --dangerously-skip-permissions 都跳过 Trust Dialog。前者给管道和 CI 用,ask.tsx 自己注释写了,不会再问人要权限。后者名字里带着 dangerously,setup() 里还有三道门。Unix 上 uid 为 0 直接拒绝。必须能看见 /.dockerenv 且系统是 Linux。还必须对 1.1.1.1 发一次 1 秒超时的 HEAD,探测到外网就退出。三道门是给内部评测准备的,USER_TYPE=SWE_BENCH 甚至会走单独的 API Key。外部用户在自己笔记本上打开这个开关,等于把整盘交给模型。程序选择把旁路写得很难误用,没有把开关藏起来假装不存在。

src/utils/state.ts 一共 25 行。文件头第 4 行注释写着 DO NOT ADD MORE STATE HERE OR BORIS WILL CURSE YOU。内存里只冻了启动时的 cwd。之后实际使用的工作目录住在持久 shell 里。Boris 是 Claude Code 的作者之一,Cherny。这行注释后来在公开讨论里反复被人提起,因为它把整仓对全局状态的态度写死了。

门厅结束时,工具表、斜杠命令表、MCP 客户端已经准备好,render(<REPL />) 才发生。模型到这一步仍然没有收到任何仓库内容。

flowchart TD A[进程启动 cli.tsx] --> B[Sentry] B --> C[enableConfigs 开闸] C -->|JSON 坏了| D[InvalidConfigDialog 后退出] C -->|OK| E{stdin 是 TTY?} E -->|否且非 mcp| F[吸干 stdin 当 prompt<br/>重开 /dev/tty] E -->|是| G[Commander 解析] F --> G G --> H{哪条路径?} H -->|交互 claude| I[Onboarding] I --> J[Trust Dialog] J -->|Yes| K[grantReadPermission] J -->|No / Esc| X[process.exit] K --> L[setup 预取 context] L --> R[render REPL] H -->|--print| P[ask 一次性问答] H -->|mcp serve| S[stdio MCP Server] H -->|--dangerously-skip-permissions| T[必须 Docker + 无网 + 非 root] T --> L

图 2 进程启动后的分叉。省略了内部用户的 .mcprc 审批和 melon 包装器。数据来自 cli.tsx 的 showSetupScreens 与 setup。

三种活法共用工具实现,分家从“谁拥有 stdin”开始。

对回源码 · 本章引用的主要位置


第 3 章 发给模型的第一包里有什么

系统提示在 src/constants/prompts.ts。getSystemPrompt() 返回字符串数组。各段来自不同生命周期,没有焊成一篇长文。第一块是人格,第二块是 <env>,第三块把恶意软件禁令再抄一遍。formatSystemPromptWithContext 再往后挂一组 <context name="...">。

首包解剖图

图 3 发给模型的第一包解剖:两个 system 块、五键 context 与三处 cache 断点如何拼出一次 messages.stream 请求(手绘版。数据来自 prompts.ts、context.ts 与 claude.ts)。

人格里有几条写得很死。回答必须短,不含工具调用和生成代码时要少于 4 行。禁止用 Bash 或者代码注释跟用户聊天。用户没明确要求就不要 git commit。拒绝帮助时不要解释为什么,不要说教,给替代方案,一两句结束。这些句子在文件前部和尾部各出现一次,中间夹着一组极端 few-shot,2+2 的回答就是 4,is 11 a prime number 的回答就是 true。

终端一行大约八十到一百二十个字符。模型先自我总结一段,工具输出就会被挤出屏幕。ReAct 循环里,每一轮 assistant 文本还会作为下一轮的观察送回去。客套话会污染下一轮推理。所以把回答压到四行以内,既是在保护视口,也是在保护循环。

恶意软件那一段的关键句是,开始干活前先根据文件名和目录结构想这段代码是干什么的。目录看起来像恶意软件,即使用户只是让你解释或者加速,也必须拒绝。判断材料正好是后面要拍进系统提示的那份目录树。教育用途免责声明被明确关掉。我愿意把它看成产品默认行为,不把它看成强制执行。强制执行在权限对话框里。模型可以被 jailbreak,作者不会不知道。

仓库快照在 src/context.ts。getContext() 会抓这些东西。

键 怎么来的 会话里会不会更新
directoryStructure 对当前目录调一次 LSTool,1 秒超时 不会。超时可能变成空串
gitStatus branch、origin/HEAD、git status、最近 5 条 log。status 超 200 行截断 不会。测试环境直接 null
codeStyle 从 cwd 走到文件系统根,沿途每个 CLAUDE.md 全文,父级在前。读文件在 style.ts /compact 和 /clear 才清缓存
claudeFiles ripgrep 找 **/*/CLAUDE.md,只列路径 这个 glob 找不到根目录那一份
readme cwd 下的 README.md 跟目录树一起 memoize

注释写明,directory structure 和 git status 在对话期间不会更新。整场会话共用一份 memoize 结果。/compact 和 /clear 会清掉这份缓存。对话中途别人提交了代码,模型拿着的还是开场时的 git status,它必须再调 Bash 或者 Read 才能看见新状态。

刚写 Agent 的人常会纠结,要不要每轮把整个仓库再索引一遍。0.2.8 的答案很土。开场拍一张静态快照,写进系统提示,之后靠工具自己探索。快照过期是刻意的。稳定的系统提示前缀更容易命中 prompt cache。cache 在这里的意思是,Anthropic API 可以记住已经发过的前缀,下次少收一点输入钱。cache_control: ephemeral 标在文本块上,就是在告诉 API 这块可以进缓存,存活时间按他们当时的默认策略。项目配置里还有 dontCrawlDirectory,大仓库可以把目录树关掉,免得那一次 LSTool 把启动拖死。setContext / removeContext 写盘前会把 codeStyle 和 directoryStructure 从对象里 omit 掉,算出来的键不准持久化。

claude.ts 有 948 行,负责跟 Anthropic / Bedrock / Vertex 说话。SDK 的 maxRetries 被设成 0,重试写在自己的 withRetry 里,这样终端才能画出 Retrying in Ns,Statsig 也才打得上 tengu_api_retry。408、409、429、5xx 和连接错误会重试,普通用户最多 10 次,USER_TYPE=SWE_BENCH 最多 100 次,并尊重 Retry-After。重试耗尽以后,getAssistantMessageFromError 把几种失败收成固定短句。prompt 太长、余额不足、Key 无效,循环看到的都是一条 type: 'assistant' 的消息。循环不用处理半打异常类型。

HTTP 层调用的是 anthropic.beta.messages.stream,见 claude.ts 第 516 行。函数会等到 finalMessage。REPL 用 for await 一次收一条完整的 Message。传输在流,屏幕上仍是整段突然出现。文件里有一处 TODO(ben) 提到增量进度,0.2.8 没做完。

prependCLISysprompt 为真时,最前面还会再插一句 You are Claude Code, Anthropic's official CLI for Claude.。插之前先把原来的第一块拿去算 sha256,打到 Statsig 事件 tengu_sysprompt_block 上,还带上前 20 个字符和长度。注释里有一条 Statsig 控制台链接,配置名是 claude_cli_system_prompt_prefixes。他们在观测 API 能不能认出这段固定前缀。

主请求的温度写死在 MAIN_QUERY_TEMPERATURE = 1,旁边注释写,为了给 binary feedback 更多变化。采样更散,两次并行调用才比较容易长得不一样,左右对照才有得挑。max_tokens 取 maxThinkingTokens + 1 和模型上限里较大的那个。模型名里带 3-5 或 haiku 上限 8192,其余 20000。thinking 预算很大时,输出上限会被它顶上去。

消息列表送给 API 之前要过 addCacheBreakpoints。下标大于 messages.length - 3 的那些,也就是最后两条,会再打一次 ephemeral。前面的历史不再标。0.2.8 的缓存策略就这三处。系统两块加最后两轮对话。

countTokens 不在本地跑 tokenizer。它从消息列表尾部往前找最近一条带 usage 的非合成 assistant,把 input、cache_creation、cache_read、output 四个数加起来。合成打断句被跳过。界面上的 Context low 警告读的就是这个数。TokenWarning.tsx 把上限写成 190_000,注释说给 /compact 留余量。用到 60% 开始黄,80% 变红,文案让人跑 /compact。会话费用到 5 美元会弹出 CostThresholdDialog,点一次 Got it 就写入 hasAcknowledgedCostThreshold,同一会话不再弹。

词表和预算见上表。实现在 utils/thinking.ts。只看最后一条 user 文本,而且 content 必须是字符串,带图片的数组直接返回 0。claude.ts 第 529 行还要求 USER_TYPE === 'ant' 才把 thinking 发给 API。对外构建里,即使用户写出 ultrathink,这个数字也不会上路。子串匹配有误伤。“I think we should use Redis” 也会落到 4000 那一档。

费用记在 cost-tracker.ts 的模块级单例里,注释同样写着不要再往这里加状态。价表写死在 claude.ts,单位是每百万 token 的美元。

模型 输入 输出 cache 写入 cache 读取
Sonnet 3 15 3.75 0.3
Haiku 0.8 4 1 0.08

进程退出时 useCostSummary 把 lastCost 写回项目配置。/cost 只是把这个单例格式化打出来。

thinking 关键词和预算也最好摊开看。匹配只发生在最后一条 user 文本上,而且是小写子串。

用户文本里出现 预算 对外构建会不会发给 API
ultrathink / think harder / think intensely / think longer / think really hard / think super hard / think very hard 31999 不会。还要 USER_TYPE=ant
megathink / think hard / think more / think a lot / think about it 10000 同上
单独一个 think 4000 同上。I think we should use Redis 也会命中
ThinkTool 已打开 0 两条思考管道互斥
MAX_THINKING_TOKENS 环境变量且 ant 变量值 整段覆盖

系统提示出站时会被压扁。splitSysPromptPrefix 只保留数组第一块,其余 join('\n') 成第二块。送到 Messages API 的 system 通常只有两个 text block,每块都标 cache_control: ephemeral。消息列表只给最后两条再打一次 ephemeral。0.2.8 没有按稳定性切段的 cache 协议,也没有自动压缩的电路熔断。/compact 是人自己下的命令,实现在 commands/compact.ts。它先用 getMessagesGetter() 取出当前列表,再追加一条写死的 user 消息,要求详细但简洁地摘要,重点是做了什么、正在做什么、在改哪些文件、下一步准备做什么。然后单独调一次 querySonnet,系统提示换成 You are a helpful AI assistant tasked with summarizing conversations.,thinking 预算 0。摘要文本以 API Error 开头就当失败抛出。成功则把这条回复的 input_tokens、cache_creation_input_tokens、cache_read_input_tokens 改成 0,留下 output_tokens。注释写,countTokens 读最近一条 assistant 的 usage,这样改是为了让界面上的超限警告先消失,下一条真实 usage 会很快覆盖这些假数字。接着 clearTerminal(),把消息数组清空,再用 setForkConvoWithMessagesOnTheNextRender 塞进两条新消息。一条告诉模型用 /compact 清过历史,一条是摘要本身。getContext 和 getCodeStyle 的 memoize 缓存一并清掉,下一轮会重新爬仓。

内部用户还有 /ctx-viz。它按 4 字节估一个 token,把系统提示各段、每个 <context>、每个工具的 prompt 和 JSON Schema、当前消息摊成一张表。作者在乎模型究竟看见了什么,并且愿意把这件事做成可调试的命令。

对回源码 · 本章引用的主要位置


第 4 章 query.ts 只有 516 行,循环就写在这里

阿舟图解:query 循环——模型回答、调用工具、工具回包、再发给模型

export async function* query(...) 从第 124 行开始。async function* 是异步生成器。调用方用 for await 拉下一条消息。函数可以在中途 yield 一条 assistant,再去跑工具,再 yield 一条 tool_result,最后用 yield* 把自己的下一次调用接上去。对刚写 Agent 的人,这比 while (true) 难读一点,好处是每一轮的局部变量自然停在这一层栈上,取消时 return 就能停。

它吃四样东西。已经规范化的消息列表,系统提示数组,上下文表,以及一个叫 canUseTool 的函数。它向外 yield 的是 Message。

canUseTool 是注入进来的。同一套 query,刹车可以换成不同实现。这是这份代码里最值得抄的一处。

调用方 传入的刹车 人看不看得到对话框
交互 REPL useCanUseTool 看得到。Promise 挂在 React state 上
--print / ask() hasPermissionsToUseTool 看不到。需要批的写操作会变成 is_error
AgentTool 子循环 hasPermissionsToUseTool 看不到。默认还被裁成只读工具集

REPL、--print 用的 ask()、AgentTool、ArchitectTool 都走进这一个函数。没有第二套编排器。

人打回车之后,文字先经过 processUserInput,见 utils/messages.tsx 第 156 行。它还不进 query。模式是 bash 时,输入会被包成 <bash-input>...</bash-input>。以 cd 开头的单独处理,直接 setCwd,返回一对 user 加 assistant,连 BashTool 都不调。其余命令会先 BashTool.validateInput,再 lastX(BashTool.call(...))。lastX 是生成器的对偶,只要最后一个值。失败则包进 <bash-stderr>。REPL 看到末条已经是 assistant,就不再调用 query。感叹号模式是本地终端,模型不在场。

输入以 / 开头,先切开命令名。第二个词是 (MCP) 时,会拼回 name (MCP),否则 MCP 命令对不上。hasCommand 查不到,整句当普通 prompt 发给模型,并打点 tengu_input_prompt。查得到则按命令类型走。local-jsx 返回空数组,界面已经由 setToolJSX 接管。一对 user 加 assistant,通常是本地命令跑完了。更长的列表才是要送给 query 的 prompt 型命令。未知命令如果走到错误分支,assistant 文本以 Unknown command: 开头,旁边有一条 @ts-expect-error,作者自己写了 TODO,觉得这里大概是个 bug。

一次调用做三件事。先拿一条 assistant 回复。回复的 content 里如果没有 type === 'tool_use' 的块,函数直接 return。有的话,先跑完这一轮全部工具,再

yield* await query(
  [...messages, assistantMessage, ...orderedToolResults],
  systemPrompt,
  context,
  canUseTool,
  toolUseContext,
  getBinaryFeedbackResponse,
)

把旧消息、本轮 assistant、按原始顺序排好的 tool_result 拼回去,再调自己一次。局部变量留在这一层栈帧上。0.2.8 没有自动压缩,工具轮数通常是个位数,递归深度够用。文件里没有递归上限。后来的 2.x 把同一文件改成 while 加七处 continue,是因为循环体内要插入多层压缩和 stop hook(2.x 的循环改写材料来自官方文档和公开泄露分析,不在本仓库)。那是后话。

flowchart TD A[query] --> B{ant 且命中 binary feedback?} B -->|否| C[querySonnet 一次] B -->|是| D[并行两次 querySonnet] D --> E[人左右对照] E -->|prefer 左或右| F[采用该份且跳过权限] E -->|没有偏好| G[随机一份仍走权限] E -->|都不要 / Esc| I[yield 打断句并 return] C --> H[yield AssistantMessage] F --> H G --> H H --> J{content 里有 tool_use?} J -->|无| Z[本轮结束] J -->|有| K{全部 isReadOnly?} K -->|是| L["all 并发 最多 10 路"] K -->|否| N[整批串行] L --> O[每条走 checkPermissionsAndCallTool] N --> O O --> P{abort?} P -->|是| Q[yield 打断句] P -->|否| R[按 tool_use 原序排 tool_result] R --> S["yield* query 旧消息+assistant+results"]

图 4 query 一次调用内部怎么走。省略了 Zod 失败和工具名找不到。数据来自 query.ts 第 124 行到递归处。

stop_reason === 'tool_use' 被明确放弃了。第 170 行注释写,这个字段不可靠。utils/messages.tsx 第 605 行又写了同一句。循环和界面统一改看 content 块里有没有 tool_use。早期 Anthropic API 在流式、多 block、打开 thinking 时,信封上的 stop_reason 不一定填对。程序选择相信块,不相信信封。

并发规则写在第 184 到 216 行。本轮每一个 tool_use 都能在工具表里找到,并且对应工具的 isReadOnly() 都返回 true,才走 runToolsConcurrently。否则整批串行。并发上限是文件顶部的 MAX_TOOL_USE_CONCURRENCY = 10。实现是 utils/generators.ts 里的 all(),用 Promise.race 保持最多 10 个生成器同时跑,完成一个再从等待队列补一个。模型一轮吐出 30 个 Read 和 Grep 时,同时占着的文件描述符不会超过 10 个。

为什么混着读和写的时候不按段切开,只读的先并行、写的再串行。文件里有 TODO,说其实可以更激进。0.2.8 没做,我能从旁边代码里看到三个具体障碍。权限界面只有一个 setToolUseConfirm,两个写工具同时问人会互相覆盖。Bash 共用一份 PersistentShell,并行写会打坏那四个临时文件。MCP 工具的 isReadOnly() 恒为 false,只要这一轮里有一个 MCP 调用,整批都会改成串行。把切批逻辑写进这 516 行,循环会明显变厚。预览期他们选择不写。

消息有三种,按谁消费来切。

类型 人看不看 下一轮 API 看不看 典型内容
UserMessage 看 看。toolUseResult.data 这份结构化结果除外 人打的字,或工具回写的 tool_result
AssistantMessage 看 看。合成句的 model 是 <synthetic> 模型原话,外加 costUSD / durationMs
ProgressMessage 看 不看。normalizeMessagesForAPI 直接丢掉 子代理中间帧,比如正在 Grep

主模型下一轮看不到子代理中间搜过什么,只看得到子代理最后交回来的那段文字。

取消、拒绝、打断各有一句写死的英语,见 messages.tsx 第 36 到 43 行。

常量 原文在干什么 什么时候 yield
INTERRUPT_MESSAGE [Request interrupted by user] 模型还在说话时按 Esc
INTERRUPT_MESSAGE_FOR_TOOL_USE [Request interrupted by user for tool use] 工具已经开跑时按 Esc
CANCEL_MESSAGE 要模型停下来等用户指示 单个工具被 abort
REJECT_MESSAGE 写明文件没有写入,同样要求停下等 人在权限框里点拒绝

它们进 SYNTHETIC_ASSISTANT_MESSAGES 这个 Set。计费、token 统计、界面渲染都会认出这些句子,避免当成模型原文。模型调用中按 Esc,yield 第一条。工具执行中按 Esc,yield 第二条。单个工具被 abort,tool_result 是 CANCEL_MESSAGE。人在权限框里点拒绝,tool_result 是 REJECT_MESSAGE,同时 abortController.abort(),同批还没开跑的工具一起停。模型下一轮读到的是普通 tool_result,循环不用为取消另开一条路。

送给 API 之前还有两步规范化。normalizeMessages 把一条带多个 content 块的 assistant 拆成每块一条,costUSD 按块数均分,每条新 uuid。user 的数组 content 却原样返回。第 580 行开始有一段很长的注释,作者签名是 (ab),怀疑这是历史 no-op,还列了 MCP 可能把 role 弄乱的情况。界面按块渲染靠的是 assistant 这条拆分。user 那边没拆开。

normalizeMessagesForAPI 丢掉所有 progress。连续多条 tool_result 会合并进同一条 user 消息。Anthropic 的 Messages API 要求同一轮多个工具结果长在一条 user 里。漏了这一步,下一轮请求会报协议错误。API 有时会回只有换行的空文本,normalizeContentFromAPI 会滤掉,全空时填一句 NO_CONTENT_MESSAGE,否则下次 query 会因为空块被拒。

工具抛错时,formatError 把 message、stderr、stdout 拼起来。总长超过 10000 字符就留头尾各 5000,中间写删了多少字符。避免一次失败把整个上下文窗口灌满。

query() 正上方有一段用 wizard 口气写的注释,第 111 到 123 行。三条规则。带 thinking 块的消息,这次 query 的 max_thinking_length 必须大于 0。thinking 块不能当一条消息的最后一块。从本轮 assistant 经过 tool_result 再到下一轮 assistant,thinking 必须还在。写在循环旁边是因为违规的惩罚发生在下一轮回放时。只写在 claude.ts 里,以后改 normalizeMessagesForAPI 的人看不见。

内部员工会碰到 binary feedback。USER_TYPE === 'ant',并且 Statsig 采样命中,queryWithBinaryFeedback 会并行打两次 querySonnet。两次都不是错误,并且 tool_use 或文本有差异,屏幕上左右对照,让人挑。挑左边或右边,shouldSkipPermissionCheck 为 true,这一轮工具不再问权限。点没有偏好会随机取一份,仍然走权限。点都不要或 Esc,yield 打断句,循环结束。默认采样频率是 0。测试里强制关掉,注释写它会破坏权限相关测试。成本翻倍,只许内部狗食承担。query.ts 第 424 行,shouldSkipPermissionCheck 为真时直接把权限结果当成通过。这是研究仪器在闸门上开的口。

runToolUse 外层有一个 catch,只 logError,不 yield。极端情况下会丢一条 tool_result,下一轮 API 会对不齐。我没在这份还原里看到对应测试。递归也没有深度上限。这两处我倾向于看成预览期没补上的洞,不看成有意设计。

对回源码 · 本章引用的主要位置


第 5 章 每个工具自己带着校验、执行和画界面的代码

阿舟图解:工具契约——schema 校验、call 执行、render 画界面三件套

src/Tool.ts 在这份还原里不存在。大量文件写着 from './Tool.js'。接口只能从各工具的 satisfies Tool、query.ts 的调用点、claude.ts 的 schema 组装反推。同批缺失的还有 Memory 两个工具的 prompt.ts。这是 sourcemap 还原的残缺,读的时候要自己补上那张接口。

我反推出来的形状大致是这样。一个工具至少有 API 名 name,给人看的名 userFacingName,长说明书 prompt(),Zod 的 inputSchema,可选的 inputJSONSchema,isEnabled(),isReadOnly(),needsPermissions(input),可选的 validateInput,返回异步生成器的 call,以及三套 Ink 渲染。description() 和 prompt() 是分开的。Bash 的 description 甚至会为每一条命令再调一次 Haiku,生成 5 到 10 个词给人看。claude.ts 把 prompt() 的返回值填进 API 的 tools[].description,模型读到的是这一份。

src/tools.ts 第 23 行的 getAllTools() 是同步清单,写成函数是为了避开 Bun 的循环依赖。getTools 第 41 行用 lodash 的 memoize 包住,合并 MCP 工具,按需 push Architect,再 filter(isEnabled)。进程生命周期内这份列表不会刷新。会话中途新批准一台 MCP 服务器,当前进程未必看得到它的工具。

API 名 给人看的名 只读? 0.2.8 实际能不能用
dispatch_agent Task 标了 true,注释写 for now 能。默认只带只读工具
Bash Bash 否 能。接持久 shell
GlobTool / Grep / LS / View 搜索和读 是 能。Grep 只回文件名
Edit Update / Create / Delete 否 能。唯一字串替换
Replace Write 否 能。整文件写
NotebookEditCell / 笔记本读 Edit Notebook 写的那个否 能。写的那个没有先读后改
Think 思考气泡 是 要 THINK_TOOL 加 Statsig 门
Architect Architect 否 不能。isEnabled 写死 false
Memory 读写 Memory 读是、写否 不能。ant 才进清单,随后仍 isEnabled false
mcp__{server}__{tool} server:tool (MCP) 一律否 连上 MCP 以后才能用
Sticker 贴纸表单 否 彩蛋门

Architect 有三道闸。--enable-architect 或项目配置 enableArchitectTool 决定要不要 push。ArchitectTool.isEnabled() 第 51 行写死 return false。push 上去也会被滤掉。0.2.8 里这个工具实际跑不起来。它的 call 会再开一次 query,系统提示要求自己当架构师、出计划、不要写代码。允许的探索工具列表里却包括 BashTool 和 FileWriteTool,和那句禁令直接打架。我倾向于把它看成留在源码里的实验,不能当成可用功能。

循环对工具几乎零知识。checkPermissionsAndCallTool 从第 365 行开始。先 inputSchema.safeParse。注释写,模型生成合法输入的能力出奇地差。Zod 失败就立刻变成 InputValidationError: 加错误信息,is_error 为真,函数 return,不会去跑工具。然后 normalizeToolInput,目前只处理 Bash,用字符串替换剥掉 cd ${cwd} && 前缀。再 validateInput,这是每个工具自己的业务校验,没读过文件、匹配不唯一、命令在禁名单里,都在这里挡下。再 canUseTool。最后才消费 call() 这个生成器。生成器可以 yield type: 'progress',只进界面。也可以 yield type: 'result',变成带 toolUseResult 的 UserMessage。工具名在表里找不到,yield Error: No such tool available: ${toolName}。入口处如果 abort 已经置位,yield CANCEL_MESSAGE,连 Zod 都不跑。

刚写工具的人容易把校验做成抛异常。这份代码把校验失败写成给模型看的英文。模型下一轮还能改参数再试。循环保持同一条路。

sequenceDiagram participant Q as query.checkPermissionsAndCallTool participant T as Tool participant P as canUseTool participant FS as 磁盘或 Shell 或 MCP Q->>T: inputSchema.safeParse alt Zod 失败 Q-->>Q: tool_result 带 InputValidationError else Q->>T: normalizeToolInput 目前只剥 Bash 的 cd Q->>T: validateInput alt 业务校验失败 Q-->>Q: tool_result 带可执行英文 else Q->>P: canUseTool alt 拒绝 P-->>Q: REJECT_MESSAGE 并 abort 整批 else 放行 Q->>T: call 异步生成器 T->>FS: 读或写或 exec T-->>Q: result 或 progress end end end

图 5 单次工具调用的闸门。省略了 progress 与工具名找不到。数据来自 query.ts 的 checkPermissionsAndCallTool。

共享状态里最要紧的是 readFileTimestamps。这是一张绝对路径到时间戳的表,活在这一次会话的 ToolUseContext 里。Read 成功后写入 Date.now()。Edit 和 Write 的 validateInput 拿它跟 stat.mtimeMs 比。表里没有这个路径,返回 File has not been read yet. Read it first before writing to it.。文件在读完之后又被用户或 linter 改过,返回要求再读一遍。这是乐观并发。没有文件锁,人在 IDE 里的编辑优先,模型会被拒,然后自我纠正。Bash 跑完以后还会再调一次 Haiku,从 stdout 里抽文件路径,异步回填这张表,避免 cat foo.ts 之后立刻 Edit 被误杀。NotebookEdit 没有做这套检查。改 .ipynb 可以绕过先读后改。这是一致性缺口。

FileRead 的 API 名是 View,给人看的名字是 Read。必须绝对路径。默认从第 1 行读到文件尾。文件不存在时会在同目录找同名不同后缀,问模型是不是想读那一个。非图片且大于 0.25MB、又没给 offset 或 limit,直接拒绝。文本读完仍超 0.25MB 会 throw。prompt 声称默认最多 2000 行、行长截 2000 字符。MAX_LINES_TO_READ 和 MAX_LINE_LENGTH 只活在 FileReadTool/prompt.ts,call 不引用它们。模型按 2000 行行事,实现会给整文件,直到 256KB。图片走 sharp,超 2000×2000 或按 base64 估算超 3.75MB 就缩放。normalizeFilePath 还修了 macOS 截图文件名里的窄空格。

给模型的文本带 cat -n 风格行号,6 个字符宽。给用户的 Ink 默认只渲 3 行,后面写 ... (+N lines)。人和模型拿到的输出字段不同。LS 更明显。给人的是 ASCII 树。给模型的那份末尾多一句,这些文件看起来像恶意软件吗,像的话必须拒绝继续。源码里有 TODO: Kill this tool and use bash instead,Bash 的 prompt 却禁止模型去 ls。树形输出和那句安全注脚,Bash 给不出来,所以这个工具还活着。

Grep 最容易看走眼。GrepTool.tsx 的 call 写死

const args = ['-li', pattern]

-l 只打印文件名,-i 忽略大小写。返回的是含匹配的路径,按 mtime 降序,最多 100 个。没有行号,没有匹配行。prompt 却写 Searches file contents using regular expressions。0.2.8 的用法是 Grep 定位候选文件,View 读内容,Edit 再改。后来的版本才吐 path:line:text。

ripgrep 按平台放在 vendor/ripgrep/ 下面,darwin / linux 的 arm64 和 x64,win32 只有 x64。ripgrep.ts 优先用 $PATH 里的 rg,找不到或者设了 USE_BUILTIN_RIPGREP 才用内置。macOS 启动时如果发现签名是 linker-signed,会跑 codesign --sign - 并摘掉 quarantine,否则 Gatekeeper 拒跑。选 rg 的理由很土。gitignore 和 .ignore 它自己处理。大仓库上 Rust 的并行扫比 Node 里 readdir 加正则快一个数量级。10 秒超时、1MB maxBuffer、abortSignal、exit code 1 当空数组,这些边界自己写要再铺 200 行。

FileEdit 的 API 名是 Edit。输入只有 file_path、old_string、new_string。prompt 要求 old_string 必须唯一标识那一处,前后至少带 3 到 5 行上下文,一次只能改一处。validateInput 按顺序做这些检查。

  1. old_string === new_string,空操作,拒绝。
  2. old_string 为空且文件已存在,创建冲突,拒绝。文件不存在则放行,走创建。
  3. 文件不存在且不是创建,拒绝,回复里可以附带一个相近文件名。
  4. 路径是 .ipynb,拒绝,改走 NotebookEdit。
  5. readFileTimestamps 里没有这个路径,拒绝。
  6. mtimeMs 比读的时候新,拒绝。
  7. 文件里找不到 old_string,拒绝。
  8. 找到超过一处,拒绝,并告诉模型补更多上下文。

不唯一就失败。失败文案是给下一轮采样的指令。行号补丁在有人同时改文件以后会整片错位,模型算术也不好。整文件覆盖写会烧掉输出 token,diff 对人不可读。网上能读到的 Aider 文档也走 search-replace,常见说法是允许多处和模糊匹配。我没有对着某个 Aider 版本的源码逐条核对,这里只当作印象对照。这份 0.2.8 代码写死了必须恰好一处,没有模糊匹配。代价是模型常要重试。好处是改错另一个同名函数几乎不会发生。

落盘前的替换用 String.replace(old, () => new)。写成函数,避免 new_string 里的 $& 或 $1 被当成特殊替换符。删除时如果原文下一行是空行,会连那个换行一起吃掉,少留一个空洞行。替换之后字符串没变,applyEdit 会 throw,和 validateInput 双保险。

getPatch 在 utils/diff.ts。它调 diff 包的 structuredPatch,上下文 3 行。& 和 $ 会先换成 <<:AMPERSAND_TOKEN:>> 和 <<:DOLLAR_TOKEN:>>,算完再换回来。注释写,这两个字符会把那个库搞糊涂。权限对话框里的 StructuredDiff,和模型拿到的带行号新文件切片,都从这份 hunk 来,只是渲染不同。

applyEdit 自己不落盘,只算出新文件和 hunk,权限对话框才能在写入前给人看 diff。成功写入后用新的 mtimeMs 更新时间戳,紧接着的下一次 Edit 不会被自己刚写的 mtime 误杀。改 CLAUDE.md 会打一条 tengu_write_claudemd,产品想知道模型有没有把约定写进项目记忆。

FileWrite 的 API 名是 Replace。命名和 Edit 反了。它是整文件后备。新建或者改动大到 old_string 框不住,模型应该走它。给模型的回执最多 16000 行,超出就塞 <response clipped>。

NotebookEdit 的 API 名是 NotebookEditCell。输入是绝对路径、从 0 起算的 cell_number、new_source、可选的 cell_type 和 edit_mode。模式有 replace、insert、delete。replace 会清掉该 cell 的 outputs 和 execution_count,改了源还留着旧输出是谎言。写回用 JSON.stringify(notebook, null, 1),1 个空格缩进,接近 Jupyter 常见格式。它问写权限,不问 readFileTimestamps。改笔记本可以绕过先读后改。这是一致性缺口。

Bash 接的是 PersistentShell。进程级单例,spawn(process.env.SHELL || '/bin/bash', ['-l']),-l 表示 login shell。stdin 是管道,环境变量里 GIT_EDITOR=true,防止 git commit 拉起 vim 把会话卡住。shell 崩溃会打 persistent_shell_exit,四个临时文件会被删掉,isAlive 置 false,下次 getInstance() 会新开一个。临时文件名是 /tmp/claude- 加 4 位十六进制随机数,再加 -status、-stdout、-stderr、-cwd。启动时如果 SHELL 是 bash 或 zsh,并且家里有对应的 .bashrc / .zshrc,会 source 一次。

之后所有命令都 eval 进同一个 shell,stdin 接 /dev/null,所以 npm init、git rebase -i 这种要人打字的命令活不成。prompt 也明令禁止 -i。完成信号是 status 文件非空,10 毫秒轮询一次。退出码 143 按 SIGTERM 处理。超时或 abort 用 pgrep -P $pid 找子进程再发 SIGTERM,login shell 自己留着。命令进队列,isExecuting 为真时后来的命令只能等。默认超时在 shell 层是 30 * 60 * 1000,也就是 30 分钟。工具 schema 里 timeout 默认不填,上限 600000 毫秒,也就是 10 分钟。prompt 里又写了未指定则 30 分钟。三处数字对不上。输出在工具层会截到大约 30000 字符,头尾各留一半。Ink 最多渲 50 行。

语法先用 bash -n -c 探一次。-n 只解析不执行。坏命令返回 128,不进持久 shell。这能挡住一部分拼错的引号,挡不住运行时才爆炸的东西。

模型默认会把环境理解成一台一直开着的终端。export FOO=1 下一轮还在,source venv/bin/activate 必须留下,cd 也必须留下。每次重新 spawn,工具说明书就得规定每条命令自己拼环境,模型会开始写超长 one-liner,更难审批。代价是 cwd 和环境变量会漏到后面的命令。call 之后如果 getCwd() 逃出启动目录树,会 setCwd 回去,并把这件事写进 stderr。validateInput 对显式 cd 也做同样限制,只许往启动目录的子目录走。

BANNED_COMMANDS 挡住 alias、curl、wget、nc、telnet、一批 HTTP 客户端和 chrome / firefox / safari。0.2.8 没有 WebFetch,模型出不了网,除非人批准名单外的等价物。禁令只看第一个空白分出来的词。/usr/bin/curl、command curl、python -c 'import urllib' 都不在名单里。git push 没禁,prompt 写 DONOT push,拦下来的地方在权限前缀。

splitCommand 用 shell-quote 的 parse,按 &&、||、;、;; 切开。管道、重定向、$()、反引号不在这个列表里。注释点名 Denim,模型爱在命令前垫一句 cd $cwd,不剥掉的话人会对无操作反复点头。Haiku 还负责给整条命令抽出可以记住的前缀。抽失败或者报 command_injection_detected,只接受整句精确命中。光秃的 git 不能当前缀,否则批准过 git status 之后,git push --force 会跟着进来。

AgentTool 的 API 名是 dispatch_agent,给人看是 Task。call 里直接 for await (const message of query(...))。默认工具集是 getReadOnlyTools() 再去掉自己。主代理的 prompt 写明,子代理不能 Bash、Write、Edit,要改文件就自己改。dangerouslySkipPermissions 为真时拿到全量,仍然去掉自己。isReadOnly() 第 186 行返回 true,旁边写着 // for now...。needsPermissions() 返回 false,权限下沉到子循环里的每个工具。因为它标了只读,主循环可以把多个 Agent 和 Grep 一起并发。系统提示也鼓励同时拉起多个。dangerouslySkipPermissions 打开时,子代理能写文件,调度器仍按只读并发。注释已经认账。

子代理的中间搜索通过 progress 画在屏幕上,最终 result 只把最后一条 assistant 的文本交回。主会话只付一份报告的 token。日志写 sidechain 文件。第一次 overwriteLog 才去取编号,并且 memoize,避免两个并发 Agent 抢同一个文件名。子代理拿不到斜杠命令。系统提示换成更短的 getAgentPrompt(),要求绝对路径,不提 git 提交剧本。

ThinkTool 的 call 不读盘,不改仓库。它把 thought 打一条 tengu_thinking 事件,回一句 Your thought has been logged.。prompt 写明灵感来自 tau-bench 的 think tool。复杂重构或者读测试失败时,把推理写成一次 tool_use,这段思路会作为 tool_result 留在对话里,后面做摘要时比较不容易被当成可删的啰嗦。界面上 AssistantToolUseMessage 对它特判,画成思考气泡。对人这是一段思考,对循环这是一次只读、免权限、立刻返回的工具调用。

MCP 工具是运行时印出来的。MCPTool.tsx 本身是空壳,name 是 mcp,call yield 空串。getMCPTools() 对每个已连接的 client 做 tools/list,然后对象展开

{
  ...MCPTool,
  name: 'mcp__' + client.name + '__' + tool.name,
  inputJSONSchema: tool.inputSchema,
  async *call(args) { ... }
}

循环、界面、权限、调度器都不知道这是远程工具。inputJSONSchema 让 claude.ts 跳过 zodToJsonSchema,把对方的 JSON Schema 原样送给模型。空壳的 isReadOnly() 是 false,needsPermissions() 是 true。远端哪怕只是一次只读查询,也会把这一整批工具打成串行,并且要人批一次。

StickerRequestTool 是彩蛋,走 Statsig 门 tengu_sticker_easter_egg。它调用 setToolJSX,把整个输入区换成一张邮寄表格。call 可以是一个等人填表的 Promise,循环照样等生成器。这也说明,工具的 call 可以暂时接管整块输入区,循环仍然只是在等生成器结束。

对回源码 · 本章引用的主要位置


第 6 章 安全靠问人,不靠操作系统限制进程

src/permissions.ts 第 18 到 27 行,SAFE_COMMANDS 是一个 Set,里面只有八个精确字符串。

git status
git diff
git log
git branch
pwd
tree
date
which

git status --porcelain 不在里面。which rm 也不在。没有沙箱的时候,带参数、看起来只读的命令都不可信。cat、ls、echo 一旦带参数就可以读密钥、覆盖文件、拼出管道。白名单短,是因为下面没有地板。后来的版本把只读命令做大,是因为下面已经有 sandbox-runtime(沙箱实现材料来自官方文档,不在本仓库)。把 2.x 的宽名单抄回 0.2.8 会错。

hasPermissionsToUseTool 默认拒绝。通过的路只有这几条。

通行证 记在哪 活多久
SAFE_COMMANDS 八条整句 源码里的 Set 写死,带参数就不算
allowedTools 里的 key ~/.claude.json 的项目桶 跨会话。claude approved-tools remove 可删
启动目录读权 进程内 Set Trust / setup 授出,会话结束没了
启动目录写权 进程内 Set 人点“本会话不再问”才授,不落盘
dangerouslySkipPermissions 命令行 必须过 Docker + 无网 + 非 root
配置里的 ["Bash"] 同一份 JSON 放行全部 bash。界面不提供

permission key 的拼法在第 253 到 266 行。Bash 带前缀时是 Bash(git commit:*),不带前缀时是 Bash(git status) 这种整句。其他工具默认用工具名。Edit 和 Write 走 savePermission 时会短路,改写内存里的目录集合,磁盘上不会出现一张永不过期的 FileWrite 通行证。

sequenceDiagram participant Q as query participant H as useCanUseTool participant P as hasPermissionsToUseTool participant UI as PermissionRequest participant U as 人 Q->>Q: Zod 然后 validateInput alt shouldSkipPermissionCheck 或 dangerouslySkip Q->>Q: 直接 tool.call else Q->>H: canUseTool H->>P: SAFE / 前缀 / 目录 / blanket Bash alt 已有契约 P-->>H: 放行 else 默认拒绝 H->>UI: setToolUseConfirm UI->>U: Yes / 记住这类前缀 / 拒绝 alt 记住 UI->>P: savePermission 或 grantWrite end UI-->>H: onAllow 或 onReject 加 abort end end

图 6 权限对话框怎么插进循环。省略了 Haiku 抽前缀失败时藏掉“记住”按钮。数据来自 permissions.ts 与 useCanUseTool.ts。

读权在 Trust 和 setup() 里已经授给整个启动目录。FileReadTool.needsPermissions 问的是 !hasReadPermission(path)。项目树里的 Read、Grep、Glob、LS 常常根本不弹窗。出了启动目录,读一样会走 Filesystem 对话框。只读工具不给 Always 按钮,避免把家目录一次性读权写进项目配置。

两套目录集合活在 utils/permissions/filesystem.ts 的模块级 Set 里,会话结束就没了。saveReadPermission 授一个父目录时,会先删掉集合里所有以它为前缀的子路径,避免集合里同时躺着 /proj 和 /proj/src。匹配用 absolutePath.startsWith(allowedPath)。若集合里存的是 /tmp/proj 且后面没有路径分隔符,字符串 /tmp/proj-evil 也会被当成前缀命中。这是读 startsWith 推出来的,我没有用真实授权字符串跑过。实际会不会触发,取决于 saveReadPermission 当时写入 Set 的绝对路径长什么样。path.resolve 能挡住带 .. 的相对路径。pathInOriginalCwd 也是 startsWith(originalCwd)。FileEdit 对话框只有路径落在启动目录里才出示“本会话不再问”。FileWrite 那份对话框漏了这次检查。

交互路径上,Zod 和 validateInput 过了以后才进 useCanUseTool。配置里已经有通行证,打点 tengu_tool_use_granted_in_config,直接放行。否则并行去取两样东西。tool.description(input),Bash 这时会再调一次 Haiku,用 5 到 10 个词描述这条命令,失败则退回 Executes a bash command。Bash 还会跑 getCommandSubcommandPrefix,把整条命令和每个子命令丢给 Haiku,按一份内嵌 policy spec 抽前缀。人拒绝时,resolve 的 message 是 REJECT_MESSAGE,并且立刻 abortController.abort()。注释写明,这样会触发 query 里的合成 assistant 消息,把同批还没开跑的工具一起停掉。刚写权限 UI 的人容易只拒当前这一下,让并列的 Write 继续跑。这里选择全停。

Haiku 抽前缀失败,或者判定有注入,commandPrefix 会被抹成 null,对话框里那个记住前缀的选项会消失。管道、重定向、注释这类复合命令,isUnsafeCompoundCommand 同样藏掉记忆。这次可以跑,不能变成前缀。一次 Yes 仍然把整条管道送进真 shell。审批对象是模型吐出的命令字符串。操作系统不会再拦一层系统调用。

配置里可以直接写 allowedTools: ["Bash"],放行全部 bash。注释说不在界面暴露。知道这条的人等于有第二套跳过开关。

--print 接到的权限函数是 hasPermissionsToUseTool。它不会弹出对话框。utils/ask.tsx 第 26 行注释写,按非交互使用,不会问人要权限,也不会再要输入。需要人批的写操作会以 is_error 的 tool_result 失败,除非 allowedTools 里已经有对应 key,或者开了 skip-permissions。管道场景相当于,能跑这条命令的人已经同意读启动目录。写仍然要预先授权。

MCP 服务器批准是另一条闸。.mcprc 可以进 git,克隆仓库就可能带上外部进程。内部用户在 Trust 之后还会再过一轮服务器审批,名单记在 approvedMcprcServers / rejectedMcprcServers。Esc 视为全拒。文案写明,批准服务器以后,每一次 tool call 仍会再问。0.2.8 这套审批只对 USER_TYPE=ant 打开。

后续官方文档(2026 年的 permissions 页)有一句,Permission rules are enforced by Claude Code, not by the model。这句话不是 0.2.8 源码里的注释,材料来自官方文档,不在本仓库。0.2.8 的行为与它相容。CLAUDE.md 和系统提示只能改变模型想做什么。想扩大权限,得改 allowedTools、点对话框,或者走那条带着 dangerously 的旁路。

下面几条是读代码推出来的机制,我没有动态打通。禁令只看第一个空白分词,包一层别的启动器就不会落在名单上。splitCommand 按 && || ; 切开,管道和重定向不在这个列表里,一次批准可以把整条复合命令送进真 shell。前缀 key 一旦写入 allowedTools,匹配的是命令前缀,不检查重定向目标。人离开座位以后,这层检查没有操作系统进程隔离垫底。

0.2.8 跑在本机,是因为卖点就是改你正在看的仓库。把代码送到云端容器再改,延迟高,也看不见本地已经启动的测试和开发服务器。权限 UI 占了很大一截 components/permissions/。我的判断是,问人看 diff 被做成了结对产品本身。文中没有访谈或使用数据托住这个判断。人一离开座位,这层检查就停了。这句同样是推断。

对回源码 · 本章引用的主要位置


第 7 章 界面用 React 状态订阅这个生成器

src/components/ 大约 7961 行,query.ts 516 行。数字差一个数量级。工具的 renderToolResultMessage 返回的是 React 节点。权限 diff、贴纸表单、/config 这种本地页面,都要有一个能托管任意 JSX 的壳。团队用 TypeScript 和 React,Ink 加上 Yoga wasm 在终端里做 Flexbox。Claude Code、后来的 Gemini CLI、Qwen Code 都走了这条路。

REPL 在 src/screens/REPL.tsx,725 行。提交以后大致是

for await (const message of query(
  ...,
  canUseTool,
  { abortController, setToolJSX, ... },
  getBinaryFeedbackResponse,
)) {
  setMessages(old => [...old, message])
}

同一份 messages 数组同时服务四件事。屏幕渲染。下一轮送给 API 的输入,先经过 normalizeMessagesForAPI。useLogMessages 写到本地 JSONL。src/messages.ts 的 getter 给 /bug 读当前会话。回调模型会让这四份副本慢慢漂走。

需要人的时刻,循环停在一个 Promise 上。useCanUseTool 把 resolve 塞进 toolUseConfirm 这个 state。屏幕上出现 PermissionRequest,按工具类型分发。Bash 看命令。FileEdit 和 FileWrite 先画 StructuredDiff。读出界走 Filesystem。MCP 和其他未知工具走 Fallback。人按 Yes,或者勾选以后同类命令不再询问,或者拒绝。Ctrl+C 和输入 no 都走拒绝,再 abort()。6 秒没操作,useNotifyAfterTimeout 会发桌面通知。实现里用了 setInterval,清理时却调用 clearTimeout,定时器可能漏掉。

输入框只在这些东西都空的时候出现。权限框、binary feedback、cost 阈值对话框、slash 命令挂上的本地 JSX,按优先级把输入框挤走。isLoading 为真时人也不能再塞下一句,除非按 Esc。

stateDiagram-v2 [*] --> idle idle --> querying: PromptInput 提交 querying --> streaming: query yield 消息 streaming --> querying: 继续 for-await querying --> permissionPending: 需要人批 permissionPending --> streaming: 允许 permissionPending --> idle: 拒绝或 abort querying --> binaryFeedback: ant 且采样 binaryFeedback --> streaming: 人选完 streaming --> idle: 生成器结束 querying --> idle: Esc idle --> selecting: 空输入按 Esc selecting --> idle: fork 或取消 idle --> costDialog: 费用到 5 美元 costDialog --> idle: Got it

图 7 REPL 用哪些 React 状态拼出互斥界面。省略了 slash 建议和粘贴占位符。数据来自 REPL.tsx 的渲染顺序。

屏幕从上到下抢输入的顺序是 Static 历史、transient 未完成工具、Spinner、toolJSX、BinaryFeedback、PermissionRequest、CostThresholdDialog、PromptInput、MessageSelector。

已完成的对话用 Ink 的 <Static> 打到终端滚动区。打出去以后 React 不再调和它们。Spinner 大约 120 毫秒一帧,只重绘 Static 下面那一小截。未完成的 tool_use 必须留在 transient 区,等对应的 tool_result 到达、unresolvedToolUseIDs 清空,下一次 memo 才把它编进 static 列表。debug 模式给 static 绿框、transient 红框。没有这一刀,长会话里 Yoga 布局加 wrap-ansi 会把整墙历史每秒重画十几次。

Esc 按场景分流。

| 当时在干什么 | 谁吃掉 Esc | 随后发生什么 | |---|---|---|---| | 正在跑 query | useCancelRequest | 清三槽,再 abort() | | 权限框挂着 | onAbort | resolve 拒绝,并 abort 同批工具 | | 空输入且有历史 | PromptInput | 打开 MessageSelector | | 输入框里有字 | useTextInput 双击 | 清空输入 | | MessageSelector 开着 | 选择器自己 | 关掉,不取消 query |

querySonnet 把同一个 signal 传给 HTTP。runToolUse 入口若已经 aborted,立刻吐 CANCEL_MESSAGE。整批工具跑完后再看一次 signal.aborted,是则 yield 打断句,不再递归。Bash、Grep、Glob、LS 把 signal 传进 PersistentShell 或 ripgrep,子进程会被 pgrep -P 找到然后 SIGTERM。

MessageSelector 列出的是去掉 tool_result、去掉 assistant 之后的 user 消息,末尾再塞一条代表当前输入的虚项。选中第 k 条会先停掉正在跑的循环,清屏,把消息数组切成那一条之前的前缀,把原文填回输入框,然后把 forkNumber 加一。日志写到新文件,旧文件原样留在磁盘。会话是只追加的事件流。回到某一句重说,做法接近 git checkout 加新分支。原来那份数组不会被中途改写。注释写着,还不能从某一次 tool_use 中途把循环踢起来,所以选择器把 assistant 滤掉了。

Ink 自带的 TextInput 只处理单行。按词移动、把长粘贴攒在一起、以及把光标位置交给外层,它都做不到。0.2.8 自己做了 Cursor。它是不可变值对象,里面是一段用 wrap-ansi 按 columns-1 硬折过的文本,外加 offset 和 selection。左右上下、行首行尾、按词跳、删到行首行尾,全部返回新的 Cursor。折行映射用 indexOf 回贴原文,失败就打一堆 debug 再 throw。多行加硬折行加列对齐的光标,必须自己量,这是不用自带输入组件的技术原因。

useTextInput 的键位跟 Emacs 和 readline 同一套。Ctrl-A / E 行首行尾,Ctrl-K / U / W 是 kill,Ctrl-P / N 先试着在折行里上下,动不了再翻历史。Enter 提交。前一个字符是反斜杠则换成换行。Meta+Enter 插入换行。Ctrl-V 在 darwin 上用 osascript 取剪贴板里的 PNG。Node 会把长粘贴拆成多帧,常见 1024 加一个尾巴。TextInput 见到超过 800 个字符,或者已经在粘贴窗口里,就攒 chunks,100 毫秒后一次性交给 onPaste。界面上只插入 [Pasted text +N lines],原文存在旁边,提交时再换回去。人改过占位符则不换。

输入以 ! 开头会切到 bash 模式,边框变色,提示符从 > 换成 !。空输入再按 Esc 或 Backspace 退回 prompt。这条命令走上面说的 processUserInput bash 分支,返回一对 user 加 assistant,不再进 query。斜杠命令按三种形态分。

type 谁执行 例子
prompt 模型。展开成 user 消息再进 query /init /review /pr-comments MCP prompts
local 进程内 JS,返回文本 /compact /cost /clear /listen
local-jsx Ink 子树,onDone 交还 REPL /login /config /help /doctor /bug

/init 要求模型写大约 20 行 CLAUDE.md,并吸收 Cursor 和 Copilot 的规则文件。产品想把教这个仓库怎么构建、怎么测试,做成第一次会话里的动作。

/review 默认信本机的 gh。/listen 用 osascript 点系统听写,只在 darwin 的 iTerm 或 Terminal 上出现,而且 isHidden,只给内部用户。/ctx-viz 按 <context name> 拆系统提示。Help 屏幕用 250 毫秒一段,分三次展开 modes、tasks、命令列表,避免一帧打出整墙字。CustomSelect 是从 @inkjs/ui 叉出来的,选项存在双向链表 OptionMap 里,权限框、费用框、binary feedback、Doctor 都靠它。

0.2.8 已经能看见 Ink 的代价。没有虚拟列表,长 diff 全是 Box 和 Text 叶子。messages 常驻内存,normalizeMessages 给每个 block 新 uuid。getTheme() 和 getGlobalConfig() 在渲染路径上同步读盘,REPL 自己的注释承认每个按键都会读一次。Windows 上 Spinner 第三帧要改成 *,注释写某种 emoji 会带绿底。标题在 win32 改 process.title,别处写 OSC 0。人看到的回复是整段弹出,Spinner 用一堆随机动词填等待时间,Clauding、Reticulating、Honking 都在词表里。

对回源码 · 本章引用的主要位置


第 8 章 登录、观测、更新,这些东西让预览能发出去

斜杠命令表在 src/commands.ts。getCommands 先并入 MCP 的 prompts,再拼静态表,最后 filter(isEnabled)。Bedrock 和 Vertex 下没有 login / logout。ctx_viz、resume、listen 只给 USER_TYPE=ant。release-notes 写了,isEnabled 却是 false。approved-tools 挂在 CLI 子命令上。REPL 里的斜杠表没有这一项。

MCP 客户端在 src/services/mcpClient.ts,559 行。配置三层,后者覆盖前者。

层 写在哪 谁能改 连上之前还要不要再批
global ~/.claude.json 的 mcpServers 人自己 按工具再问
.mcprc 项目目录,可进 git 外部用户不能写这个 scope 内部用户要过服务器审批
project 项目配置里的 mcpServers 覆盖前两层 按工具再问

连接默认走 StdioClientTransport,type === 'sse' 才走 SSE,5 秒超时。getClients 也被 memoize。CI 且非测试直接返回空数组,注释写 npm run verify 否则会挂。连上以后 tools/list 印成 mcp__{server}__{tool},prompts/list 印成同名斜杠命令。反向入口是 claude mcp serve,src/entrypoints/mcp.ts 把 Agent、Bash、读写改、Glob、Grep、LS 和 /review 暴露出去。同一套工具,换了传输方向。CallTool 那条路径的注释写着,还没用 zod 校验输入。第三种活法的契约比 REPL 薄。

OAuth 在 src/services/oauth.ts。startOAuthFlow 同时准备两条 URL。一条回调 localhost:54545/callback,一条手动粘贴,路径是 /oauth/code/callback。打开浏览器之后,本机 HTTP 等 code。3 秒后界面切到粘贴授权码,格式是 authorizationCode#state。换 token 之后再 POST 一次,把返回的 raw_key 写入 primaryApiKey。成功页指向 console.anthropic.com/buy_credits。scope 是 org:create_api_key 加 user:profile。OAuth 用来签发 API key,之后请求走 key,不走 access token。SSH 或者没有图形界面时,自动回调会失败,所以粘贴是主路径之一。Ink 会把长 URL 折行,Safari 点开会断,授权 URL 用 <Static> 并且 width={1000} 钉死。

Statsig 打到 statsig.anthropic.com/v1/。Node 没有 window,browserMocks.ts 补了 window 和 navigator,beforeunload 接到 process.exit。状态落在 ~/.claude/statsig/。每条事件强制带 sessionId、userType、model、betas。外部用户对 Statsig 几乎是匿名设备,email 只在 ant 才填。USER_TYPE=external 时,log.ts 的 append 和 overwrite 直接 return,会话不写磁盘。内部用户按项目目录把 messages、errors、mcp-logs 写到 cache 路径,文件名里编码 fork 号和 sidechain 号,/resume 靠这个工作。

VCR 只在 NODE_ENV === 'test' 里包住 API 调用。消息会做脱水,把 cwd、数字、耗时换成占位符,SHA1 前 6 位拼成 ./fixtures/xxx-yyy.json。有夹具就回放,没有夹具且不在 CI 就真打 API 并落盘。VCR 只服务测试夹具,生产请求不走它。

assertMinVersion 读 Statsig 动态配置里的 minVersion,当前版本更旧就打印 claude update 然后 process.exit(1)。入口里调了两次。AutoUpdater 组件每 30 分钟 npm view,必要时 npm i -g。锁文件在 ~/.claude/.update.lock,5 分钟过期,带 PID。没有全局写权限就指向 claude doctor,Doctor 屏幕提供手动改权限、新建 npm prefix、或者忽略到下次。预览软件可以把过旧客户端踢下线。

http.ts 只有 7 行,User-Agent 是 claude-cli/${VERSION} (${USER_TYPE})。注释警告,日志过滤依赖这个串,改了会丢数据。

对回源码 · 本章引用的主要位置


第 9 章 后来补上的东西,不要读回这一版

0.2.8 已经有的,和后来才补上的,不要混在一张时间里。

0.2.8 这份快照里已经有 后来 2.x 才补上、不要读回这里
递归的 query queryLoop、七处 continue、五层自动压缩
统一的工具对象 WebFetch、LSP、Todo、内容级 Grep、四十多个工具
Trust Dialog、八条白名单、前缀记忆 deny/ask/allow 规则、bash AST、操作系统沙箱、classifier
CLAUDE.md 祖先链 Skills、Hooks、PreToolUse、auto memory
MCP 从第一天接进同一张表 企业托管 MCP、按需加载工具定义
Ink REPL,消息只追加,重说就 fork token 打字机、虚拟滚动、桌面和 IDE 同一循环
Statsig 和内部左右对照 构建期 DCE、client attestation、cache break 检测

右栏来自后续官方文档和公开泄露分析(例如 Siddhant Khare 读 2.x 源码的文章),材料来自官方文档,不在本仓库。我没有把 2.x 源码和本仓库做逐文件 diff。上表只防止把后来的设计读回 0.2.8。

公开文档里的现行 Claude Code 仍然用工具调用循环,仍然在运行时做权限,仍然读 CLAUDE.md。这些是产品文档层面的连续性,不能用来证明 2.x 的 query.ts 还是这份 516 行递归生成器。

anon-kode 曾经把这份 harness 改成可换模型,说明换 Sonnet 换 GPT,循环和工具表还能转。仓库后来被拿下,说明源码本身受版权约束。这份目录适合用来读设计,不适合当成可再分发的产品。

对回源码 · 右栏材料来自后续官方文档与公开泄露分析,不在本仓库;左栏各项对应的 0.2.8 文件见第 2 至 8 章的章末锚点。


第 10 章 我读完以后更相信的几件事

主循环应该短。516 行里已经塞进了左右对照、并发调度、校验、权限和错误截断。再往里塞压缩、沙箱、钩子,文件会变成后来的 queryLoop。编排框架会把取消变成取消图上的一个节点。人坐在终端前按 Esc,需要的是生成器的 return。

工具失败时返回的文字,本身就是下一轮提示。Edit 找到 3 处匹配,会告诉模型补上下文。Read 发现文件不存在,会问是不是另一个后缀。校验失败走 tool_result,is_error 为真。模型下一轮还能继续。把这些写成抛异常,循环就要开始长 switch。

如果你现在要写第一个能改文件的 Agent,这份代码里有四处最值得先搬,而且最好按依赖顺序搬。

先搬 query 这种单一循环。模型说话,抽出 tool_use,跑工具,把 tool_result 拼回去,再问模型。状态机框架可以后放。人按取消时,你需要的是生成器的 return 加一条写死的英文。

工具对象上同时挂 schema、call 和给人看的渲染。循环里不要出现 if (name === 'Bash')。新能力写成新对象,登记进列表。

校验失败要写成给模型看的 tool_result。Zod 失败、文件没读过、匹配到三处,都用英文告诉模型下一步怎么改。异常抛到循环外面,循环就会开始长分支。

权限函数从循环里注入。交互模式弹窗,管道模式只查配置,子代理用只读工具表。同一条循环,三种刹车。

isReadOnly() 这种布尔值太粗。AgentTool 对调度器撒谎。MCP 一刀切只写。一批工具里混进一个撒谎的只读标记,并发策略就会错。更老实的做法是问这一次 call 会不会写,或者按连续只读段切批。刚入门时先做到“全只读才并行”,比过早切批安全。0.2.8 自己也停在这里。

没有操作系统地板时,白名单只能短。八条精确字符串看起来可笑,下面没有 seccomp 的时候它是诚实的。前缀记忆省掉了很多弹窗,爆炸半径是整台机器。若只改 0.2.8、不引入 2.x 那套 2500 行解析器,我会先加一条最薄的地板。工作区可写,其余只读,默认断网。问人的对话框留下,当产品。前缀不再授予带重定向和管道的命令。

src/Tool.ts 缺失、Architect 焊死、Grep 的 prompt 和实现分叉、FileRead 的 2000 行上限只写在说明书里、Bash 超时三个数字对不上、NotebookEdit 没有先读后改、runToolUse 的 catch 可能丢结果。这些我都看成预览期没补上的缝。它们没有另外一套设计哲学。脏主要留在 cli.tsx 和工具实现里,没有脏进循环的主干。

我没有在本机把 0.2.8 重新跑起来。权限路径是读代码推出来的。Tool.ts 的接口是反推,和当年内部那份文件可能有出入。thinking 对外关闭,是源码里的条件,不排除打包时用宏换过。2.x 对照没有并读源码。覆盖率记在 drafts/08-coverage.md,正文本体不重复那张表。这些是我拿不准的地方。

审稿意见全文与中间笔记随分析底稿归档(见套装 README「材料出处」一节)。要核对某一句,优先回 src/query.ts、src/permissions.ts、src/tools.ts 和对应工具目录。

对回源码 · 本章提到的覆盖率表与审稿意见随分析底稿归档(见套装 README「材料出处」,不在源码仓库)。正文论断的回核入口:src/query.ts、src/permissions.ts、src/tools.ts 与各工具目录。


术语表

以下术语按本文语境解释,不追求通用定义。