基于Claude Code源码深度分析学习经验分享(附源码文件)
Claude Code 源码逆向工程笔记
基于
claude-code-main.zip(v2.1.88,2026-03-31 泄露)为主,结合cli.js.map、cc-recovered重建工程、src.zip及社区深度分析整理。
源码版权归 Anthropic 所有,本文仅作技术学习与研究用途。
写在前面
这份笔记不是一份"目录结构说明书"。1906 个文件、51 万行 TypeScript,逐个罗列没有意义。真正值得记录的,是这套代码背后那些非显而易见的设计决策——为什么选 A 而不是 B,为什么看起来"多此一举"的代码其实是刻意的工程选择,以及那些只有读源码才能发现的"幽灵逻辑"。
读完之后,你应该能回答这些问题:
- Claude Code 的主循环为什么不是递归而是
while(true)状态机? - 为什么 API 返回的错误会被"扣留"而不是立即抛给调用方?
- 一个 bash 命令要经过多少层安全检查才能真正执行?
- 记忆系统为什么用 Markdown 而不是向量数据库?
- 为什么说 Claude Code 的核心竞争力不是模型而是 Harness?

第一章 从"聊天包装器"到 Agent Harness
外界最初把 Claude Code 理解为"Claude API 的终端客户端"。泄露的源码彻底推翻了这个认知。
cli.js.map 暴露了 4756 个源文件的完整路径,其中 1906 个是 Claude Code 自身的 TypeScript/TSX 源码,其余 2850 个是 node_modules 依赖。1906 这个数字本身就在说一件事:这不是一个 thin wrapper,而是一个中型应用。
1.1 Agent Harness 是什么
读完全部源码后,最能概括 Claude Code 本质的一个词是 Agent Harness——一个为 LLM 穿上"外骨骼"的工程框架。LLM 本身只能"思考"和"输出文本",Harness 负责把文本转化为行动,把行动结果反馈给 LLM,并在这整个过程中施加安全约束、管理上下文、协调多 Agent 协作。
没有 Harness 的 LLM: 用户 → LLM → 文本回答
有 Harness 的 LLM: 用户 → Harness → LLM → Harness 执行工具 → 结果回传 LLM → 继续...
↑ ↓
└──────────── 循环 ──────────────────┘
Claude Code 的 Harness 由五层构成:主循环编排、工具控制面、任务运行时、记忆工程、远程权限桥接。每一层解决一个其他层无法替代的问题。
1.2 五条贯穿全局的设计原则
通读 51 万行代码后,能提炼出五条几乎在每个模块都出现的设计原则:
原则一:工具即能力边界。 Agent 能做什么,完全由工具集决定,没有任何后门。读文件必须用 FileReadTool,写文件必须用 FileEditTool,执行命令必须用 BashTool。新增能力等于新增工具,这保证了所有操作都可审计、可拦截。tools.ts 的导出列表就是 Agent 的完整能力清单。
原则二:Fail-closed 安全默认。 所有安全相关的默认值都是最保守的。工具默认不可并行(isConcurrencySafe: false)、默认非只读(isReadOnly: false)、权限默认需要确认。宁可牺牲性能,绝不冒安全风险。buildTool 工厂函数注入的默认值体现了这一点——不确定就当作不安全处理。
原则三:Context Engineering 优于 Prompt Engineering。 Claude Code 不是写一段固定的 system prompt 告诉模型"你是谁",而是在每轮对话中动态组装完整的上下文:系统提示、Git 状态、记忆文件、工具使用摘要、附件消息、压缩后的历史——所有这些都精心编排,让模型在任何时刻都能拿到最关键的信息,同时不浪费 token。
原则四:编译时消除优于运行时判断。 通过 Bun 的 feature() 宏,未启用的功能在构建时被完全移除,最终 bundle 中连一行相关代码都不会有。44 个功能标志控制着从 KAIROS 自主模式到终端宠物的所有功能。外部发布的包完全不包含 USER_TYPE === 'ant' 分支的代码。
原则五:廉价检查优先。 整个 codebase 到处都是"先用 O(1) 判断过滤大多数情况,再做 I/O"的思维。autoDream 的注释说得最直白:先检查时间门槛和会话数量,再决定是否启动昂贵的记忆整合子 Agent。
第二章 主循环:一个 1729 行的状态机
src/query.ts 是整个系统的心脏,1729 行,一个 while(true) 循环。很多人会问:为什么不用递归?
2.1 显式状态机 vs 隐式递归
传统的 Agent 循环通常写成递归:async function query() { ... await query() }。Claude Code 把它改成了 while(true) + 显式 State 对象:
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
turnCount: number
transition: Continue | undefined // 上一次迭代的跳转原因
}
每次"继续"都是 state = {...}; continue,而不是递归 query()。这样做有两个关键好处:
第一,避免深递归导致 stack overflow。一个复杂任务可能需要几十轮工具调用,递归调用栈会越来越深。
第二,所有"为什么继续"的原因都可观测。state.transition.reason 明确记录了 7 种继续原因:
| transition.reason | 含义 |
|---|---|
next_turn |
正常下一轮(有工具调用需要执行) |
reactive_compact_retry |
响应式压缩后重试 |
collapse_drain_retry |
上下文折叠后排空后重试 |
max_output_tokens_escalate |
输出 token 超限,升级处理 |
max_output_tokens_recovery |
输出 token 超限,恢复后重试 |
stop_hook_blocking |
停止钩子阻塞,需要继续 |
token_budget_continuation |
Token 预算未用完,注入 nudge 继续 |
这些原因不仅用于日志,还用于测试断言——开发者可以精确测试"当 prompt_too_long 发生时,系统是否走了 reactive_compact_retry 路径"。
2.2 两层循环:QueryEngine 与 queryLoop
Claude Code 将 Agent 循环拆分为两层:
外层 QueryEngine(QueryEngine.ts):负责会话级管理——多轮状态持久化、SDK 协议适配、用量统计、会话恢复。它是整个对话生命周期的协调者。
内层 queryLoop(query.ts):负责单轮执行——API 调用、工具执行、错误恢复。每次迭代就是一次"LLM 推理 + 工具执行"。
两者通过 AsyncGenerator 连接,queryLoop yield 出消息,QueryEngine 消费。这个设计带来三个关键优势:
- 背压控制:调用方按需消费,不会被消息洪水淹没
- 中断语义:generator 的
.return()可以级联关闭所有嵌套 generator,取消操作自然传播到子模块 - 流式组合:子 Agent 的
runAgent()也是 AsyncGenerator,可以直接嵌套在父 Agent 的流中
2.3 消息预处理管线:从轻到重的五层压缩
每次 API 调用前,消息要经过一条五阶段处理管线。核心原则是"从轻到重"——先做廉价的本地操作,再做需要 API 调用的重操作:
| 层次 | 文件 | 触发条件 | 操作粒度 | 成本 |
|---|---|---|---|---|
| microcompact | compact/microCompact.ts |
每轮检查 | 单工具结果替换/删除 | 本地,无 API |
| snip | compact/snipCompact.ts |
HISTORY_SNIP flag |
删除中间历史片段 | 本地,无 API |
| contextCollapse | contextCollapse/ |
CONTEXT_COLLAPSE flag |
细粒度折叠+还原 | 本地,无 API |
| autocompact | compact/autoCompact.ts |
token > 阈值 | 全对话压缩为摘要 | 需 API 调用 |
| reactiveCompact | compact/reactiveCompact.ts |
收到 PTL 错误 | 被动触发压缩 | 需 API 调用 |
microcompact 是最精妙的一层。它不是简单地截断历史,而是定向移除旧的高频工具输出(Read/Bash/Grep/WebSearch 等),通过缓存编辑的方式尽可能保住前缀缓存。这意味着压缩操作不会破坏之前 prompt cache 的命中率——在 LLM API 按缓存命中计费的模型下,这一点直接影响成本。
autocompact 有一个被生产数据驱动的熔断机制:
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
这段注释记录了一个真实的生产事故:1279 个会话出现了 50+ 次连续压缩失败,最多一个会话失败了 3272 次,每天浪费 25 万次 API 调用。熔断阈值被设为 3——连续失败 3 次就停止重试。这是一个"用真实生产数据驱动代码决策"的典型案例。
2.4 错误扣留机制:不把中间错误暴露给调用方
这是整个主循环中最反直觉但最精妙的设计。
当 API 返回 prompt_too_long(PTL)或 max_output_tokens 错误时,Claude Code 不会立即把错误 yield 给调用方,而是先"扣留"(withhold)它,内部尝试恢复:
API 返回 PTL 错误
↓
标记为 withheld,不 yield
↓
尝试 collapse drain(折叠排空)
↓ 仍失败
尝试 reactive compact(响应式压缩)
↓ 仍失败
才释放错误给调用方
源码注释解释了原因:
“Yielding early leaks an intermediate error to SDK callers (e.g. cowork/desktop) that terminate the session on any error field.”
如果立即抛出 PTL 错误,上层消费者(如桌面应用、远程协作)会直接终止会话。但 PTL 往往是可以通过压缩恢复的——扣留它,尝试恢复,恢复成功则用户无感知。
2.5 StreamingToolExecutor:分区并发模型
当模型返回多个工具调用时,Claude Code 不会简单串行执行,也不是无脑并行,而是实现了分区执行:
工具 A 到达(isConcurrencySafe=true) → 立即开始执行
工具 B 到达(isConcurrencySafe=true) → 立即开始执行(并行)
工具 C 到达(isConcurrencySafe=false) → 等 A、B 完成后串行执行
结果按接收顺序输出(即使 B 比 A 先完成)
连续的并发安全工具(如多个 FileRead)组成一个并行分区,内部最多 10 个并发执行。遇到非并发安全工具(如 FileEdit、Bash),结束当前分区,开启新的串行分区。
更关键的是 siblingAbortController:Bash 工具出错时 abort 兄弟进程,但不触发父控制器,不会终止整个 turn。这是一个精细的隔离设计——一个工具失败不应该拖垮同批次的其他工具。
2.6 Token Budget 与 nudge 消息
当模型自然停止但 token 预算没用完时,系统会注入一条 nudge 消息让模型继续工作:
"Output token limit hit. Resume directly — no apology, no recap of what you
were doing. Pick up mid-thought if that is where the cut happened."
这条消息的措辞经过了精心设计——“不要道歉,不要回顾,直接从断点继续”。同时有递减收益检测:如果连续 3 次增量都小于 500 token,就停止,避免无限循环。
2.7 Tombstone 消息与模型回退
当模型回退(fallback)发生时,已经 yield 给 UI 的半完成消息必须被"撤回"。Tombstone 是 UI 层面的删除指令——不是删除数据,而是告诉渲染器"这条消息已经作废,不要显示了"。
模型回退流程:
- 捕获
FallbackTriggeredError - 切换到 fallbackModel
- ANT-only:清除 thinking block 签名(capybara 签名在 opus 上会 400)
- 创建新的
StreamingToolExecutor(防止孤立的 tool_results 泄漏) - 发送 Tombstone 撤回已有输出
- 重新开始流式调用
第三章 工具系统:40 个工具的标准化抽象
3.1 Tool 接口:30+ 方法的六组职责
所有工具都实现统一的 Tool 接口,包含 30 多个方法,分为六组:
| 职责组 | 关键方法/字段 | 说明 |
|---|---|---|
| 定义描述 | name, description, inputSchema |
工具的身份和输入格式 |
| 安全属性 | isReadOnly, isConcurrencySafe, isDestructive |
安全决策的依据 |
| 执行逻辑 | call() |
核心执行,异步生成进度 |
| 权限检查 | checkPermissions() |
自定义权限校验 |
| UI 渲染 | renderToolUse, renderToolResult |
终端 UI 自定义 |
| 生命周期 | preHook, postHook |
执行前后钩子 |
buildTool 工厂函数注入的安全默认值值得注意:
| 属性 | 默认值 | 设计动机 |
|---|---|---|
isConcurrencySafe |
false |
假设不能并行,防止并发冲突 |
isReadOnly |
false |
假设会写入,触发更严格的权限检查 |
isDestructive |
false |
不假设破坏性,避免过度警告 |
checkPermissions |
allow |
默认放行,由外层权限系统兜底 |
这些默认值体现了 fail-closed 原则——不确定就当作不安全处理。工具开发者需要显式声明自己的工具是并发安全的或只读的,否则就按最保守的方式执行。
3.2 ToolUseContext:40+ 字段的运行时环境
每个工具的 call() 方法都会收到一个 ToolUseContext 对象,包含 40 多个运行时字段。工具不是纯函数,它需要这些上下文来实现各种功能:
- 文件读取状态缓存:让 FileEditTool 可以验证"不能编辑未读过的文件"
- 取消信号:让 BashTool 的长时间命令可以被用户中断
- UI 渲染回调(
SetToolJSXFn):让工具可以推送自定义 React 组件到终端 - 内容替换状态:控制工具结果的 token 消耗
- 文件历史:支持
/rewind命令撤销文件修改
SetToolJSXFn 的类型定义揭示了工具 UI 的完整能力:
export type SetToolJSXFn = (args: {
jsx: React.ReactNode | null // 要渲染的 React 组件
shouldHidePromptInput: boolean // 是否隐藏输入框
shouldContinueAnimation?: true // 是否继续加载动画
showSpinner?: boolean
isLocalJSXCommand?: boolean
isImmediate?: boolean
clearLocalJSX?: boolean
} | null) => void
这意味着每个工具可以自定义其终端 UI——从简单的文本输出到复杂的交互式界面,都通过 React 组件实现。这就是为什么 Claude Code 的终端 UI 如此丰富——389 个 UI 组件都是 React 组件,由 Ink 渲染到终端。
3.3 contextModifier:受控的上下文修改
ToolUseContext 中有一个 contextModifier 字段:工具执行后可以通过它修改后续的上下文(比如切换工作目录)。但这个修改只对非并发安全的工具生效,避免并发执行的工具互相干扰。这是一个容易被忽略但极其重要的设计——并发安全和状态修改天然冲突。
3.4 BashTool:18 个文件的安全堡垒
BashTool 是整个工具系统中最复杂的一个,tools/BashTool/ 目录包含 18 个文件。Shell 命令的表达力无限,但安全约束必须严格。它实现了 8 层安全检查:
第一层:AST 级命令解析。 用 tree-sitter 解析 Bash 命令的 AST,提取每个子命令,对每个子命令独立检查。这防止了 cd / && rm -rf / 这种复合命令绕过检查——cd / 和 rm -rf / 会被分别检查。
第二层:Flag 级白名单验证。 不只检查命令名,还验证每个 flag 的值类型。比如 xargs -I 和 -i 的行为不同,需要分别验证,防止 flag 注入攻击。
第三层:命令注入检测。 25+ 种检查,覆盖命令替换($(...))、进程替换(<(...))、Zsh 危险命令、控制字符、Unicode 空白字符等所有注入向量。
第四层:权限规则匹配。 先匹配 alwaysDeny 规则,再匹配 alwaysAllow,最后检查用户已批准的命令前缀。顺序很重要——deny 优先于 allow。
第五层:沙箱隔离。 通过 sandbox-exec 限制文件系统读写和网络访问。沙箱内的命令即使没有 allow 规则也可以执行,但 deny 规则仍然生效。
第六层:超时控制。 自动截断过长时间运行的命令。
第七层:大结果外联。 超过阈值的命令输出保存到本地磁盘,返回引用而非内容,避免消耗上下文窗口。
第八层:语义分类。 对命令进行语义分类(搜索、读取、修改等),用于 UI 的折叠优化。
3.5 FileEditTool:两个看似严格但救命的约束
FileEditTool 实现了精准的搜索-替换编辑,有两个严格的安全约束:
约束一:old_string 必须在文件中唯一匹配。 如果有多处匹配,编辑直接失败,要求用户提供更多上下文。这看似不便,但彻底避免了 Agent 修改错误位置的风险。
约束二:不能编辑未读过的文件。 系统会缓存哪些文件被读过(readFileCache),如果模型试图编辑未读文件,直接拒绝。这防止了 Agent 在不了解文件内容的情况下盲改。
这两个约束体现了 Claude Code 对文件编辑的核心理念:宁可要求模型多读一次文件,也不允许它在不确定的情况下修改。
3.6 AgentTool:6 个内置专业 Agent
tools/AgentTool/built-in/ 目录包含 6 个专业化的内置 Agent,每个都有明确的职责边界:
| Agent | 职责 | 工具限制 | 模型 |
|---|---|---|---|
| General Purpose | 通用任务,默认执行者 | 全工具 | 主模型 |
| Explore | 只读代码库探索 | 禁止一切写操作 | 外部用 Haiku,ANT 用更强模型 |
| Plan | 架构规划,输出实现方案 | 只读 | 主模型 |
| Verification | 对抗性验证,独立审查 | 只读 | 主模型 |
Explore Agent 的系统提示词明确禁止四类操作:创建新文件、修改现有文件、删除文件、移动或复制文件。它被锁死在只读模式,避免探索过程中的误操作。
Verification Agent 是一个特别精妙的设计——非简单任务完成后,必须由独立的对抗性 Agent 验证,自己的测试不算数,只有 verifier 说 PASS 才算完成。 这防止了 Agent "自说自话"地宣布任务完成。
第四章 权限系统:三层分发的异步竞速
4.1 权限状态机:三层规则
权限上下文 ToolPermissionContext 包含三层规则:
alwaysAllowRules: ToolPermissionRulesBySource // 自动批准
alwaysDenyRules: ToolPermissionRulesBySource // 自动拒绝
alwaysAskRules: ToolPermissionRulesBySource // 总是询问
规则可以来自不同来源(user、project、policy),按优先级合并。关键设计是 deny 优先于 allow——project 级别的 alwaysDeny 可以覆盖 user 级别的 alwaysAllow。这防止了用户设置的宽松规则被恶意项目的规则利用。
4.2 三层处理器:按 Agent 类型分派
权限系统按 Agent 类型分派到不同的处理器,每层有独立的逻辑但共享 PermissionContext 和 resolve-once 竞态保护:
Coordinator Worker Handler(协调器 Worker):
请求 → permission hooks(快) → classifier(慢,仅 bash) → 降级到交互式
Swarm Worker Handler(Swarm Worker):
请求 → classifier → mailbox 转发给 leader → 等待 leader 响应 → 降级到交互式
注意:在发送 mailbox 请求之前先注册回调,避免 leader 在回调注册前就响应的竞态条件。
Interactive Handler(主 Agent)——最复杂:
请求 → 推入确认队列
↓
异步竞速:
├── 权限 hooks 后台运行
├── bash classifier 后台运行
└── 用户交互对话框
↓
resolve-once 守卫确保只 resolve 一次
4.3 ResolveOnce:原子性竞态保护
ResolveOnce<T> 是权限系统的核心并发原语:
class ResolveOnce<T> {
private claimed = false
private delivered = false
claim(): boolean {
// check-and-mark,在 await 之前执行
if (this.claimed) return false
this.claimed = true
return true
}
deliver(value: T) {
if (this.delivered) return
this.delivered = true
this.resolve(value)
}
}
claim() 在 await 之前执行(同步),确保只有一个异步分支能赢得竞态。即使 hook 和用户交互同时返回结果,也只有一个会被 deliver。这解决了 JavaScript 异步编程中最常见的竞态问题。
4.4 权限前置过滤:从源头消除风险
Claude Code 的权限控制不是"事后拦截",而是在工具池装配阶段就进行前置过滤。filterToolsByDenyRules 函数在工具注册时就剔除不符合权限规则的工具,确保模型从一开始就看不到高风险工具。
这是一个"前置塑形"的设计——与其让模型尝试调用某个工具再被拒绝,不如直接不让模型知道这个工具的存在。前者浪费一轮 API 调用,后者零成本。
4.5 AnalyticsMetadata:把数据治理编码进类型系统
整个 codebase 中有一个令人印象深刻的类型名:
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = ...
这个类型名本身就是一个契约——要往遥测里发的每个字符串都必须被开发者手动确认不包含代码或文件路径。这不是一个 lint 规则或代码评审检查清单,而是编译期强制执行的类型约束。把数据治理规则编码进类型系统,是一种"让正确的事情成为唯一选择"的工程哲学。
第五章 记忆系统:三层递进的知识管理
5.1 为什么不用向量数据库
Claude Code 的记忆系统没有用数据库或向量存储——每条记忆是一个独立的 .md 文件,带有 YAML frontmatter。这个选择并非偶然:
- 用户可以直接用任何文本编辑器查看、编辑、删除记忆文件
- Git 可以追踪记忆变更
- 跨工具使用零摩擦
- 人类可读优先
这是一种"透明化优先"的设计哲学。向量数据库可能召回更准,但用户无法理解、无法编辑、无法审计。Claude Code 选择了可控性 over 精确性。
5.2 三层记忆架构
| 层 | 目录 | 时间维度 | 核心问题 |
|---|---|---|---|
| 会话记忆 | 内存中 | 今天 | 如何在有限上下文窗口内保留关键信息 |
| 持久记忆 | ~/.claude/projects/<project>/memory/ |
这个项目 | 如何跨会话保存和召回知识 |
| 团队记忆 | memory/team/ |
整个团队 | 如何让多人积累的知识形成共识 |
5.3 四类记忆与六类排除
持久记忆分四类:user(用户偏好)、feedback(用户反馈)、project(项目决策)、reference(外部参考)。feedback 和 project 类型有强制结构要求——必须包含 **Why:** 和 **How to apply:** 两行。没有上下文的结论容易被错误应用。
更重要的是六类明确不保存的内容:代码模式、架构、git 历史、调试方案、CLAUDE.md 已有的内容、临时任务细节。即使用户明确要求保存,系统也会拒绝。设计理念是:凡是可以从代码仓库本身推导出的信息,不应该存进记忆。 记忆保存的是"人"的知识(偏好、背景、决策原因),而不是"代码"的知识。
5.4 MEMORY.md:索引而非记忆本身
MEMORY.md 是整个持久记忆的"目录页",不是记忆内容本身。它有严格的上限:200 行、25,000 字节。超限时不是静默截断,而是追加截断警告,确保用户知晓。
截断方式是双重截断——先截行(自然边界),再截字节(在最后一个换行符处截断,避免切断行)。这种"双重保险"确保截断后的内容仍然是有效的 Markdown。
5.5 路径安全:七层防护
memdir/paths.ts 的 validateMemoryPath 函数实现了七层路径安全防护:
| 安全层 | 检查内容 | 防御目标 |
|---|---|---|
| 绝对路径检查 | 拒绝相对路径 | 路径遍历攻击 |
| 根路径检查 | 拒绝长度 < 3 的路径 | 写入系统根目录 |
| UNC 路径检查 | 拒绝 \\server\share |
NTLM 凭证泄露 |
| Null 字节检查 | 拒绝包含 \0 的路径 |
路径截断攻击 |
| Tilde 展开限制 | 拒绝 ~、~/、~/.. |
匹配整个 HOME 目录 |
| 项目设置排除 | .claude/settings.json 不能设 autoMemoryDirectory |
恶意仓库写入 ~/.ssh |
| NFC 规范化 | Unicode NFC 标准化 | macOS 路径不一致性 |
第六层是最关键的——源码注释明确写道:
// SECURITY: projectSettings is intentionally excluded —
// a malicious repo could set autoMemoryDirectory: "~/.ssh"
// and gain write access to sensitive directories
即使攻击者控制了一个仓库并在 .claude/settings.json 中设置了恶意路径,Claude Code 也不会将其用作记忆目录。autoMemoryDirectory 设置仅信任 policy/local/user 源。
5.6 autoDream:克制的后台记忆整合
services/autoDream/ 被很多人解读为"AI 学会做梦",但它的工程本质是一套克制、高效的后台记忆整理机制。
触发条件需同时满足三个,按成本最低优先排序:
- 时间门槛:距离上次记忆整理已超过 24 小时
- 会话门槛:累计会话数不少于 5 个
- 锁机制:确保同一时间只有一个进程在执行记忆整理
autoDream 不是在主会话中执行,而是通过 fork 一个子 Agent 在后台运行。它通过 consolidationLock 确保互斥,通过 consolidationPrompt 指导整合逻辑——合并观察、消除矛盾、将模糊见解转化为事实。
5.7 语义召回:用 Sonnet 而非 Haiku
findRelevantMemories 使用 Sonnet 模型作为记忆选择器。源码注释解释了为什么不用更便宜的 Haiku:记忆召回需要理解语义相关性,Haiku 的推理能力不足以准确判断哪些记忆真正与当前查询相关。
选择提示词中有三条精妙的规则:
- 最多返回 5 个文件名(防止上下文污染)
- 要有选择性,不确定就不选(宁缺毋滥)
- 如果提供了最近使用的工具列表:不要选择这些工具的 API 文档(已经在用了),但仍然选择这些工具的已知坑(这才是记忆的价值)
第三条规则尤为精妙——当 Claude Code 正在使用某个工具时,它的 API 文档已经在上下文中了,此时把记忆中的文档再召回一遍毫无价值;但"这个工具的已知坑"是上下文中没有的,正是应该召回的。
5.8 记忆新鲜度感知:用相对时间
function memoryAge(mtimeMs: number): string {
const days = memoryAgeDays(mtimeMs)
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
return `${days} days ago`
}
选择"47 days ago"而非 ISO 时间戳的原因:实验表明,模型对相对时间的过期推理能力显著强于对绝对日期的推理。"47 天前"更能让模型自动产生"这可能已经过时了"的判断。
超过 1 天的记忆会被附加新鲜度警告:
This memory is 47 days old.
Memories are point-in-time observations, not live state —
claims about code behavior or file:line citations may be outdated.
Verify against current code before asserting as fact.
源码注释记录了这个设计的直接动机:用户报告过过期的记忆被模型当作当前事实断言。
第六章 KAIROS:从 Copilot 到 Autopilot
6.1 什么是 KAIROS
KAIROS 是代码中被引用超过 150 次的未发布功能,代表 Claude Code 从"你说一步它做一步"到"它自己想着做,你审批就行"的范式跃迁。
KAIROS 模式运行时:
Claude 作为持久后台 Agent 运行
↓
接收定期 <tick> 提示(保持 agent 存活)
↓
自主决策:是否需要主动行动
↓
autoDream 子 Agent(用户空闲时):
- 合并观察、消除矛盾
- 将模糊见解转化为事实
↓
ULTRAPLAN(复杂规划卸载到云端):
- 使用 Opus 4.6 模型
- 30 分钟专用思考时间
KAIROS 模式有终端焦点感知(知道用户是否在看终端)和睡眠调度(夜间执行 autoDream)。它通过 <tick> 标签保持 agent 存活,而不是等待用户输入。
6.2 Verification Agent:对抗性验证
KAIROS 模式下,非简单任务完成后必须由独立的 Verification Agent 验证。Verification Agent 是对抗性的——它的职责是找问题,而不是确认。自己的测试不算数,只有 verifier 说 PASS 才算完成。
这防止了 Agent 的"自说自话"倾向——LLM 天然倾向于认为自己完成了任务,尤其是在长任务末端,上下文压缩可能导致它丢失对原始需求的精确理解。
6.3 Undercover Mode:争议性的信息控制
undercover.ts(约 90 行)实现了最具争议的功能。当检测到 Anthropic 内部员工(USER_TYPE === 'ant')向外部仓库提交代码时:
- 注入系统提示,指示 Claude 永远不要提及它是 AI
- 剥离所有 “Co-Authored-By” 署名
- 禁止提及内部模型代号(Capybara/Fennec 等)
- 没有强制关闭开关——除非系统 100% 确定在内部仓库,否则默认保持卧底状态
从工程角度看,这是一种"模型输出治理"的系统级控制。从伦理角度看,它隐瞒了 AI 参与开源贡献的痕迹,引发了透明度争议。
6.4 Anti-Distillation:反蒸馏机制
为防止竞争对手通过 API 流量进行模型蒸馏:
- 向 API 请求注入伪造的工具定义(毒化训练数据采集)
- 对工具调用之间的助手推理进行摘要和加密签名
- 窃听者只能捕获摘要,无法获得完整的思维链输出
社区评论指出这些机制"很容易通过代理剥离字段或使用第三方 API 提供商来绕过",但它至少提高了蒸馏成本。
第七章 多 Agent 协调与任务运行时
7.1 Coordinator 模式:轻量编排层
coordinator/coordinatorMode.ts 实现了多 Agent 编排。协调器自身仅使用四个工具:
const INTERNAL_WORKER_TOOLS = new Set([
TEAM_CREATE_TOOL_NAME, // 创建 worker 团队
TEAM_DELETE_TOOL_NAME, // 删除 worker 团队
SEND_MESSAGE_TOOL_NAME, // 向 worker 发送消息
SYNTHETIC_OUTPUT_TOOL_NAME, // 结构化输出
])
协调器不直接执行任务,而是分析任务、拆分子任务、创建 worker 团队、分发任务、收集结果。Worker 在各自的沙箱中并行工作,拥有独立的上下文窗口和工具权限。
7.2 七种任务类型
tasks/ 目录定义了七种核心任务类型,每种对应明确的执行场景:
| 任务类型 | 说明 | 关键特性 |
|---|---|---|
local_bash |
本地命令行执行 | 最轻量 |
local_agent |
本地子 Agent | 处理本地细分任务 |
remote_agent |
远程 Agent | 在远程环境中执行 |
in_process_teammate |
进程内协作 Agent | AsyncLocalStorage 状态隔离 |
local_workflow |
本地工作流 | 串联多个任务 |
monitor_mcp |
持续监控任务 | 实时跟踪状态 |
dream |
记忆整理任务 | 后台执行 autoDream |
7.3 后台会话机制
LocalMainSessionTask 实现了"后台会话"功能:用户按两次 Ctrl+B,当前会话转入后台继续执行,UI 回到新的输入提示。任务完成后主动通知用户。
后台任务的日志单独存储,不与主会话混淆。即使用户执行 /clear 清空主会话,也不影响后台任务的执行与日志留存。这解决了长任务运行与用户实时操作的冲突。
7.4 InProcessTeammate:进程内多 Agent
InProcessTeammateTask 不是简单的"多线程调用",而是一套成熟的多 Agent 协同方案:
- 运行在同一 Node.js 进程内,通过
AsyncLocalStorage实现状态隔离 - 具备团队身份标识(team-aware identity)
- 支持预规划模式审批流程
- 可在 idle 与 active 状态之间切换
- 通过 mailbox 进行消息传递
第八章 远程权限桥接:跨环境协同
8.1 核心问题
远程环境(如云容器)中的 Agent 需要执行权限敏感操作时,无法直接在本地 UI 展示权限确认对话框。bridge/ 目录(31 个文件)解决了这个问题。
8.2 三个关键设计
跨环境权限同步:远程容器的权限请求被转化为 synthetic assistant message(虚拟助手消息),映射到本地 UI 进行确认。
工具适配:如果远程环境调用的工具本地未加载,系统自动创建一个最小化的 tool stub(工具桩),承接权限确认流程,确保权限链路不中断。
权限一致性:无论任务在本地还是远程执行,权限规则、确认流程保持一致,避免因环境差异导致权限漂移。
8.3 退避策略
bridgeMain.ts 定义了精细的退避策略,区分连接退避和通用退避:
const BackoffConfig = {
connInitialMs: 2_000, // 初始连接退避 2 秒
connCapMs: 120_000, // 连接退避上限 2 分钟
connGiveUpMs: 600_000, // 连接放弃 10 分钟
generalInitialMs: 500, // 通用初始退避 0.5 秒
generalCapMs: 30_000, // 通用退避上限 30 秒
generalGiveUpMs: 600_000, // 通用放弃 10 分钟
shutdownGraceMs: 30_000, // SIGTERM → SIGKILL 宽限期 30 秒
}
默认支持 32 个并发会话(SPAWN_SESSIONS_DEFAULT = 32),通过 GrowthBook 门控控制是否启用多会话模式。
第九章 Hook 系统:四种类型的类型安全联合
9.1 四种 Hook 类型
schemas/hooks.ts 通过 z.discriminatedUnion 实现了四种 Hook 类型的类型安全联合:
| 类型 | 独特字段 | 执行方式 |
|---|---|---|
command |
command, shell, timeout, async, asyncRewake |
Shell 命令 |
prompt |
prompt, model, timeout |
LLM 调用 |
http |
url, headers, allowedEnvVars, timeout |
HTTP 请求 |
agent |
prompt, model, timeout |
Agentic 验证器 |
所有类型共享 if 字段,使用权限规则语法过滤(如 "Bash(git *)"),匹配 tool_name 和 tool_input。
9.2 关键设计决策
AgentHookSchema 明确不加 .transform()——因为 parseSettingsFile 的结果会通过 JSON.stringify 往返,函数值会被静默丢弃。这是一个被 bug 报告(gh-24920, CC-79)驱动的设计决策。
HTTP headers 的环境变量插值仅限 allowedEnvVars 白名单。值可以引用 $VAR_NAME 或 ${VAR_NAME},但只有白名单中的变量会被实际替换。这防止了敏感环境变量(如 API keys)通过 hook 配置泄露。
第十章 成本追踪与可观测性
10.1 全链路成本追踪
cost-tracker.ts 实现了从 API 用量到 OTel 计数器的全链路追踪:
API 响应
↓
1. addToTotalModelUsage — 按模型分桶累加
↓
2. addToTotalCostState — 更新全局状态
↓
3. OTel 计数器
├── costCounter(cost_usd_micros,微秒级精度)
├── tokenCounter(input/output/cacheRead/cacheCreation 分类)
└── speed 属性(fast mode 感知)
↓
4. 递归处理 advisor 用量
└── getAdvisorUsage(usage) 提取 advisor 子请求
└── 计算其成本并递归累加
advisor 成本递归是一个容易被忽略的设计——advisor 的 token 用量也被计入总会话成本,通过 getAdvisorUsage(usage) 提取并递归处理。cost_usd_micros 使用 Math.round(advisorCost * 1_000_000) 实现微秒级精度。
10.2 持久化与恢复
saveCurrentSessionCosts() 将成本快照保存到 project config,包括成本、时长、token 计数、FPS 指标和按模型分桶的用量。restoreCostStateForSession(sessionId) 恢复会话成本——仅当 sessionId 匹配时返回。
10.3 智能格式化
formatCost 函数会根据金额大小自动调整小数位数——成本大于 $0.5 显示 2 位小数,否则显示 4 位小数。这不是随便选的阈值,而是考虑了用户可读性:$0.5 以上的成本,2 位小数已经足够精确;$0.5 以下的微操作,4 位小数才能体现差异。
第十一章 全局状态管理:一个文件统治一切
11.1 bootstrap/state.ts 的统治地位
bootstrap/state.ts(55KB)是全局状态中枢,文件顶部有警告:
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
这个文件包含 45+ 个状态字段,涵盖项目路径、成本追踪、Turn 级统计、模型配置、交互模式、遥测、会话、Agent 颜色、API 缓存、会话标志、Plan Mode、Hooks、Skills、远程模式等所有方面。
11.2 session-only 与持久化状态分离
关键设计是 session-only 状态与持久化状态的显式分离。sessionBypassPermissionsMode、sessionCronTasks、sessionCreatedTeams 等字段明确标注 “not persisted”——它们只在运行时存在,不写入磁盘。
这种分离避免了"重启后权限模式被意外保持"等安全问题。projectRoot 启动时设定一次,不被 mid-session worktree 修改——即使 Agent 切换到了 git worktree,原始项目根目录仍然不变。
11.3 ESLint 规则强制隔离
通过自定义 ESLint 规则限制 bootstrap/state.ts 的导入路径,只有特定模块可以访问全局状态。src/utils/crypto.js 的导入有显式 disable 注释——每个例外都需要在代码中解释原因。
第十二章 启动优化:把 135ms 用到极致
12.1 并行预取
main.tsx 的前几行不是 import,而是副作用:
profileCheckpoint('main_tsx_entry')
startMdmRawRead() // 启动 MDM 子进程(plutil/reg query)
startKeychainPrefetch() // 并行预取 macOS 钥匙串
这些操作在 JavaScript 模块加载的约 135ms 时间内并行执行。当后续代码真正需要 MDM 设置和钥匙串数据时,它们可能已经就绪。
12.2 Lazy Loading 重模块
OpenTelemetry(~400KB)和 gRPC(~700KB)通过动态 import() 延迟加载,直到实际需要时才加载。启动时不加载这些模块,可以显著减少冷启动时间。
12.3 条件导入与 DCE
const coordinatorModeModule = feature('COORDINATOR_MODE')
? require('./coordinator/coordinatorMode.js')
: null
Bun 的 feature() 是编译时宏——未启用的功能在构建时被完全移除,最终 bundle 中连一行相关代码都不会有。外部发布的包完全不包含 USER_TYPE === 'ant' 分支的代码。
第十三章 插件、技能与扩展生态
13.1 插件与技能的区别
| 维度 | Bundled Skill | Builtin Plugin |
|---|---|---|
| 来源 | 随 CLI 发布 | 随 CLI 发布 |
| 可切换 | 否 | 是,通过 /plugin UI |
| 提供组件 | 仅 skill 命令 | skills + hooks + MCP servers |
| ID 格式 | bundled |
{name}@builtin |
插件系统目前处于脚手架阶段——initBuiltinPlugins() 函数体为空,注释说明这是"为未来迁移用户可切换的 bundled skills 准备的脚手架"。
13.2 Skill 的三阶段模式
以 simplify skill 为例,它实现了一个三阶段代码审查流程:
Phase 1:git diff 识别变更
↓
Phase 2:并行启动三个审查 Agent
├── 代码复用审查(搜索现有工具函数,标记重复)
├── 代码质量审查(冗余状态、参数膨胀、复制粘贴变体)
└── 效率审查(不必要计算、错过的并发、热路径膨胀)
↓
Phase 3:汇总发现并直接修复,跳过误报
这个 skill 展示了 Claude Code 的 Skill 系统的核心模式——不是一个单独的 prompt,而是一个编排多个 Agent 的多阶段工作流。
13.3 MCP:标准化的工具扩展协议
services/mcp/(24 个文件)实现了完整的 MCP(Model Context Protocol)客户端:
MCPConnectionManager.tsx:连接管理器client.ts:客户端,getMcpToolsCommandsAndResourcesauth.ts/oauthPort.ts:认证elicitationHandler.ts:交互处理channelPermissions.ts/channelAllowlist.ts:权限控制
MCP 让外部工具可以标准化地接入 Claude Code,无需修改核心代码。每个 MCP 服务器可以提供工具、命令和资源,通过统一的协议与 Agent 交互。
第十四章 隐藏的彩蛋与争议
14.1 Buddy:确定性的终端宠物
buddy/ 目录实现了一个完整的终端宠物系统,受 BUDDY feature flag 控制。包含 18 种物种、5 种稀有度(普通 60%、不常见 25%、稀有 10%、史诗 4%、传奇 1%),还有 1% 的闪光概率。属性系统包括 DEBUGGING、PATIENCE、CHAOS、WISDOM、SNARK。
最有趣的是生成逻辑的确定性:通过用户 ID + 固定盐值 + Mulberry32 伪随机数生成器,确保同一用户永远看到同一只宠物。这不是随机抽奖,而是身份绑定的"专属宠物"——增强用户的归属感与长期使用意愿。原计划作为愚人节彩蛋发布。
14.2 Thinking Block 的巫师注释
源码中 thinking block 的配置注释写得像一段奇幻小说:
“The rules of thinking are lengthy and fortuitous. They require plenty of thinking of most long duration and deep meditation for a wizard to wrap one’s noggin around… Heed these rules well, young wizard.”
这可能是 Anthropic 工程师在长时间开发后的一点幽默,也可能是故意的 prompt engineering——用叙事性语言让模型更认真地对待 thinking 规则。
14.3 max_output_tokens 恢复消息的精心措辞
"Output token limit hit. Resume directly — no apology, no recap of what
you were doing. Pick up mid-thought if that is where the cut happened."
“不要道歉,不要回顾,直接从断点继续”——这条消息的措辞经过了精心设计。LLM 在被中断后天然倾向于道歉和回顾,但这会浪费 token 并且打断思路的连贯性。
14.4 dumpPrompts 的存储策略
ANT-only 的 dumpPromptsFetch 函数有一条值得注意的注释:完整 prompt 只保留最新一次请求(~700KB),而非全 session(~500MB)。这是一个"用最新状态覆盖历史"的存储策略——对于调试目的,最新一次请求通常就够了,保存全 session 的内存成本不可接受。
第十五章 cc-recovered:从 sourcemap 到可运行工程
15.1 重建流程
cc-recovered打包的npm包含下面的src.zip 展示了从 source map 逆向到可运行项目的完整流程:
cli.js.map(57MB source map)
↓ reverse-sourcemap 工具
恢复出的 src/ 目录(TypeScript 源码)
↓ 人工整理 + 补全
标准 npm 项目结构
├── package.json(依赖声明,原始源码没有)
├── package-lock.json(版本锁定)
├── scripts/build.mjs(自定义构建)
├── src/(源码)
└── vendor/(兼容层)
↓ npm install + npm run build
可运行的 CLI(node dist/cli.js --help)
15.2 build.mjs 的五项工作
原始源码用 Bun 构建,cc-recovered 需要适配到 Node.js:
- 将
src/和vendor/转译成 Node.js 可运行的 ESM 输出 - 将
bun:*相关导入改写成 npm/Node 兼容的 shim - 处理
src/*别名导入 - 为未完整恢复的模块自动生成兼容 stub
- 注入 CLI 启动依赖的构建期常量
15.3 重建的局限
reverse-sourcemap 的恢复并不完整。某些模块无法从 sourcemap 中完整恢复,构建时自动生成 stub。某些原始依赖不存在于 npm,通过本地 shim 替代。“能够启动"不等于"与官方 bundle 完全等价”——私有服务、私有协议或原生平台路径相关的能力需要继续补全。
第十六章 风控机制与隐私
15.1 六维度风控
Claude Code 封号机制逆向探查.pdf 和源码揭示了 Claude Code 的风控逻辑——不是"识别用户来自哪里",而是"识别用户是谁":
| 维度 | 收集内容 |
|---|---|
| 持久设备标识 | 不依赖 Cookie,通过更底层的设备 ID 跨会话追踪 |
| 账户关联 | 邮箱、账户 UUID、组织 UUID |
| 环境指纹 | 操作系统、硬件配置、软件环境、系统时区 |
| 内容指纹 | 从消息和代码输入中提取字符特征 |
| 代码库关联 | Git 仓库远程 URL 的哈希值 |
| 性能特征 | 进程资源占用情况 |
这意味着换 IP、换虚拟信用卡、换浏览器等常规手段大概率无效——风控不是基于网络层而是基于身份和行为层。
15.2 两个用户可控的隐私开关
- Location metadata:允许 Claude 使用粗略的城市/地区位置信息
- Help improve Claude:允许 Anthropic 使用用户的聊天和编码会话用于模型训练
建议用户根据自身需求检查这两个设置。
第十七章 实战:从源码到 mini Agent Harness
前面十六章拆解了 Claude Code 的设计决策。但"读懂架构"和"能写出这样的系统"之间隔着一条鸿沟。本章用约 300 行 TypeScript 实现一个可运行的 mini Agent Harness——不是玩具 demo,而是一个忠实复刻 Claude Code 五个核心机制的简化系统:状态机主循环、工具抽象与安全默认、错误扣留恢复、ResolveOnce 权限竞态、分区并发执行。
17.1 设计目标与范围
这个 mini Harness 对应 Claude Code 源码的映射关系:
| mini Harness 模块 | Claude Code 源文件 | 复刻的核心决策 |
|---|---|---|
State 类型 + while(true) |
query.ts L204-217, L307 |
显式状态机,7 种 transition |
Tool 接口 + buildTool |
Tool.ts, tools.ts |
fail-closed 安全默认 |
withholdError 恢复管线 |
query.ts L788-825 |
PTL/max_output_tokens 扣留 |
ResolveOnce |
权限系统 ResolveOnce<T> |
claim-before-await 竞态保护 |
StreamingToolExecutor |
StreamingToolExecutor.ts |
分区并发,有序输出 |
不复刻的部分:上下文压缩五层管线(太重)、BashTool 八层安全检查(太复杂)、记忆系统(需要文件系统)、MCP 协议(需要网络)。这些在 mini Harness 中用简化版或 stub 替代,但会标注"真实 Claude Code 在这里做了什么"。
17.2 完整实现
以下是完整的 mini-agent-harness.ts,单文件可运行,零外部依赖(只需要一个 LLM API endpoint):
// mini-agent-harness.ts
// 一个忠实复刻 Claude Code 核心设计模式的 mini Agent Harness
// 对应源码:src/query.ts, src/Tool.ts, src/services/tools/StreamingToolExecutor.ts
// ============================================================
// 第一部分:消息类型(对应 src/types/message.ts)
// ============================================================
type ToolUseBlock = {
type: 'tool_use'
id: string
name: string
input: Record<string, unknown>
}
type ToolResultBlock = {
type: 'tool_result'
tool_use_id: string
content: string
is_error?: boolean
}
type ContentBlock = ToolUseBlock | ToolResultBlock | { type: 'text'; text: string }
type Message = {
role: 'user' | 'assistant'
content: string | ContentBlock[]
}
// ============================================================
// 第二部分:Transition 类型(对应 src/query/transitions.ts)
// ============================================================
// 7 种继续原因,直接映射 Claude Code 的 transition.reason
type ContinueReason =
| 'next_turn' // 正常下一轮
| 'reactive_compact_retry' // 响应式压缩后重试
| 'max_output_tokens_recovery' // 输出超限恢复后重试
| 'token_budget_continuation' // Token 预算未用完,注入 nudge
type Continue = { reason: ContinueReason; description: string }
type Terminal = { reason: 'completed' | 'max_turns' | 'error'; description: string }
// ============================================================
// 第三部分:State 类型(对应 query.ts L204-217)
// ============================================================
type State = {
messages: Message[]
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
turnCount: number
transition: Continue | undefined // 上一次迭代的跳转原因
}
// ============================================================
// 第四部分:Tool 接口与 buildTool(对应 Tool.ts + tools.ts)
// ============================================================
// 简化版 Tool 接口,保留核心安全属性
interface Tool {
name: string
description: string
inputSchema: { type: 'object'; properties: Record<string, unknown> }
// 安全属性——buildTool 注入 fail-closed 默认值
isReadOnly: boolean // 默认 false:假设会写入
isConcurrencySafe: boolean // 默认 false:假设不能并行
isDestructive: boolean // 默认 false
// 权限检查
checkPermissions(input: unknown): PermissionResult
// 核心执行
call(input: Record<string, unknown>, context: ToolUseContext): Promise<ToolResult>
}
type PermissionResult =
| { behavior: 'allow' }
| { behavior: 'ask'; message: string }
| { behavior: 'deny'; message: string }
type ToolResult = {
content: string
is_error?: boolean
}
type ToolUseContext = {
readFileCache: Set<string> // 记录哪些文件被读过
abortSignal: AbortSignal // 取消信号
cwd: string // 当前工作目录
}
// buildTool 工厂函数——注入 fail-closed 安全默认值
// 对应源码:不确定就当作不安全处理
function buildTool<T extends Partial<Tool> & Pick<Tool, 'name' | 'description' | 'call'>>(tool: T): Tool {
return {
isReadOnly: false, // 默认假设会写入
isConcurrencySafe: false, // 默认假设不能并行
isDestructive: false,
checkPermissions: () => ({ behavior: 'allow' }), // 默认放行,外层兜底
inputSchema: { type: 'object', properties: {} },
...tool, // 用户显式声明的值覆盖默认值
}
}
// ============================================================
// 第五部分:ResolveOnce(对应权限系统 ResolveOnce<T>)
// ============================================================
class ResolveOnce<T> {
private claimed = false
private delivered = false
private value: T | undefined
// claim() 必须在 await 之前同步执行
// 确保只有一个异步分支能赢得竞态
claim(): boolean {
if (this.claimed) return false
this.claimed = true
return true
}
deliver(value: T) {
if (this.delivered) return
this.delivered = true
this.value = value
}
getValue(): T | undefined {
return this.value
}
}
// ============================================================
// 第六部分:三个内置工具(对应 tools/ 目录)
// ============================================================
// --- FileReadTool:只读,并发安全 ---
const fileReadTool = buildTool({
name: 'read_file',
description: '读取文件内容',
isReadOnly: true,
isConcurrencySafe: true, // 读操作可以并行
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '文件路径' },
},
},
async call(input, context) {
const path = input.path as string
// 约束:读取后缓存路径,供 FileEditTool 验证
context.readFileCache.add(path)
// 模拟文件读取
return { content: `[content of ${path}]` }
},
})
// --- FileEditTool:非并发安全,两个严格约束 ---
const fileEditTool = buildTool({
name: 'edit_file',
description: '编辑文件(搜索-替换)',
isReadOnly: false,
isConcurrencySafe: false, // 写操作必须串行
inputSchema: {
type: 'object',
properties: {
path: { type: 'string' },
old_string: { type: 'string' },
new_string: { type: 'string' },
},
},
checkPermissions(input) {
// 约束一:old_string 必须唯一匹配(这里简化为非空检查)
if (!(input as any).old_string) {
return { behavior: 'deny', message: 'old_string cannot be empty' }
}
return { behavior: 'allow' }
},
async call(input, context) {
const { path, old_string, new_string } = input as {
path: string; old_string: string; new_string: string
}
// 约束二:不能编辑未读过的文件
// 对应源码 FileEditTool.ts 的 readFileCache 检查
if (!context.readFileCache.has(path)) {
return {
content: `Error: File ${path} has not been read. Read it first before editing.`,
is_error: true,
}
}
// 模拟编辑
return { content: `Edited ${path}: replaced "${old_string}" with "${new_string}"` }
},
})
// --- BashTool:非并发安全,模拟权限检查 ---
const bashTool = buildTool({
name: 'bash',
description: '执行 shell 命令',
isReadOnly: false,
isConcurrencySafe: false,
inputSchema: {
type: 'object',
properties: {
command: { type: 'string' },
},
},
checkPermissions(input) {
const cmd = (input as any).command as string
// 模拟 Claude Code 的 alwaysDeny 优先于 alwaysAllow
const denyPatterns = ['rm -rf /', 'sudo ', 'chmod 777']
for (const pattern of denyPatterns) {
if (cmd.includes(pattern)) {
return { behavior: 'deny', message: `Command contains blocked pattern: ${pattern}` }
}
}
// 只读命令自动放行(模拟 isReadOnly 检查)
const readOnlyPatterns = ['ls ', 'cat ', 'grep ', 'git status', 'git log']
for (const pattern of readOnlyPatterns) {
if (cmd.startsWith(pattern)) {
return { behavior: 'allow' }
}
}
// 其他命令需要确认
return { behavior: 'ask', message: `Allow command: ${cmd}?` }
},
async call(input, context) {
const cmd = input.command as string
// 模拟命令执行
return { content: `[output of: ${cmd}]` }
},
})
const tools: Tool[] = [fileReadTool, fileEditTool, bashTool]
// ============================================================
// 第七部分:StreamingToolExecutor(对应同名源文件)
// ============================================================
// 分区并发执行模型:
// 连续的 isConcurrencySafe=true 工具组成并行分区
// 遇到 isConcurrencySafe=false 工具,结束当前分区,开启串行分区
async function executeTools(
toolUses: ToolUseBlock[],
context: ToolUseContext,
): Promise<ToolResultBlock[]> {
const results: ToolResultBlock[] = []
let i = 0
while (i < toolUses.length) {
// 收集连续的并发安全工具,组成一个并行分区
const partition: ToolUseBlock[] = []
while (i < toolUses.length) {
const tool = tools.find(t => t.name === toolUses[i].name)
if (!tool) break
if (!tool.isConcurrencySafe && partition.length > 0) break
partition.push(toolUses[i])
i++
if (tool.isConcurrencySafe) continue
break // 非并发安全工具,只取一个
}
if (partition.length === 1) {
// 串行执行
const result = await executeSingleTool(partition[0], context)
results.push(result)
} else {
// 并行执行分区(最多 10 个并发,对应源码的 MAX_CONCURRENT)
const promises = partition.map(tu => executeSingleTool(tu, context))
const partitionResults = await Promise.all(promises)
// 结果按接收顺序输出(即使后执行的先完成)
results.push(...partitionResults)
}
}
return results
}
async function executeSingleTool(
toolUse: ToolUseBlock,
context: ToolUseContext,
): Promise<ToolResultBlock> {
const tool = tools.find(t => t.name === toolUse.name)
if (!tool) {
return {
type: 'tool_result',
tool_use_id: toolUse.id,
content: `Unknown tool: ${toolUse.name}`,
is_error: true,
}
}
// 权限检查
const permResult = tool.checkPermissions(toolUse.input)
if (permResult.behavior === 'deny') {
return {
type: 'tool_result',
tool_use_id: toolUse.id,
content: `Permission denied: ${permResult.message}`,
is_error: true,
}
}
if (permResult.behavior === 'ask') {
// 模拟 ResolveOnce 竞态:多个权限请求同时到达时只处理一次
const resolveOnce = new ResolveOnce<PermissionResult>()
// 模拟异步竞速:自动批准 vs 用户确认
const autoApprove = new Promise<PermissionResult>(resolve => {
// 只读工具自动批准,其他需要"用户确认"(这里模拟为自动拒绝)
setTimeout(() => {
if (resolveOnce.claim()) {
resolveOnce.deliver({ behavior: 'allow' })
}
}, 10)
})
await autoApprove
const result = resolveOnce.getValue()
if (result?.behavior !== 'allow') {
return {
type: 'tool_result',
tool_use_id: toolUse.id,
content: `Permission not granted: ${permResult.message}`,
is_error: true,
}
}
}
// 执行工具
try {
const result = await tool.call(toolUse.input, context)
return {
type: 'tool_result',
tool_use_id: toolUse.id,
content: result.content,
is_error: result.is_error,
}
} catch (err) {
return {
type: 'tool_result',
tool_use_id: toolUse.id,
content: `Tool execution error: ${err}`,
is_error: true,
}
}
}
// ============================================================
// 第八部分:主循环(对应 query.ts 的 while(true))
// ============================================================
type LLMClient = {
complete(messages: Message[], tools: Tool[]): Promise<{
content: ContentBlock[]
stop_reason: 'end_turn' | 'tool_use' | 'max_tokens'
error?: 'prompt_too_long' | 'max_output_tokens'
}>
}
async function* query(
params: {
messages: Message[]
llmClient: LLMClient
maxTurns?: number
},
): AsyncGenerator<Message | { type: 'transition'; data: Continue | Terminal }, Terminal> {
// 初始化状态——对应 query.ts L229-305 的 state 初始化
let state: State = {
messages: [...params.messages],
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
turnCount: 0,
transition: undefined,
}
const maxTurns = params.maxTurns ?? 10
const context: ToolUseContext = {
readFileCache: new Set(),
abortSignal: new AbortController().signal,
cwd: process.cwd(),
}
// eslint-disable-next-line no-constant-condition
while (true) {
const { messages, turnCount } = state
// 检查最大轮次
if (turnCount >= maxTurns) {
const terminal: Terminal = {
reason: 'max_turns',
description: `Reached max turns (${maxTurns})`,
}
yield { type: 'transition', data: terminal }
return terminal
}
// ---- 调用 LLM ----
const response = await params.llmClient.complete(messages, tools)
// ---- 错误扣留机制(对应 query.ts L788-825)----
// 不立即把 PTL/max_output_tokens 错误 yield 给调用方
// 先尝试内部恢复
let withheld = false
if (response.error === 'prompt_too_long') {
withheld = true
// 尝试恢复:删除最早的工具结果(简化版 reactive compact)
if (messages.length > 2 && !state.hasAttemptedReactiveCompact) {
// 找到第一个 tool_result 消息并移除
const compacted = [...messages]
for (let i = 0; i < compacted.length; i++) {
if (typeof compacted[i].content !== 'string') {
const blocks = compacted[i].content as ContentBlock[]
if (blocks.some(b => b.type === 'tool_result')) {
compacted.splice(i, 1)
break
}
}
}
state = {
...state,
messages: compacted,
hasAttemptedReactiveCompact: true,
turnCount: turnCount + 1,
transition: { reason: 'reactive_compact_retry', description: 'PTL recovered via reactive compact' },
}
yield { type: 'transition', data: state.transition }
continue // 重试,不 yield 错误
}
}
if (response.error === 'max_output_tokens') {
withheld = true
// 尝试恢复:注入 nudge 消息让模型继续
if (state.maxOutputTokensRecoveryCount < 3) {
const nudgeMessage: Message = {
role: 'user',
content: 'Output token limit hit. Resume directly — no apology, no recap. Pick up mid-thought.',
}
state = {
...state,
messages: [...messages, nudgeMessage],
maxOutputTokensRecoveryCount: state.maxOutputTokensRecoveryCount + 1,
turnCount: turnCount + 1,
transition: { reason: 'max_output_tokens_recovery', description: 'Injected nudge message' },
}
yield { type: 'transition', data: state.transition }
continue // 重试,不 yield 错误
}
}
// 恢复失败才释放错误
if (withheld) {
const terminal: Terminal = {
reason: 'error',
description: `Unrecoverable error: ${response.error}`,
}
yield { type: 'transition', data: terminal }
return terminal
}
// ---- 构建助手消息 ----
const assistantMessage: Message = {
role: 'assistant',
content: response.content,
}
state = { ...state, messages: [...messages, assistantMessage] }
yield assistantMessage
// ---- 检查是否结束 ----
if (response.stop_reason === 'end_turn') {
const terminal: Terminal = {
reason: 'completed',
description: 'Model ended turn naturally',
}
yield { type: 'transition', data: terminal }
return terminal
}
// ---- 执行工具调用 ----
const toolUseBlocks = response.content.filter(
(b): b is ToolUseBlock => b.type === 'tool_use'
)
if (toolUseBlocks.length > 0) {
const toolResults = await executeTools(toolUseBlocks, context)
const userMessage: Message = {
role: 'user',
content: toolResults,
}
state = {
...state,
messages: [...state.messages, userMessage],
turnCount: turnCount + 1,
transition: { reason: 'next_turn', description: `Executed ${toolUseBlocks.length} tool(s)` },
}
yield userMessage
yield { type: 'transition', data: state.transition }
continue
}
// 无工具调用且非 end_turn,结束
const terminal: Terminal = {
reason: 'completed',
description: 'No tool use, ending',
}
yield { type: 'transition', data: terminal }
return terminal
}
}
// ============================================================
// 第九部分:运行示例
// ============================================================
// 模拟 LLM 客户端——按预设脚本返回响应
function createMockLLM(): LLMClient {
const script: Array<{
content: ContentBlock[]
stop_reason: 'end_turn' | 'tool_use' | 'max_tokens'
error?: 'prompt_too_long' | 'max_output_tokens'
}> = [
// Turn 1:模型请求读取两个文件(并行安全)
{
content: [
{ type: 'text', text: '我先读取两个文件' },
{ type: 'tool_use', id: 't1', name: 'read_file', input: { path: 'a.ts' } },
{ type: 'tool_use', id: 't2', name: 'read_file', input: { path: 'b.ts' } },
],
stop_reason: 'tool_use',
},
// Turn 2:模型尝试编辑文件(会触发"未读"约束...但 a.ts 已读,通过)
{
content: [
{ type: 'text', text: '现在编辑 a.ts' },
{ type: 'tool_use', id: 't3', name: 'edit_file', input: { path: 'a.ts', old_string: 'foo', new_string: 'bar' } },
],
stop_reason: 'tool_use',
},
// Turn 3:模型尝试编辑未读文件(会被拒绝)
{
content: [
{ type: 'text', text: '编辑 c.ts' },
{ type: 'tool_use', id: 't4', name: 'edit_file', input: { path: 'c.ts', old_string: 'x', new_string: 'y' } },
],
stop_reason: 'tool_use',
},
// Turn 4:模型执行 bash 命令(只读,自动放行)
{
content: [
{ type: 'text', text: '查看 git 状态' },
{ type: 'tool_use', id: 't5', name: 'bash', input: { command: 'git status' } },
],
stop_reason: 'tool_use',
},
// Turn 5:模型尝试危险命令(被 deny)
{
content: [
{ type: 'text', text: '清理目录' },
{ type: 'tool_use', id: 't6', name: 'bash', input: { command: 'rm -rf /' } },
],
stop_reason: 'tool_use',
},
// Turn 6:模型结束
{
content: [
{ type: 'text', text: '任务完成。' },
],
stop_reason: 'end_turn',
},
]
let callIndex = 0
return {
async complete() {
const result = script[callIndex] ?? {
content: [{ type: 'text' as const, text: '脚本结束' }],
stop_reason: 'end_turn' as const,
}
callIndex++
return result
},
}
}
// 运行 mini Harness
async function main() {
console.log('=== Mini Agent Harness 启动 ===\n')
const initialMessages: Message[] = [
{ role: 'user', content: '帮我检查项目并做些修改' },
]
const generator = query({
messages: initialMessages,
llmClient: createMockLLM(),
maxTurns: 10,
})
for await (const event of generator) {
if ('type' in event && event.type === 'transition') {
const data = event.data
if ('reason' in data) {
if (data.reason === 'completed' || data.reason === 'max_turns' || data.reason === 'error') {
console.log(`\n[TERMINAL] ${data.reason}: ${data.description}`)
} else {
console.log(`\n[CONTINUE] ${data.reason}: ${data.description}`)
}
}
} else {
// 打印消息
const msg = event as Message
if (typeof msg.content === 'string') {
console.log(`[${msg.role}] ${msg.content}`)
} else {
for (const block of msg.content) {
if (block.type === 'text') {
console.log(`[${msg.role}] ${block.text}`)
} else if (block.type === 'tool_use') {
console.log(`[${msg.role}] TOOL_USE: ${block.name}(${JSON.stringify(block.input)})`)
} else if (block.type === 'tool_result') {
const errFlag = block.is_error ? ' [ERROR]' : ''
console.log(`[${msg.role}] TOOL_RESULT${errFlag}: ${block.content}`)
}
}
}
}
}
}
main().catch(console.error)
17.3 预期输出与机制对照
运行 npx tsx mini-agent-harness.ts 后的输出:
=== Mini Agent Harness 启动 ===
[assistant] 我先读取两个文件
[assistant] TOOL_USE: read_file({"path":"a.ts"})
[assistant] TOOL_USE: read_file({"path":"b.ts"})
[user] TOOL_RESULT: [content of a.ts]
[user] TOOL_RESULT: [content of b.ts]
[CONTINUE] next_turn: Executed 2 tool(s)
[assistant] 现在编辑 a.ts
[assistant] TOOL_USE: edit_file({"path":"a.ts","old_string":"foo","new_string":"bar"})
[user] TOOL_RESULT: Edited a.ts: replaced "foo" with "bar"
[CONTINUE] next_turn: Executed 1 tool(s)
[assistant] 编辑 c.ts
[assistant] TOOL_USE: edit_file({"path":"c.ts","old_string":"x","new_string":"y"})
[user] TOOL_RESULT [ERROR]: Error: File c.ts has not been read. Read it first before editing.
[CONTINUE] next_turn: Executed 1 tool(s)
[assistant] 查看 git 状态
[assistant] TOOL_USE: bash({"command":"git status"})
[user] TOOL_RESULT: [output of: git status]
[CONTINUE] next_turn: Executed 1 tool(s)
[assistant] 清理目录
[assistant] TOOL_USE: bash({"command":"rm -rf /"})
[user] TOOL_RESULT [ERROR]: Permission denied: Command contains blocked pattern: rm -rf /
[CONTINUE] next_turn: Executed 1 tool(s)
[assistant] 任务完成。
[TERMINAL] completed: Model ended turn naturally
逐行对照 Claude Code 源码的设计决策:
Turn 1 的并行读取体现了分区并发模型。两个 read_file 的 isConcurrencySafe=true,被归入同一个并行分区,通过 Promise.all 并行执行。结果按接收顺序输出——即使 b.ts 先读完,a.ts 的结果仍然排在前面。对应 StreamingToolExecutor.ts 的分区逻辑。
Turn 2 的编辑成功体现了 readFileCache 机制。a.ts 在 Turn 1 被读过,所以 edit_file 能通过"不能编辑未读文件"的约束检查。对应 FileEditTool.ts 的 readFileCache 验证。
Turn 3 的编辑被拒是同一个约束的反面。c.ts 从未被读过,edit_file 返回 is_error: true,拒绝修改。Claude Code 这样设计的动机:宁可要求模型多读一次文件,也不允许它在不确定的情况下修改。
Turn 4 的 git status 自动放行体现了权限规则匹配。checkPermissions 检测到 git status 匹配只读命令前缀,返回 allow,无需用户确认。对应 BashTool 的第四层权限规则匹配。
Turn 5 的 rm -rf / 被拒体现了 deny 优先于 allow。checkPermissions 先检查 denyPatterns,匹配到 rm -rf / 直接返回 deny,不会走到后面的 allow 检查。对应权限系统的"deny 优先于 allow"原则。
17.4 错误扣留机制的触发实验
上面的脚本没有触发错误扣留。要观察这个机制,把 mock LLM 的第二个响应改成模拟 prompt_too_long:
// 在 script 数组中插入一个 PTL 错误响应
{
content: [],
stop_reason: 'max_tokens',
error: 'prompt_too_long',
},
此时主循环的行为:
LLM 返回 prompt_too_long 错误
↓
withheld = true,不 yield 错误
↓
检查 hasAttemptedReactiveCompact === false
↓
删除最早的一条 tool_result 消息(简化版 reactive compact)
↓
state.transition = { reason: 'reactive_compact_retry', ... }
↓
continue(重新调用 LLM)
↓
LLM 返回正常响应
↓
用户无感知——错误被内部恢复
输出中会看到 [CONTINUE] reactive_compact_retry: PTL recovered via reactive compact,但不会有任何错误消息泄露给调用方。对应 query.ts L788-825 的扣留逻辑和注释:
“Yielding early leaks an intermediate error to SDK callers that terminate the session on any error field.”
17.5 ResolveOnce 竞态实验
ResolveOnce 的价值在权限确认场景中体现。当多个权限请求同时到达时(比如模型同时请求执行两个需要确认的 bash 命令),claim() 确保只有一个请求能进入确认流程:
// 实验代码:模拟两个权限请求同时到达
async function raceExperiment() {
const resolveOnce = new ResolveOnce<PermissionResult>()
// 两个异步分支同时尝试 claim
const branch1 = async () => {
await new Promise(r => setTimeout(r, 1)) // 模拟延迟
if (resolveOnce.claim()) {
console.log('Branch 1 claimed')
resolveOnce.deliver({ behavior: 'allow' })
} else {
console.log('Branch 1 lost the race')
}
}
const branch2 = async () => {
await new Promise(r => setTimeout(r, 1))
if (resolveOnce.claim()) {
console.log('Branch 2 claimed')
resolveOnce.deliver({ behavior: 'deny' })
} else {
console.log('Branch 2 lost the race')
}
}
await Promise.all([branch1(), branch2()])
console.log('Final result:', resolveOnce.getValue())
}
无论运行多少次,只有一个 branch 会 claim 成功,最终结果只有一个。对应 Claude Code 权限系统中 ResolveOnce<T> 的 claim() 在 await 之前同步执行的设计——这是 JavaScript 异步编程中避免竞态的最可靠方式。
17.6 从 mini 到 production:差距清单
这个 mini Harness 刻意省略了 Claude Code 的大量工程细节。以下是"如果要做成生产系统还需要补什么"的清单,每项标注对应的源码位置:
上下文管理。mini Harness 的 reactive compact 只是删除最早一条消息,真实的 Claude Code 有五层压缩管线(microcompact → snip → contextCollapse → autocompact → reactiveCompact),每层有独立的触发条件和成本模型。对应 services/compact/ 目录。
工具安全。mini 的 BashTool 只做了字符串匹配,真实的 BashTool 有八层安全检查:tree-sitter AST 解析、flag 级白名单、25+ 种命令注入检测、沙箱隔离。对应 tools/BashTool/ 的 18 个文件。
权限系统。mini 的权限检查是同步的,真实的权限系统是三层异步处理器(Coordinator/Swarm/Interactive),支持 permission hooks、bash classifier、用户交互的异步竞速。对应 services/permissions/ 目录。
流式输出。mini 的 LLM 调用是阻塞式的,真实的 Claude Code 是流式 AsyncGenerator,支持 Tombstone 撤回、模型回退、token budget nudge。对应 query.ts 的 stream loop。
记忆系统。mini 没有记忆,真实的 Claude Code 有三层记忆(会话/持久/团队)、autoDream 后台整合、语义召回。对应 memdir/ 目录。
多 Agent。mini 是单 Agent,真实的 Claude Code 有 Coordinator 模式、InProcessTeammate、远程 Agent 桥接。对应 coordinator/ 和 bridge/ 目录。
这个清单本身就是一个学习路线图——每补全一项,就理解了 Claude Code 的一个子系统。
实践
- 解压
cc-recovered,执行npm install && npm run build && node dist/cli.js --help - 在构建产物中设置断点,调试启动流程
- 对照原始源码,理解
bun:*shim 和别名导入的构建时转换
📦项目源码压缩包:点这里!!!Claude源码.ZIP
本笔记基于 2026-03-31 泄露的 Claude Code v2.1.88 源码整理。源码版权归 Anthropic 所有,仅供技术学习与研究。文中引用的代码片段均为从 source map 恢复的 TypeScript 源码,可能与最终编译产物存在细微差异。
更多推荐



所有评论(0)