Claude Code 源码深度解析

第1章 为什么需要理解 Claude Code

作者 杨艺韬 · 9,707 字 · 发布于 · 更新于

1.1 AI 编程助手的演进

从 GitHub Copilot 的单行补全(2021 年 6 月技术预览),到 Cursor 的多文件编辑,再到 Claude Code 的自主 Agent 模式——AI 编程助手在短短几年内经历了几次范式跃迁。

每一次跃迁的背后,不只是模型能力的提升,更是系统架构的根本性变化:

阶段 代表产品 架构模式 核心挑战
补全式 Copilot 请求-响应 上下文窗口有限
对话式 Cursor, Windsurf 多轮对话 + 工具调用 工具编排、权限控制
Agent 式 Claude Code 自主循环 + 多 Agent 安全边界、状态管理、任务分解

Claude Code 代表的是第三阶段:模型不再只是"建议者",而是"执行者"。它可以自主决定读哪个文件、执行什么命令、创建什么分支,甚至可以生成子 Agent 并行处理任务。

注意这张表按的是各产品起步时的形态,不是它们今天的能力边界:Copilot 与 Cursor 后来都有了 Agent 模式(见 §1.12)。Claude Code 自己的时间线(均以 Anthropic 官方公告为准,核对于 2026-09-24):2025-02-24 随 Claude 3.7 Sonnet 以"有限研究预览"发布,当时就是终端工具;2025-05-22 随 Claude 4 宣布正式可用(GA),同时推出 VS Code / JetBrains 的 beta 扩展和 Claude Code SDK;2025-09-29 Claude Code SDK 更名为 Claude Agent SDK,官方说法是"驱动 Claude Code 的那套 agent harness"也能驱动其他类型的 Agent。截至核对日,官方文档列出的使用入口有终端、IDE 扩展、桌面应用(Code 标签页,自带 Claude Code,无需另装 CLI)和网页,并称各入口连接的是"同一个底层 Claude Code 引擎"。本专栏讲的是其中终端 CLI 那一份代码在 2026-03-31 前后的快照。

graph LR
    subgraph "第一阶段: 补全"
        C1["用户写代码"] --> C2["模型补全一行"]
    end
    subgraph "第二阶段: 对话"
        D1["用户描述需求"] --> D2["模型生成代码"] --> D3["用户确认/修改"]
    end
    subgraph "第三阶段: Agent"
        A1["用户描述目标"] --> A2["Agent 自主规划"]
        A2 --> A3["读代码 / 搜索"]
        A3 --> A4["编辑 / 执行"]
        A4 --> A5["验证 / 迭代"]
        A5 -->|"未完成"| A3
    end
    style C1 fill:#dbeafe,stroke:#3b82f6
    style D1 fill:#fef3c7,stroke:#f59e0b
    style A1 fill:#dcfce7,stroke:#22c55e

这种能力的实现,需要一套远比传统 CLI 工具复杂得多的架构。

Claude Code 的规模

让我们用数字感受一下这个系统的规模:

源码总量:        ~510,000 行 TypeScript
内置工具:        40+ 个(Read, Write, Edit, Bash, Grep, Glob, Agent...)
CLI 命令:        80+ 个
MCP 协议集成:    src/services/mcp/ 约 25 个文件、40 万字符(客户端 + OAuth + 服务发现,详见第 11 章)
权限规则引擎:    7 种模式(6 种用户可选 + 仅内部使用的 bubble)+ 动态分类器(详见第 9 章)
终端 UI:         React + Ink 组件库(详见第 17 章)

口径说明:这里的 ~510,000 行是整仓、含测试的口径。如果只统计 src/ 一棵子树下的 .ts 文件(同样含测试),实测是 379997 行——这是《Harness Engineering》专栏第 1 章 §1.3.1 与《MCP 协议设计与实现》专栏第 19 章使用的口径。两个数不矛盾,差额来自 src/ 之外的部分与 .tsx 文件,但引用时不能互换。本专栏后文凡出现"51 万行",指的都是整仓口径。

作为对比:一个典型的"30 行 Agent Demo"只有模型调用 + 工具循环。Claude Code 在这之上多出的 51 万行代码,就是 Harness——让模型变成可靠产品的一切工程。

1.2 为什么选择 Claude Code 作为学习对象

市面上有很多 AI 编程工具,为什么要深入 Claude Code?

第一,它的源码曾经完整可见。 Claude Code 本身不开源(npm 包的 LICENSE.md 写的是 "© Anthropic PBC. All rights reserved."),Cursor、Windsurf 也是闭源产品。2026 年 3 月底 Claude Code 的完整 TypeScript 源码通过 npm 包里的 source map 意外流出,一个闭源的商业编程 Agent 的内部实现因此被外界看到。开源的同类并非没有——OpenAI 的 Codex CLI 与 Google 的 Gemini CLI 都以 Apache-2.0 开源,Aider 也是开源项目,Copilot 的 VS Code 聊天扩展 2025 年起以 MIT 许可开源——但 Claude Code 这份快照的规模与产品化程度,让它成了一个难得的解剖对象。

第二,它的架构足够复杂。 51 万行代码不是堆砌出来的——工具系统、权限模型、MCP 集成、多 Agent 协调、Remote Control 桥接,每个子系统都经过了深思熟虑的设计。这些设计模式可以直接迁移到你自己的 Agent 项目中。

第三,它在真实生产环境中经受了考验。 这不是一个 demo 或 POC,而是被大量开发者日常使用的工具。每一个"看起来奇怪"的设计决策背后,通常都有一个真实的 bug 或性能问题驱动。

1.3 你将学到什么

读完本专栏,你将理解:

系统架构层面

  • 如何设计一个流式 Agent 循环,让 AI 的思考过程实时可见
  • 如何构建可扩展的工具系统,支持 40+ 工具的注册、校验、执行和权限控制
  • 如何实现多模式权限模型,在安全和效率之间找到平衡点
  • 如何集成 MCP 协议,让第三方工具无缝接入

工程实践层面

  • 如何用 async generator 实现可组合、可取消的流式管道
  • 如何用 Zod 做运行时类型校验,让工具输入类型安全
  • 如何用 React + Ink 构建复杂的终端 UI
  • 如何通过并行预加载和特性标志优化 CLI 启动速度
  • 如何用 JWT 实现 CLI 与 IDE 之间的安全通信

设计思维层面

  • 为什么选择"工具即对象"而不是类继承?
  • 为什么用 generator 而不是回调?
  • 为什么权限检查要分静态规则和动态分类器两层?
  • 为什么 MCP 客户端要实现完整的 OAuth 流程?

这些问题的答案,比代码本身更有价值。

1.4 本专栏的组织方式

本专栏按照从外到内、从宏观到微观的顺序组织:

  1. 架构总览(第2章)——先看全景图,理解各子系统的关系
  2. 启动与核心循环(第3-5章)——从 CLI 入口到 Agent 循环,理解主干流程
  3. 工具系统(第6-8章)——深入最核心的工具类型、编排和实现
  4. 权限与安全(第9-10章)——理解安全模型的设计哲学
  5. 协议与集成(第11-13章)——MCP、Remote Control 桥接、LSP 的集成架构
  6. Agent 进阶(第14-16章)——多 Agent、Skill、上下文管理
  7. UI 与工程(第17-18章)——终端 UI 和可迁移的设计模式

每一章的结构是:设计意图 → 代码实现 → 可迁移的模式。

让我们开始吧。

1.5 一窥源码——Tool 类型的 30 多个字段

为了让"51 万行代码"不是抽象数字——给你一个具体切片:Claude Code 的 Tool 类型(src/Tool.ts:362+)定义了30 多个字段和方法——每一个都对应一类真实产品需求。下面按职责分组列出其中的主要部分(省略少量内部字段):

export type Tool<Input, Output, P> = {
  // 身份相关
  readonly name: string
  aliases?: string[]                              // 重命名兼容
  readonly inputSchema: Input                     // Zod schema
  readonly inputJSONSchema?: ToolInputJSONSchema  // MCP 原生 JSON Schema
  searchHint?: string                             // ToolSearch 关键词

  // 执行相关
  call(args, context, canUseTool, parent, onProgress): Promise<ToolResult>
  description(input, options): Promise<string>    // 动态描述、每次调用生成
  validateInput?(input, context): Promise<ValidationResult>
  checkPermissions(input, context): Promise<PermissionResult>

  // 能力声明
  isConcurrencySafe(input): boolean              // 能和其他工具并发吗
  isReadOnly(input): boolean                      // 是读操作吗
  isDestructive?(input): boolean                  // 不可逆吗
  isOpenWorld?(input): boolean                    // 访问外部系统吗
  isSearchOrReadCommand?(input): {...}           // UI 折叠展示
  isEnabled(): boolean
  isMcp?: boolean                                 // 来自 MCP server
  isLsp?: boolean                                 // 来自 LSP

  // UI 和交互
  interruptBehavior?(): 'cancel' | 'block'       // 被打断时的行为
  requiresUserInteraction?(): boolean

  // 性能和 token
  maxResultSizeChars: number                     // 结果持久化门槛
  readonly strict?: boolean                       // API 严格模式
  readonly shouldDefer?: boolean                  // 延迟加载 schema
  readonly alwaysLoad?: boolean                   // 总是加载

  // 生命周期 hook
  backfillObservableInput?(input): void          // observer 看到前修饰
  inputsEquivalent?(a, b): boolean               // 等价性判断

  // MCP 元信息
  mcpInfo?: { serverName; toolName }
  outputSchema?: z.ZodType<unknown>

  // 文件路径
  getPath?(input): string

  // hook 准备
  preparePermissionMatcher?(input): Promise<(pattern: string) => boolean>
}

每个字段都回答一个具体的工程问题:

  • 没有 isConcurrencySafe——并发执行两个 Edit 会 race condition
  • 没有 interruptBehavior——工具运行中用户提交新消息时,无从区分"该中止的工具"和"该跑完的工具"
  • 没有 shouldDefer——所有工具 schema 全塞 prompt、token 爆掉
  • 没有 backfillObservableInput——hook 看不到 legacy 字段
  • 没有 checkPermissions——所有工具都能无条件执行、安全崩
  • 没有 preparePermissionMatcher——hook 里 Edit(src/**) 这类规则式匹配无从按工具自己的输入语义(文件路径、Grep 的 pattern 等)去比对

一个字段 = 一类真实产品问题的沉淀。这是本专栏第一个 takeaway——简单的"工具"概念在生产级 Agent 系统里变成了三十来维的空间。学会识别这些维度,你就知道评估任何 Agent 框架时要问什么问题。

1.6 TypeScript 类型系统作为架构语言

注意 Tool 类型的签名:

export type Tool<
  Input extends AnyObject = AnyObject,
  Output = unknown,
  P extends ToolProgressData = ToolProgressData,
> = { ... }

三个泛型参数——让每个工具精确表达自己的输入、输出、进度类型。这不是装饰——它让 TypeScript 编译器在工具调用处做静态类型检查:

// FileReadTool 的 call 接受 { file_path: string, offset?: number, limit?: number }
// FileEditTool 的 call 接受 { file_path: string, old_string: string, new_string: string }
// 类型错了编译期就挂掉

为什么不用 any 省事?——因为 Claude Code 内部有 40+ 工具——手动记住每个的参数不现实。TypeScript 泛型让 IDE 自动补全、重构一处改 30 处——开发效率和正确性双赢。

这是 TypeScript 作为"架构语言"的核心价值——不只是 JS + 类型检查、是让代码的架构意图能被编译器理解。Claude Code 的 51 万行里、类型定义占了可观比例——这些类型是活的文档、也是编译期防御。

对比不写类型标注的 Python agent 代码——每个工具的参数要读文档或 docstring 才知道。TypeScript 把"内部代码调错工具"这类问题提前到编译期;至于 LLM 生成的工具参数,它只在运行时才出现,任何语言都得在运行时校验——这正是 Zod 的分工。两者合起来,对 51 万行代码的可维护性很关键。

1.7 从 30 字段看产品级 vs demo 级的差距

网上有大量 "30 行代码写一个 Claude Code-like agent" 的教程——它们长这样:

# 30 行版
TOOLS = {
    "read_file": lambda path: open(path).read(),
    "write_file": lambda path, content: open(path, "w").write(content),
    "bash": lambda cmd: subprocess.run(cmd, shell=True).stdout.decode(),
}

def agent(prompt):
    while True:
        response = llm.call(prompt, tools=TOOLS)
        if response.is_final:
            return response.text
        result = TOOLS[response.tool_name](**response.tool_args)
        prompt += f"\nTool result: {result}"

这 30 行能跑——对 hello-world 够用。但生产级 Claude Code 的 51 万行包含的每一块都对应 demo 里的一个失败模式:

  • Demo 失败:bash("rm -rf /") ——Claude Code 的 checkPermissions 拦
  • Demo 失败:并发 write_file 到同一路径 race ——Claude Code 的 isConcurrencySafe 管
  • Demo 失败:grep 或命令输出几万行、塞爆上下文 ——Claude Code 的 maxResultSizeChars 超限即持久化到磁盘(Read 例外,它设为 Infinity、靠自身的 offset/limit 限量,见 §1.22)
  • Demo 失败:工具运行中用户插话,要么全停要么全等 ——Claude Code 的 interruptBehavior 让工具自己声明
  • Demo 失败:每次 prompt 重传 40 工具 schema、token 爆 ——Claude Code 的 shouldDefer 延迟加载
  • Demo 失败:LLM 写错工具名叫 old name ——Claude Code 的 aliases 兼容
  • Demo 失败:第三方 MCP server 的工具被无条件执行 ——Claude Code 的 isMcp 标记 + 权限检查把它们与内置工具区别对待
  • Demo 失败:hook 拿到的路径时而相对时而绝对、规则匹配失准 ——Claude Code 的 backfillObservableInput 在观察者看到前统一补齐

一百个小问题 × 真实生产的放大系数 = 51 万行。这就是为什么这个专栏要花几百页讲 Claude Code——每一万行都解决一类你在 demo 里想不到的问题。

读这个专栏的回报是什么?——下次你自己写 Agent 时、知道要考虑这些维度——不用等到生产环境被用户发现 bug 再回炉。

1.8 Harness 这个词的由来

本专栏反复用到 "Harness"(挽具)这个词——指 "让模型真的能用起来的所有工程代码"。Anthropic 自己也这么用:2025-09-29 宣布 Claude Code SDK 更名为 Claude Agent SDK 的文章,就把它称为 "the agent harness that powers Claude Code"。

词源自 Harness horse——马的挽具、让马的原始力量能被有意图地用。LLM 模型是"马"、Harness 是"让马能拉车"的所有周边工程:

  • Prompt engineering——怎么写系统提示、few-shot 例子
  • Tool orchestration——40+ 工具的注册、权限、并发
  • Memory / Context——对话历史、工作区状态的管理
  • Safety rails——什么能做、什么不能做
  • Error recovery——出错怎么 retry、怎么降级
  • Observability——日志、metrics、用户可见性
  • UX layer——终端 UI、流式输出、中断响应

Harness 是比"prompt"更深的功夫——会写 prompt 的人多,能把 harness 做到生产级的人少。

读 Claude Code 源码、就是学世界顶级的 harness engineering。

1.9 和本系列其他专栏的关联

这个专栏是系列专栏中的一个——和其他几个有紧密关联:

  • 《MCP 协议设计与实现》——Claude Code 内置完整的 MCP 客户端(src/services/mcp/,约 25 个文件、40 万字符),本专栏第 11 章会反复引用《MCP 协议设计与实现》专栏的协议设计
  • 《LangChain 设计与实现》——LangChain 的 Agent 抽象是框架化的另一条路,和 Claude Code 这种"单一产品内建 harness"的做法可以对照阅读
  • 《LangGraph 设计与实现》——LangGraph 的 Pregel 模型和 Claude Code 的 Agent 循环(本专栏第 4 章)是两种不同的 agent runtime 范式,适合对照阅读
  • 《React 19 内核探秘》——Claude Code 用 React + Ink 做终端 UI,本专栏第 17 章讲它自定义的 React reconciler,有 React 源码基础会读得更顺
  • 《Serde 元编程》——Claude Code 大量用 Zod 做 schema validation(本专栏第 6 章),和 Serde 用 derive 从类型生成(反)序列化代码是同一类问题的两种解法,可以对照阅读

读这几个专栏合起来、才能完整把握 "Agent 工程" 这个新兴领域的工程全景。

1.10 阅读建议

这个专栏不是 Claude Code 的使用手册——想学怎么用、看官方文档。这个专栏是源码内幕——假设读者:

  • 用过 Claude Code 至少几次、理解基本概念(对话、工具调用、权限)
  • 懂 TypeScript(或至少 JavaScript + 基本类型概念)
  • 有一定的架构直觉(至少写过几千行代码的项目)

如果你是前端工程师——React + Ink + async 部分会很顺、工具系统部分需要多点想象。

如果你是后端工程师——工具系统 / 权限 / 协议部分会很顺、前端 UI 部分可以快读。

如果你是 AI 工程师但没大项目经验——Harness 部分是核心财富、会补齐你从 "会 prompt" 到 "能写生产 agent" 的关键一跃。

读完这一章、你应该对 "Claude Code 到底是什么"、"为什么复杂"、"学它有什么价值" 有了一个骨架认识。下一章进入架构全景——从入口到循环到工具、把整个系统串成一张图——让你在深入任何细节前、先有全局视角。

1.11 一个真实对话的全链路——窥探系统内部

用一个简单的对话示例——"请找出项目里所有待办注释"——展开看 Claude Code 内部发生的事:

Step 1:用户输入进入终端——React + Ink 组件接收 keystroke、累积到 prompt buffer。按 Enter 后 prompt 被 serialize、送到 main query loop。

Step 2:进入查询循环(交互式 REPL 由 REPL.tsx 直接调用 src/query.ts 的 query();SDK 与 -p 才经过 src/QueryEngine.ts,1295 行)——拼装 system prompt、user message、注入工具 schema(部分 defer-loaded)、调用 Anthropic API。

Step 3:LLM 响应流开始——Server-Sent Events 一个 token 一个 token 进来。React UI 通过 async generator 实时渲染——用户看到"打字机效果"。

Step 4:LLM 输出 tool_use block——"I'll use Grep to search task markers"——同时输出 tool call:Grep({ pattern: "FIXME|待办", glob: "*.{ts,tsx}" })。

Step 5:tool_use block 触发查询循环的 tool dispatch——找到 GrepTool,依次经过 validateInput → PreToolUse 钩子 → 权限检查(含工具的 checkPermissions)→ call → PostToolUse 钩子。

Step 6:GrepTool 的 call 方法——把参数拼成 ripgrep 命令行、以子进程运行,等它跑完后一次性拿到全部输出,再按 head_limit / offset 截取(2.1.89 的 cli.js 里 Grep 的 call 只解构前两个参数,不接 onProgress)。

Step 7:tool_result 回填——GrepTool 执行完毕、结果按 maxResultSizeChars 判断——小的直接回给 LLM、大的持久化到磁盘再把文件路径回给 LLM。

Step 8:LLM 拿到 tool_result 继续生成——可能继续调 Read 查看几个匹配、继续调 Grep 缩范围、或者直接生成最终总结。

Step 9:LLM 输出 final text——回到 React UI 流式渲染——用户看到 "找到 47 个待办标记、主要集中在 src/utils/... 和 tests/..."。

Step 10:对话留存——user message + assistant messages 都 append 到会话转录、以 JSONL 写到 ~/.claude/projects/<项目>/ 下供下次恢复(~/.claude/history.jsonl 另存的是你输入过的 prompt 历史,见第 16 章)。

这 10 步每一步都是 Claude Code 源码里的几百到几千行代码——整个 51 万行就是这 10 步的各种路径和边界情况。本专栏的 18 章就是把这 10 步拆开讲——每一步的设计动机、代码实现、工程权衡。

1.12 Claude Code 和传统 IDE 插件的边界

一个常见的比较——Claude Code vs GitHub Copilot / Cursor。先说清口径:下面每家的能力只取对方官方文档写明的(核对于 2026-09-24),不凭印象。三家今天都有 Agent 能力,差别主要在起点形态和主战场:

Copilot(从 IDE 内补全起步):

  • 起点:2021 年 6 月技术预览,边打字边给补全建议
  • 今天:GitHub 文档列出代码补全、Copilot Chat、agent mode("描述目标后多步执行:研究仓库、提出计划、编辑文件、运行工具"),以及在云端处理任务并开/更新 PR 的 coding agent
  • 主战场:IDE 与 GitHub 平台本身

Cursor(VS Code 的 fork,编辑器即产品):

  • 起点:编辑器内的对话与多文件编辑
  • 今天:官方文档称 Cursor Agent 能"独立完成复杂编码任务、运行终端命令、编辑代码",自主搜索文件、跨多文件修改;另有 CLI 和网页端的 Agent 入口
  • 主战场:编辑器

Claude Code(从终端 CLI 起步):

  • 起点:2025 年 2 月以终端工具形态研究预览,显式命令(claude)启动
  • 今天:终端之外还有 IDE 扩展、桌面应用和网页入口(见 §1.1),但本专栏拆的是终端 CLI 那份代码
  • 形态:对话 + 自主工具调用,2.1.89 的 cli.js 里带 searchHint 的工具定义有三十多个(读写、搜索、命令、任务、MCP 资源、子 Agent 等)

终端 Agent 这条路也不止 Claude Code 一家:OpenAI 的 Codex CLI(官方仓库自述 "Lightweight coding agent that runs in your terminal",Apache-2.0)、Google 的 Gemini CLI("an open-source AI agent that brings the power of Gemini directly into your terminal",Apache-2.0)、开源的 Aider(在终端里与 LLM 结对编程)都属于这一类。

选哪个?——本专栏不做产品评测,这类比较变化极快,以各家当前文档为准。对本专栏更重要的是:无论哪家,一旦让模型"替你执行任务",就绕不开权限、并发、上下文、中断这一整套 harness 问题——而 Claude Code 是其中少有的、完整实现曾经可以被逐行阅读的一个。

1.13 为什么讲 TypeScript 而不是 Go/Rust 实现

有读者可能问——Anthropic 为什么用 TypeScript 写 Claude Code、不用更"性能的" Go 或 Rust?

几个原因:

(先声明:Anthropic 没有在官方文档里解释过这个选型,以下是本专栏的推测,不是官方说法。)

① 生态成熟度——LLM 生态的 SDK 以 Python 和 TypeScript 为主,Anthropic 官方也同时维护这两门语言的 SDK。

② 快速迭代——Claude Code 自 2025 年 2 月发布以来一直处于高速迭代期——TypeScript 的类型推导 + 热重载比 Rust 编译时间友好得多。

③ UI 生态——React + Ink 让终端 UI 能用前端的组件思维——Rust 的 tui-rs / Ratatui 要自己手写布局、开发效率明显更低。

④ 生态定位——面向广大开发者的工具,用 TypeScript 这样的最大公约数语言,写插件、MCP server、hook 脚本的门槛都更低(注意 Claude Code 本身并不开源)。

⑤ 性能不是瓶颈——Claude Code 的主要等待是LLM API 延迟、不是 JS 执行速度,两者差着数量级——换成 Rust 省下的那点执行时间,用户在等模型响应时几乎感知不到,优化 UI 状态比优化底层有意义。

⑥ 招聘池——全球 TypeScript 开发者的基数远大于 Rust——维护团队扩容容易。

TypeScript 的劣势——运行时类型擦除、需要 Zod 做 runtime validation、Node.js 内存占用高于 Rust 进程——但对这个产品形态,这些劣势可接受。

工具选择永远是 "用最适合场景的"——不是 trendy 的、也不是极限性能的、而是 "能快速交付优质产品的"。反过来也要看到,同类的 Codex CLI 就走了 Rust 路线(官方仓库里有 Rust 实现 codex-rs),两种选择各有代价。

1.14 Claude Code 源码目录结构全景

在进入后续章节前、扫一眼源码目录——让你对后续章节对应的文件位置有个地图。src/ 下面 53 个顶层目录/文件——按职责分组如下(只列代表性条目,不给各组行数:各组的精确行数本专栏没有统一实测,仅知 src/tools/ 为 42309 行,见 §1.17):

核心 runtime:

  • QueryEngine.ts(1295 行)——会话层编排,SDK 与 -p 的入口(主循环在 query.ts)
  • Tool.ts(792 行)——工具类型定义
  • Task.ts(125 行)——后台任务管理
  • commands/、commands.ts——slash commands 注册
  • hooks/——各类 pre/post hook 的协议
  • context/——对话上下文、消息流
  • coordinator/——多 agent 协调

工具实现:

  • tools/(40+ 子目录)——每个工具独立文件夹
  • services/mcp/——MCP 协议客户端实现
  • services/lsp/——LSP 客户端(第 13 章)

UI 层:

  • components/——React + Ink 组件
  • ink/、ink.tsx——Ink 集成
  • dialogLaunchers.tsx——对话框触发器
  • keybindings/——快捷键系统

入口和配置:

  • entrypoints/——不同入口(cli、sdk、bridge)
  • cli/——CLI 解析和路由
  • bootstrap/——启动前的预加载和初始化
  • constants/——常量定义

持久化和迁移:

  • memdir/——会话记忆目录
  • history.ts——对话历史
  • migrations/——老版本数据迁移

bridge 和集成:

  • bridge/——和 IDE 的桥接协议
  • native-ts/——原生 TypeScript 集成
  • assistant/——assistant 配置和 profile

其他:

  • buddy/——终端里的"伙伴"(companion)形象及其即时反应(2.1.89 的 cli.js 里可见 buddy_companion、/claude_code/buddy_react 等字符串;不是一种 agent 模式)
  • cost-tracker.ts、costHook.ts——成本追踪
  • interactiveHelpers.tsx——交互辅助

这53 个顶层条目映射到本专栏的 18 章——每一章深入 1-3 个模块。你在读某一章时、可以直接翻到对应源码文件对照看——是本专栏的核心学习方式。

1.15 源码可见性的来源

这个专栏的契机是 2026 年 3 月底 Claude Code 完整源码通过 npm source map 流出:@anthropic-ai/claude-code@2.1.88(2026-03-30 发布,随后下架)的包里带了 .js.map 文件,它在 sourcesContent 字段里内嵌了原始 TypeScript 源码,用 source-map-explorer 一类工具就能还原回 .ts。次日发布的 2.1.89 已不再带 map。

至于这个 map 为什么会被打进发布包,Anthropic 没有公开过技术细节,本专栏不做揣测。

这种意外公开的边界很灰——map 文件确实随公开的 npm 包发出过,但 Anthropic 没有显式同意它被系统性研究。本专栏的处理方式是:只讲设计原则、不发布完整代码、引用最小必要片段,用于教学而非竞品复制。

1.16 术语约定

本专栏会用到一些特定术语——先统一约定、避免混淆:

  • Agent——指 Claude Code 的某次运行实例(类似"进程")
  • Sub-agent——指 AgentTool 启动的子 Agent(可能与主 Agent 同进程,也可能是独立进程,见第 14 章)
  • Tool——指 Claude Code 的工具实现(区别于"工具"泛指)
  • Session——指一次完整的对话会话(磁盘持久化)
  • Harness——让模型变成可靠产品的所有工程
  • Turn——一次 user → assistant 的来回(对话的基本单元)
  • Stream——LLM 的 token-by-token 输出流
  • Hook——插入到特定生命周期点的扩展代码
  • MCP——Model Context Protocol、第三方工具协议

这些术语在第 2 章会扩充,每个术语后都有精确定义。

1.17 三个真实数字——感受代码的密度

再给你三个数字感受 Claude Code 的工程密度:

数字一:Tool.ts 单文件 792 行、30 多个字段和方法——这只是类型定义、不是实现。真实工具实现在 40 来个子目录里——光 FileEditTool 就有 1524 行(口径同下文),因为要处理:

  • 文件不存在的 create
  • 文件已存在的 edit
  • 多 replace 的 batch
  • LSP 诊断集成
  • git rename detection
  • 权限/hook 调用
  • UI diff 渲染
  • 错误恢复
  • 测试 mock 接口

FileEdit 这一个工具的复杂度、就超过你想象的"用 fs.writeFile 一行搞定"几十倍。每个工具都如此。不过这里要给个实测数字而不是拍脑袋:作者同一份快照下 src/tools/ 合计 42309 行(统计口径见《Harness Engineering》专栏第 1 章 §1.3.1),约 40 个内置工具,平均每个工具约 1057 行——包含 prompt、实现、类型与测试。单看 BashTool 就有 10894 行、PowerShellTool 7829 行,而 FileReadTool 只有 1418 行:工具之间的体量差着近十倍,"平均值"在这里意义有限,真正说明问题的是最重的那几个。

数字二:80+ CLI 命令——包括 /help、/clear、/compact、/status、/review、/fast、/schedule、/doctor、/hooks、/agents、/logout、/debug 等(这几个名字都能在 2.1.89 那份 npm 包的 cli.js 里按 name:"…" 的命令声明查到;/worktree、/sidebar 这两个在该快照里还不是斜杠命令——worktree 当时只有 EnterWorktree / ExitWorktree 两个工具,sidebar 则是 tengu_terminal_sidebar 这个默认关闭的终端状态位)。每个命令后面都有完整的 React 组件、slash 解析、参数校验——粗估平均每个 300 行、合计约 24k 行(这是量级估算,不是实测)。

数字三:40+ 键盘快捷键和 shortcuts——按键组合 + 上下文映射——输入框支持默认模式和 Vim 模式(2.1.89 的 editorMode 选项只有 normal 与 vim,旧值 emacs 会被当作 normal)——两种模式下同一键位可能有完全不同含义——键绑定系统本身就是一个小型状态机。

这三个数字撑起的是功能面的广度,不要拿它们去反推总行数——工具、命令、快捷键之间大量共享 utilities,简单相乘会重复计算。全仓规模以 §1.1 的口径说明为准。

这个量级的代码量在 CLI 工具里是极端罕见的——它更接近一个 IDE 级别的项目、而不是一个命令行程序;Claude Code 从 2025 年 2 月研究预览到这份 2026 年 3 月底的快照不过一年多、51 万行是惊人的工程产出密度。

1.18 第 2 章的骨架图预告

下一章会给出一张一屏图——把 Claude Code 整个系统画成这样:

    ┌──────────────────────────────────────────────────┐
    │  CLI entry (claude)                              │
    │    ↓                                             │
    │  Bootstrap (parallel preload, feature flags)     │
    │    ↓                                             │
    │  query() main loop (REPL / QueryEngine)          │
    │    ↓    ↑                                        │
    │  ┌──────┴──────┐                                 │
    │  │ LLM API     │  ←→  Streaming (SSE)            │
    │  └──────┬──────┘                                 │
    │    ↓                                             │
    │  Tool Dispatch                                   │
    │    ↓                                             │
    │  ┌──────────────────────┐                        │
    │  │ validateInput()      │                        │
    │  │ PreToolUse hooks     │                        │
    │  │ checkPermissions()   │  ←→  Permission Engine │
    │  │ call() + PostToolUse │                        │
    │  └──────────────────────┘                        │
    │    ↓                                             │
    │  Tool Result                                     │
    │    ↓                                             │
    │  React + Ink UI (streaming)                      │
    │    ↓                                             │
    │  History / Persistence                           │
    └──────────────────────────────────────────────────┘

这张图的每一条线都对应一章——18 章读完、这张图就从简化的 box-and-arrow 变成带代码记忆的真实系统。

1.19 代码阅读方法论

分享一套读复杂 TypeScript 项目的方法论——在后续章节反复用到:

① 先读 type 定义、再读 implementation——Tool.ts 的 792 行里一半是类型定义——看懂这些类型就抓住了大部分设计意图。实现只是填充类型骨架的血肉。

② 入口优先——先看 main.tsx / cli/ 入口——顺着调用链下沉——比随机翻某个文件理解得快得多。

③ 找 central hub——QueryEngine / Tool / Task 这种被所有地方引用的核心类型——是理解整体的锚点。

④ 按"数据流"读、不是按"调用栈"读——跟踪用户输入怎么变成 LLM 响应、工具调用怎么回传结果——比读每个函数的调用关系顺得多。

⑤ 大量用 grep / search——找一个函数在哪被调——ripgrep + context lines——几秒知道一个函数的生命周期。

⑥ 画图同步——一边读一边在纸上/白板画 box-and-arrow——主动构造心智模型——比单纯看代码记得牢得多。

⑦ 对比类比——遇到新概念问自己 "像别的什么"——比如 Tool 的 30 字段类似于Rust trait 的多 method、或Java Servlet 的 lifecycle methods——类比能快速建立理解。

⑧ 不懂就跳过——第一遍读完整个目录允许 30% 不懂——第二遍重点攻这 30%。线性读完一遍 > 前 20% 读透但永远没读后面——先拿到整体感最关键。

这套方法论在读 React / Tokio / serde 等复杂项目时同样适用。

1.20 一段真实源码——ToolPermissionContext

在结束前、再给你一段真实源码切片感受 Claude Code 的密度——Tool.ts 里的 ToolPermissionContext:

export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource
  alwaysDenyRules: ToolPermissionRulesBySource
  alwaysAskRules: ToolPermissionRulesBySource
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  strippedDangerousRules?: ToolPermissionRulesBySource
  /** When true, permission prompts are auto-denied
   *  (e.g., background agents that can't show UI) */
  shouldAvoidPermissionPrompts?: boolean
  /** When true, automated checks (classifier, hooks) are awaited before
   *  showing the permission dialog (coordinator workers) */
  awaitAutomatedChecksBeforeDialog?: boolean
  /** Stores the permission mode before model-initiated plan mode entry,
   *  so it can be restored on exit */
  prePlanMode?: PermissionMode
}>

短短 20 来行代码、至少藏着 8 个深层工程决策:

① DeepImmutable<{...}> wrapper——让整个类型递归只读(包括 Map、包括嵌套对象)——防止中间层代码 mutate context。注意这只是编译期约束:类型擦除后运行时并不冻结对象,靠的是所有代码都过类型检查。

② Map<string, AdditionalWorkingDirectory> 而非 Record<string, ...>——Map 保持插入顺序、允许非字符串 key(未来扩展)、iteration 更清晰——Record 是 plain object、有 prototype pollution 风险。

③ 三种规则分类 alwaysAllow/Deny/Ask——对应 三种权限决策——比"allow/deny"两态丰富、让"ask once then remember" 这类交互成为可能。

④ ToolPermissionRulesBySource——权限规则按来源分组(workspace 配置、用户全局配置、CLI flag 等)——让用户知道哪条规则来自哪里、易于 debug。

⑤ isBypassPermissionsModeAvailable——运行时 feature flag——某些环境(企业部署)禁用 bypass 模式——让权限系统有 defense-in-depth。

⑥ shouldAvoidPermissionPrompts——区分 "可以弹 UI 问用户" vs "后台 agent 不能问"——后台 agent 看到权限问题只能拒绝、不能阻塞等用户。

⑦ awaitAutomatedChecksBeforeDialog——coordinator workers 的特殊需要——先跑 classifier + hook、再决定要不要问用户——让 dialog 只出现在真正需要人决策的场景、减少打扰。

⑧ prePlanMode——Plan mode 进出的状态保存——从 plan mode 出来后能回到进入前的状态——避免用户丢失上下文。

20 来行代码 = 8 个产品决策 = 大量用户反馈的沉淀。这就是本专栏要拆解的东西——让你看到代码之下的产品心智。

1.21 searchHint 字段——ToolSearch 机制的开端

Tool 类型里有一个小字段、承载着 Claude Code 最精妙的"token 管理策略"——searchHint:

/**
 * One-line capability phrase used by ToolSearch for keyword matching.
 * Helps the model find this tool via keyword search when it's deferred.
 * 3–10 words, no trailing period.
 * Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
 */
searchHint?: string

这个字段存在的原因——Claude Code 的40+ 工具再加上 MCP 工具,如果 schema 全塞 prompt、会占掉可观的 context window。

解法是 ToolSearch 机制——把一部分工具标记为 deferred(2.1.89 的判定:alwaysLoad 为真的不延迟,MCP 工具一律延迟,内置工具看 shouldDefer)——模型一开始只看到这些工具的名字,完整定义不进模型上下文;要用时调 ToolSearch 搜索,命中后定义才被展开(请求层面的细节见《MCP 协议设计与实现》专栏第 19 章)。

searchHint 就是供 ToolSearch 做关键词匹配的——它只在本地打分时用,不发给模型。每个工具写一句 3–10 词的能力短语,让模型搜 "jupyter" 时 ToolSearch 能匹配到 NotebookEdit(其 searchHint 为 "edit Jupyter notebook cells (.ipynb)")。

三个细节约束:

  • 3–10 words——太短关键词不够、太长会稀释匹配
  • "no trailing period"——避免拼接时出现 "jupyter. use for..." 的标点问题
  • "Prefer terms not already in the tool name"——别重复工具名本身、扩展搜索命中率

这个策略的量级(纯假设性粗算,不是实测值):

  • 无 ToolSearch:假设 40 个工具、每个定义约 500 token = 约 20,000 token
  • 有 ToolSearch:假设 5 个常驻工具 × 500 + 35 个 deferred 工具只列名字(每个按 10 token 算)= 约 2,850 token
  • 在这组假设下,工具相关 token 省掉八成以上

省下的 token 给对话历史 + 代码上下文,让 Claude Code 能在同样 context window 下处理更长的任务。

这个小字段背后是整个 token 经济学——40+ 工具、3–10 词的 searchHint 规范、"只给名字 → 搜索 → 展开定义"的延迟加载,合起来是 Claude Code 能使用这么多工具而不炸 context window 的关键。本专栏第 6 章会回到 searchHint 与工具 Schema 的延迟加载。

1.22 maxResultSizeChars 字段——输出爆炸的防护

再看一个字段——maxResultSizeChars:

/**
 * Maximum size in characters for tool result before it gets persisted to disk.
 * When exceeded, the result is saved to a file and Claude receives a preview
 * with the file path instead of the full content.
 *
 * Set to Infinity for tools whose output must never be persisted (e.g. Read,
 * where persisting creates a circular Read→file→Read loop and the tool
 * already self-bounds via its own limits).
 */
maxResultSizeChars: number

问题场景:Grep 搜索一个大代码库、匹配 10000 行——直接塞进 LLM prompt 会爆 context。

解法是output persistence——结果超过 maxResultSizeChars 时、写到磁盘、只回给 LLM 一个预览 + 文件路径。2.1.89 的 cli.js 里,多数工具设的是 100,000 字符,Bash / PowerShell 为 30,000,Grep 为 20,000,Read 为 Infinity。LLM 想看完整输出、用 Read 工具读那个文件——按需加载。

Infinity 的特殊值——Read 工具的输出不能持久化——因为 "持久化 Read 的结果到文件、再 Read 那个文件" 会形成无限循环。Read 靠自己的 offset/limit 机制限量。

这种"结果太大自动持久化、LLM 按需加载" 的模式是 LLM agent 必备机制——否则一次 grep 能把整个 context 吃光。

实现细节——Claude Code 内部有 ContentReplacementState 机制(utils/toolResultStorage)——把持久化的内容替换成 placeholder、后续 LLM 可以 reference。

这个机制的巧妙处——对 LLM 透明——LLM 看到的 "结果太大、已存到会话目录下 tool-results/ 里的某个文件" 就当作正常的工具输出、自然地继续 chain-of-thought——Read 一下就拿到详情。

一个 number field + 几行持久化逻辑 = LLM agent 能处理任意大小输出的关键 trick。

1.23 interruptBehavior ——工具运行中来了新消息怎么办

最后一个字段——interruptBehavior:

/**
 * What should happen when the user submits a new message while this tool
 * is running.
 *
 * - `'cancel'` — stop the tool and discard its result
 * - `'block'`  — keep running; the new message waits
 *
 * Defaults to `'block'` when not implemented.
 */
interruptBehavior?(): 'cancel' | 'block'

问题场景:Bash 工具正在跑 npm test(10 分钟)、用户突然说 "算了先写个 README"——怎么办?

两种策略:

  • cancel——立刻停 test、开始写 README——响应快、但 test 状态丢失
  • block——让 test 跑完、完了再处理新 message——state 不丢、但用户等太久

Claude Code 的选择是让每个工具自己决定——默认 block、特定工具可以 override 成 cancel。执行器取不到该方法(或调用抛错)时一律按 block 处理;只有当前有声明为 cancel 的工具在跑,提交新消息才会以 "interrupt" 为理由中止本轮(遥测事件 tengu_cancel,source 为 interrupt_on_submit;优先级为 now 的排队消息也会触发同样的中止)。

实际情况:在 2.1.89 的公开包里,interruptBehavior 只出现在执行器读取它的地方,没有任何内置工具覆盖它——也就是说 Bash、Edit、Grep、Agent 等全部走默认的 block。这个接口是给"可以安全丢弃结果"的工具预留的(是否有内部构建里的工具用到,公开包看不出来)。

这种 "允许工具自定义中断语义" 的设计比 "统一 cancel / 统一 block" 更合理——因为不同工具的重启代价不同。

别和 Esc / Ctrl-C 混为一谈——interruptBehavior 只管"工具运行中用户又提交了一条消息"这一种情况。用户主动按 Esc 或 Ctrl-C 取消,走的是另一条路:直接触发 abort,把当前轮次停下来(见第 5 章);连按两次 Ctrl-C 则是退出程序(界面提示 "Press Ctrl-C again to exit")。

这个字段的存在提醒我们——Agent 系统的中断语义比想象复杂——写一个 Agent 要想好"正在执行的工作被打断时该怎样"。

1.24 backfillObservableInput 字段的故事

最后再讲一个 Tool 里的细节——backfillObservableInput 字段:

/**
 * Called on copies of tool_use input before observers see it (SDK stream,
 * transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
 * to add legacy/derived fields. Must be idempotent. The original API-bound
 * input is never mutated (preserves prompt cache). Not re-applied when a
 * hook/permission returns a fresh updatedInput — those own their shape.
 */
backfillObservableInput?(input: Record<string, unknown>): void

这个字段回答的问题——tool 的 input schema 升级了、老代码还传老字段、怎么 observer 看到完整信息?

场景(示意,非源码):假设某工具的输入字段从 path 改名为 file_path,而外部的 hook 脚本还在读老字段。模型按新 schema 调用,写 hook 的人却不一定跟着改。

解法——backfillObservableInput 在 input 传给 observers 前补上 legacy / 派生字段:

// 示意:为 observer 副本补回老字段名
backfillObservableInput(input) {
  if (typeof input.file_path === 'string' && input.path === undefined) {
    input.path = input.file_path
  }
}

observer 们(hook、transcript、SDK 流)看到的 input 既包含新字段也包含老字段——老代码依然能读到期望的字段、新代码也能读新字段。2.1.89 里真实的用法更朴素:Edit、Write 等文件工具用它把 file_path 展开成绝对路径,让 hook 与权限规则看到的路径始终一致(见第 8 章 §8.6.2)。

注意注释里的三个约束:

  1. "Mutate in place"——直接改 input 对象、不返回新 object——省内存
  2. "Must be idempotent"——可能被多次调用、第二次调用不该产生不同结果
  3. "The original API-bound input is never mutated (preserves prompt cache)"——API 发给 Anthropic 的 input 不被 mutate——保持 prompt 的缓存 hash 稳定

第 3 点最妙——Anthropic API 对相同 prompt 有缓存(节省 token 成本)——如果每次请求时 input 里都 backfill 新字段、prompt hash 会变、缓存失效。所以 backfillObservableInput 只对 observer 副本调用、不对 API 副本调用——既兼容观察者、又保缓存。

"一个 input 两份副本、分别服务不同消费者"的架构是生产 AI 产品的常见优化——缓存命中能显著压低重复前缀部分的输入 token 成本。

这个字段的存在说明,在这份代码里,可能影响缓存的路径是被专门考虑过的。下一章进入架构全景图。