源码逐层解剖 · Claude Code v2.1.88

Claude Code 源码架构全解剖

基于官方 npm 包 @anthropic-ai/claude-code@2.1.88 的 source map 还原源码(1884 个 TS/TSX 文件),逐层拆解:你的一条消息从敲下回车,到最终回复出现在终端,中间发生的每一件事。所有结论都标注了 文件:行号,可直接对照 restored-src/src/ 阅读。

restored-src · 1884 源文件 query.ts 1729 行 REPL.tsx 5005 行 claude.ts 125 KB

00全景架构:六层结构

先建立一个整体的心智模型,后面每一章都是对其中一层的放大。

Claude Code 本质上是一个「把 React 渲染到终端的前端」+「一个 while(true) 的 agent 引擎」+「一套工具执行器」。所有花哨的功能(斜杠命令、Skills、MCP、子 agent)最终都收敛为两种东西:注入对话的文本,或模型可调用的工具

① 入口层entrypoints/cli.tsx · main.tsx · setup.ts

进程启动、commander 解析参数、settings 五层合并、迁移、交互/print 模式分叉

② UI 层(Ink fork)ink/ · screens/REPL.tsx · components/

自研 fork 的 Ink:React → 虚拟DOM → Yoga排版 → 帧diff → ANSI 转义序列。输入框、消息流、权限对话框

↓ onSubmit / ↑ yield 消息流
③ 引擎层(核心)QueryEngine.ts · query.ts

QueryEngine = 会话经理(落盘/协议/预算);queryLoop = 真正的 agentic while(true):调模型 → 执行工具 → 回填结果 → 再调模型

↓ tool_use / ↑ tool_result
④ 工具层Tool.ts · tools/ · services/tools/

30+ 个工具,Zod 校验 → 权限门禁 → 执行 → 结果映射回 tool_result block。只读工具并发、写工具串行

↓ HTTP/SSE
⑤ API 层services/api/client.ts · claude.ts · withRetry.ts

官方 SDK(4 条认证路径),SSE 流式消费,prompt caching 断点,重试/降级,成本统计,上下文压缩

横向插入所有层
⑥ 扩展生态commands/ · hooks · skills/ · services/mcp/ · plugins/

斜杠命令/Skill = 注入 markdown;Hooks = 绕过模型的生命周期钩子;MCP = 动态工具;Subagent = 递归调用同一个 query()

三个最重要的文件:如果只看三个文件,看 query.ts(主循环)、services/tools/toolExecution.ts(工具怎么被安全执行)、services/api/claude.ts(请求怎么组装和流式消费)。

01一条消息的一生 ★

这是全文的主线。从你按下一个键,到回复完整出现,共 12 站。每一站在后面的章节里都有对应的放大详解。

1

按键进入 Ink 事件总线

终端 stdin 的原始字节被 fork 版 Ink 读取,parse-keypress.ts 解析成按键对象后 emit 'input' 事件。输入框组件用 useInput 订阅并更新光标/文本状态。

ink/ink.tsx → ink/hooks/use-input.ts:84 → components/BaseTextInput.tsx:88 → hooks/useTextInput.ts

2

回车触发 onSubmit 链

useTextInputhandleEnter(排除 \ 续行和 Shift+Enter)调用 onSubmit(originalValue);PromptInput 先检查补全菜单是否打开(打开则先接受补全),然后把输入交给 REPL 的 onSubmit。

hooks/useTextInput.ts:247,266 → PromptInput.tsx:984,1100 → screens/REPL.tsx:3142

3

输入分流:斜杠命令 / bash 模式 / 普通文本

processUserInput 判断:以 / 开头走斜杠命令分发(prompt 类命令展开成隐藏 user 消息、local 类直接调本地函数);! 前缀走 bash 直执行;普通文本包成 user 消息。UserPromptSubmit hook 在此拦截(exit 2 可阻止提交)。

utils/processUserInput/processUserInput.ts · processSlashCommand.tsx:550

4

进入引擎:QueryEngine.submitMessage → query()

REPL 的 onQuery 调用引擎。QueryEngine 并行取三样上下文:默认 system prompt、userContext(CLAUDE.md + 日期)、systemContext(git 状态),然后 for await 消费 query() 生成器。三层都是 async generator,全程流式无阻塞点。

REPL.tsx:2855→2793 · QueryEngine.ts:209,288,675 · utils/queryContext.ts:44

5

循环体预处理:裁剪 + 压缩检查

queryLoop 每轮开头做四件事:切掉上次 compact 边界前的历史 → 工具结果预算裁剪 → microcompact 清理旧工具输出 → auto-compact 检查(token 数 ≥ 上下文窗口−13000 时让模型生成摘要替换全部历史)。

query.ts:365,379,414,454 · services/compact/autoCompact.ts:160

6

组装 API 请求

paramsFromContext 组装:model、system(数组分块,静态段打全局缓存)、messages(CLAUDE.md 拼在最前 + 最后一条消息打 cache 断点)、tools(Zod → JSON Schema)、thinking、betas。

services/api/claude.ts:1538-1729 · addCacheBreakpoints :3063

7

SSE 流式消费

官方 SDK 发起 messages.create({stream:true}),逐事件消费:text_delta 累加文本、input_json_delta 拼工具参数字符串、content_block_stop 时把完整块包成 AssistantMessage 边流边 yieldmessage_delta 拿最终 usage 并算成本。

claude.ts:1822(发起)· :1940-2299(事件循环)· :2251(算成本)

8

UI 实时渲染流式增量

每个事件同时冒泡回 REPL 的 onQueryEvent:流式文本用独立的 <StreamingMarkdown> 渲染(避免每个 token 重排整个列表),工具调用显示为转圈卡片。Ink 以 ~60fps 节流做前后帧 diff,只重画变化的终端单元格。

REPL.tsx:2584 · Messages.tsx:703 · ink/log-update.ts:308

9

检测到 tool_use → 权限门禁

响应里有 tool_use 块则 needsFollowUp = true。执行前每个工具过三道关:Zod 校验 → PreToolUse hook(exit 2 阻断)→ 权限检查(allow 规则/权限模式/弹窗问用户)。弹窗通过一个 Promise + 队列从后台循环冒泡到 UI,用户的选择 resolve 这个 Promise。

query.ts:829-834 · toolExecution.ts:615,921 · hooks/useCanUseTool.tsx:28

10

执行工具:只读并发、写串行

连续的「并发安全」工具(Read/Grep/Glob…)合并成一批,最多 10 个并发;Bash/Edit 等逐个串行。每个结果经 mapToolResultToToolResultBlockParam 转成 tool_result block(文本或图片数组),错误统一包成 <tool_use_error> 让模型自己修正重试。

services/tools/toolOrchestration.ts:19,91 · toolExecution.ts:1292

11

结果回填,回到第 5 步

tool_result 作为 user 消息追加进 messages,state = next; continue 回到循环顶部再调一次 API。这就是「agentic loop」:模型每轮看到自己上一轮工具的产出,直到某轮不再要求工具。

query.ts:1395-1400,1715-1727

12

收尾:Stop hooks → 落盘 → 成本汇总

没有 tool call 的那一轮是终点:跑 Stop hooks(exit 2 可以「不让它停」逼模型继续干活)→ return {reason:'completed'}。QueryEngine 把全程消息写 transcript、累计 usage/成本,UI 收起 spinner,完整回复停留在屏幕上。

query.ts:1267,1357 · QueryEngine.ts:728,812,1135

02启动流程:进程启动 → REPL 出现

claude 命令敲下到界面出现,分为轻量引导、重量主流程、初始化钩子、渲染挂载四个阶段。

2.1 两级入口设计

真正的进程入口是 entrypoints/cli.tsx:302void main()。这个文件是一个刻意「轻量」的引导层——所有 import 都用动态 import(),这样 claude --version 之类的快速路径几乎零模块加载就能返回。它按顺序检查一堆特殊 flag(--version、MCP server 模式、daemon、后台会话管理、remote 桥接……),都不命中才加载 800KB 的 main.tsx

entrypoints/cli.tsx:288-297
// No special flags detected — 进入重量级主流程 const { startCapturingEarlyInput } = await import('../utils/earlyInput.js'); startCapturingEarlyInput(); // 缓存用户在加载期间提前敲的键 const { main: cliMain } = await import('../main.js'); await cliMain();

2.2 main.tsx:commander 解析与模式判定

参数解析用 @commander-js/extra-typings(commander 的类型增强版),在 main.tsx:884run() 里链式定义所有 flag,主命令处理器在 main.tsx:1006。此前 main.tsx:800-803 先判定交互性:

main.tsx:800-803
const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print'); const isNonInteractive = hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;

commander 的 preAction hook(main.tsx:907)在任何 action 之前执行:init()(配置系统、CA 证书、OAuth 账户信息、代理、预连接 API 的 TCP+TLS)→ runMigrations()(版本号 11,比对 migrationVersion 跑一批配置迁移)→ 加载企业远端托管设置。

2.3 setup():会话初始化

主命令 action 里调 setup.ts:56,关键步骤按顺序:Node ≥18 检查 → setCwd()(一切依赖 cwd 的代码之前必须先设,setup.ts:161)→ hooks 配置快照 → worktree 创建(--worktree 可能 chdir)→ session memory 初始化 → 预取斜杠命令 → logEvent('tengu_started') 遥测信标 → bypassPermissions 安全校验(禁止 root、要求容器隔离,setup.ts:396-442)。

2.4 交互模式:Ink 挂载

a

createRoot

main.tsx:2229ink.ts:25,创建 Ink 根,外面包一层 ThemeProvider。

b

showSetupScreens:阻塞式对话框序列

interactiveHelpers.tsx:104:Onboarding(首次运行)→ TrustDialog 信任对话框(信任之后才 applyConfigEnvironmentVariables() 应用「完整」环境变量——不信任的目录不会执行你的 settings 里的危险配置)→ .mcp.json 审批 → API key 审批。

c

launchRepl:真正的 render

replLauncher.tsx:13-21root.render(<App><REPL/></App>) —— 这一句就是「界面出现」的时刻。随后 startDeferredPrefetches() 在用户已看到界面后预热 CLAUDE.md 缓存等。

print 模式(-p)的区别:分叉在 main.tsx:2585。不创建 Ink root(否则 patchConsole 会吞掉输出)、跳过信任对话框(视为可信直接应用全部环境变量)、MCP 在启动路径同步连接(交互模式则交给 REPL 内的 hook 增量连接)、执行引擎换成 cli/print.jsrunHeadlessmain.tsx:2827),按 --output-format 输出后直接退出。

2.5 settings 五层体系

定义在 utils/settings/constants.ts:7SETTING_SOURCES,顺序即优先级(后者覆盖前者):

文件说明
userSettings~/.claude/settings.json用户全局
projectSettings.claude/settings.json项目共享(入库)
localSettings.claude/settings.local.json项目本地(gitignore)
flagSettings--settings 指定路径命令行注入
policySettingsmanaged-settings.json / MDM / 远端企业托管,覆盖一切

合并在 utils/settings/settings.ts:645loadSettingsFromDisk():以插件 settings 为最低优先级基底,按序用 lodash mergeWith 深合并;policy 层内部「first source wins」(远端 > MDM > managed-settings.json)。

2.6 CLAUDE.md 什么时候被读

不是启动时,而是第一次组装请求时惰性读取(memoize 缓存整个会话)。utils/claudemd.ts:790getMemoryFiles 按 Managed → User(~/.claude/CLAUDE.md + rules/*.md)→ Project(从 cwd 向上遍历到根,每层试 CLAUDE.md 和 .claude/CLAUDE.md)的顺序扫描;context.ts:155getUserContext 把它拼进 userContext.claudeMd。注意:CLAUDE.md 不进 system prompt,而是拼在消息数组最前面(第 3 章详述)。

03Agentic 主循环:query.ts

整个产品的心脏。1729 行的 query.ts 里是一个 while(true),每次迭代 = 一次「调模型 + 执行工具」的回合。

3.1 三层调用栈

调用关系
用户输入 └─ QueryEngine.submitMessage() [QueryEngine.ts:209] 会话经理:输入处理、落盘、SDK 协议、maxTurns/预算 └─ query() [query.ts:219] 薄壳生成器 └─ queryLoop() [query.ts:241] 真正的 while(true) 主循环

三者都是 async generator,用 yield 把消息一块块流式吐出去。分工是:QueryEngine 管「会话生命周期」(transcript 落盘、usage 累计、把内部消息转成 SDK 协议、maxTurns/maxBudgetUsd 检查),queryLoop 管「干活」。前者可以换外壳(REPL / headless / SDK),后者不变。

签名值得细看——输入输出类型就是整个引擎的契约:

query.ts:219-228
export async function* query(params: QueryParams): AsyncGenerator< StreamEvent | RequestStartEvent | Message | TombstoneMessage | ToolUseSummaryMessage, // yield 的类型 Terminal // return 的终止原因:completed / aborted / max_turns / blocking_limit … >

循环不用递归而用 while(true) + 可变 State 对象(query.ts:204-217,携带 messages、turnCount、autoCompactTracking 等)。「再来一轮」就是给 state 赋新值然后 continuequery.ts:1715-1727),避免真递归的栈增长。

3.2 一轮迭代的完整步骤

阶段做什么位置
A 预处理切掉 compact 边界前历史 → 工具结果预算裁剪 → microcompact 清理旧工具输出query.ts:365,379,414
B 拼提示把动态 systemContext(git 状态)追加到 system prompt 尾部query.ts:449
C 压缩检查autoCompactIfNeeded:快满就让模型生成摘要替换历史(第 7 章)query.ts:454
D 硬上限压缩关闭时检查 blocking limit,超了直接 returnquery.ts:637-647
E 调模型callModel(= queryModelWithStreaming),CLAUDE.md 经 prependUserContext 拼在消息最前query.ts:659-664
F 流式解析for await 逐消息:assistant 消息收集,过滤 tool_use 块,置 needsFollowUpquery.ts:708,829-834
G 终点判断无 tool_use → stop hooks → return {reason:'completed'}query.ts:1062,1267,1357
H 执行工具runTools(或流式执行器边流边跑),yield 每个结果query.ts:1380-1400
I 下一轮messages = [...旧, ...assistant, ...toolResults],continuequery.ts:1715-1727

needsFollowUp(有没有工具调用)是唯一的「是否再来一轮」判据。这就是 agent「自主多步工作」的全部秘密——没有任何计划器,只是模型不停要工具、循环不停喂结果。

3.3 System Prompt 是怎么拼出来的

入口在 utils/queryContext.ts:44fetchSystemPromptParts,并行取三样:

system prompt

getSystemPrompt

constants/prompts.ts:444。返回字符串数组(按段做缓存):身份与安全声明、系统规则、干活准则、工具使用指南、语气风格……静态段与动态段之间有 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔标记,静态部分可跨会话全局缓存。动态段含 memory、环境信息(cwd/git/平台/模型)、输出风格、MCP 说明。

user context

getUserContext

context.ts:155。CLAUDE.md 内容 + 当前日期。注意它不进 system prompt,而是经 prependUserContext 拼在消息数组最前面(query.ts:660)。

system context

getSystemContext

context.ts:116。git 状态:当前分支、主分支、status --short、最近 5 条提交。每轮动态追加到 system prompt 尾部。

优先级链在 utils/systemPrompt.ts:41:override > coordinator > agent > custom > default,最后还能追加 appendSystemPromptQueryEngine.ts:321-325)。

3.4 中断(ESC)与 abort signal

信号源是 toolUseContext.abortController。ESC → UI 的 useCancelRequesthooks/useCancelRequest.ts:87)→ abortController.abort('user-cancel')。这个 signal 被传给 API 调用(query.ts:664,SDK 抛 APIUserAbortError)和所有工具。循环在三个检查点响应:流式中(不再接收新工具)、API 流结束后(query.ts:1015)、工具执行后(query.ts:1485)。

细节:合成 tool_result。API 要求每个 tool_use 必须有配对的 tool_result,否则下次请求会 400。所以中断时会用 yieldMissingToolResultBlocksquery.ts:123)给所有未执行的工具补上 is_error: true 的合成结果,保证消息序列合法。

04工具系统

一个工具 = 执行逻辑 + 权限判断 + 给模型的说明书 + 终端 UI 渲染,全部塞在一个对象里。

4.1 Tool 接口(Tool.ts:362-695)

分组关键成员作用
身份name / aliases / searchHintAPI 匹配名、改名兼容、延迟加载时供 ToolSearch 检索的关键词
输入inputSchema(Zod v4)模型输入的唯一真值来源,转成 JSON Schema 发给 API;shouldDefer 标记默认延迟加载
执行call(args, context, canUseTool, …)真正干活,返回 ToolResult<Output>prompt() 返回进系统提示的「使用手册」
行为标志isConcurrencySafe / isReadOnly / isDestructive决定并发调度、acceptEdits 自动放行、UI 折叠。默认值全部 fail-closed:不确定就当「不安全、会写」
安全validateInput / checkPermissions / getPath业务校验(失败反馈给模型)→ 权限判定(allow/ask/deny)
结果mapToolResultToToolResultBlockParam / maxResultSizeChars结构化结果 → API tool_result block;超限落盘只给预览
UIrenderToolUseMessage / renderToolResultMessage终端里的「正在调用」卡片和结果展示

4.2 注册表:谁被启用(tools.ts)

三层条件:① 编译期——feature() 是打包期常量,false 的 require() 整块被摇树删除(内部构建才有 REPLTool 等);② 运行期全量池 getAllBaseTools()tools.ts:193-251),按环境变量/特性开关条件展开;③ 按权限过滤 getTools()tools.ts:271)——被整条 deny 的工具在模型看到之前就剔除。合并 MCP 工具时内置工具保持连续前缀且分别排序——因为服务端在最后一个内置工具后放缓存断点,乱序会击穿 prompt cache(tools.ts:345-389)。

4.3 校验失败怎么让模型重试

两阶段校验都不抛异常,而是把错误包成 is_error: true 的 tool_result 回给模型,模型下一轮看到就能修正参数:

services/tools/toolExecution.ts:615(阶段1:Zod)
const parsedInput = tool.inputSchema.safeParse(input) if (!parsedInput.success) { return [{ message: createUserMessage({ content: [{ type: 'tool_result', is_error: true, tool_use_id: toolUseID, content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>` }], })}] } // 阶段2:tool.validateInput()(如 Edit 的"必须先 Read")失败同样包成 tool_use_error

源码注释直言:「模型其实不太擅长生成合法输入」,所以这层反馈闭环很关键。

4.4 四个代表性工具

BashTool —— 不是持久 shell,每次 spawn 新进程
  • 执行callBashTool.tsx:624)→ exec()utils/Shell.ts:181)每次 spawn 新进程。cwd 的「持久性」是技巧模拟的:命令末尾偷偷 pwd -P >| $tmpfile,结束后读回并 setCwd()——所以 cd 能记住,但环境变量和 shell 函数不持久。
  • 沙箱shouldUseSandbox 判定后走 SandboxManager(macOS Seatbelt / Linux bwrap)。注释强调排除列表只是便利功能,真正的安全边界是权限提示系统
  • 超时与后台:超时不杀而是「原地转后台」(startBackgrounding);run_in_background 立即返回 taskId,stdout 落盘供 Read 读取;sleep N 被 validateInput 直接拦截。
  • 截断:保尾截断累加器;maxResultSizeChars: 30000,超大输出落盘,模型收 <persisted-output> 预览+路径。
FileEditTool —— 「必须先 Read」的三重校验

validateInputFileEditTool.ts:137-362)按序检查(节选核心三条):

  1. 必须先 ReadreadFileState.get(path) 没有读取记录(或只读了局部)就拒绝——这是「Edit 前必须 Read」规则的实现。
  2. 时间戳追踪:磁盘 mtime > 读取时间 → 「文件已被修改,请重读」——防止覆盖用户/linter 的并发改动。
  3. 唯一性file.split(old_string).length - 1 > 1 且未开 replace_all → 拒绝并要求更多上下文。

call:387-574)里有一段注释反复强调的「原子临界区」:重新读文件、再校验一次时间戳、生成 patch、写盘(保留原编码和行尾)之间禁止任何 async 操作。写完把新内容+新 mtime 写回 readFileState,让后续的过期写作废。

FileReadTool —— 按文件类型分派的多面手
  • 文本:读取后加行号前缀(cat -n 格式),token 超限抛错让模型用 offset/limit 分段读。
  • 图片:按 token 预算 resize(超预算激进压缩,兜底 sharp 400x400 q20),结果作为 base64 image block。
  • PDFpages 参数抽指定页转图片;整篇则作为 DocumentBlock 经 newMessages 附加发送。
  • 去重优化:同一 range 读过且 mtime 没变 → 返回 file_unchanged 占位(数据显示约 18% 的 Read 是重读,白白浪费缓存 token)。
  • 安全:拒绝 /dev/zero 等会挂起的设备文件;maxResultSizeChars: Infinity(防止「结果落盘→Read→又落盘」死循环)。
AgentTool —— 子 agent 就是递归调用同一个 query()

tools/AgentTool/runAgent.ts:248 是一个生成器,核心就是再跑一遍主循环:

runAgent.ts:748
for await (const message of query({ messages: initialMessages, systemPrompt: agentSystemPrompt, // agent 定义自己的 prompt + 环境信息 toolUseContext: agentToolUseContext, // 隔离的上下文(异步 agent 有独立 abortController) maxTurns, ... })) { yield message }

隔离设计:子 agent 有独立 agentId、独立权限模式(但父级 bypass/acceptEdits 不被覆盖)、allowedTools 完全替换 allow 规则(父级的批准不泄漏给子 agent)、只读 agent 砍掉 CLAUDE.md 和 git 状态省 token。结束时统一清理:MCP 连接、readFileState、该 agent 起的后台 bash 任务。子会话完整记录到 sidechain transcript。

05权限系统

「模型想做 X,允许吗?」的完整判定链,插在每个工具真正执行之前。

5.1 四种权限模式

default

正常弹窗问用户

acceptEdits

文件编辑类自动放行(快路径)

plan

非只读工具被强制拒绝(toolExecution.ts:523)

bypassPermissions

全部放行,受启动时安全校验约束(禁 root、要求容器隔离)

5.2 规则匹配:Bash(npm:*) 是怎么生效的

规则解析成三型(tools/BashTool/bashPermissions.ts):prefixnpm:* → 命中 npm install 但不命中 npms)、exact(完整命令相等)、wildcard(通配)。两个防绕过设计:

  • 复合命令拆分ls && rm -rf 被拆成子命令逐个检查,不能靠第一个安全命令蒙混。
  • 环境变量剥离白名单FOO=bar git push 剥离前缀再匹配,但 LD_PRELOADNODE_OPTIONS 等能执行代码的变量绝不剥离

5.3 弹窗怎么从后台循环冒泡到 UI

这是引擎和 UI 之间最精巧的握手——一个 Promise + 队列

1

canUseToolhooks/useCanUseTool.tsx:28)返回一个 new Promise。已有规则允许 → 直接 resolve,根本不弹窗。

2

需要问 → interactiveHandler 把待确认项 push 进 React state 队列interactiveHandler.ts:92)。setState 触发渲染,focusedInputDialog 变成 'tool-permission',对话框出现(REPL.tsx:4519)。同时并行「赛跑」着 claude.ai 远程审批、Telegram/iMessage 通道、bash 自动分类器——谁先响应谁生效。

3

用户选「允许」→ onAllowresolve 最初那个 PromiseinteractiveHandler.ts:154),主循环里 await 的 canUseTool 返回 allow,工具继续执行。选「记住」会把规则持久化进 settings。

06API 层:请求组装、流式、缓存、重试

services/api/ 三个文件各司其职:client.ts 管认证,claude.ts 管请求与流,withRetry.ts 管失败。

6.1 四条认证路径

services/api/client.ts:88 getAnthropicClient()
if CLAUDE_CODE_USE_BEDROCK → AnthropicBedrock // AWS(Bearer token 或 AK/SK) if CLAUDE_CODE_USE_FOUNDRY → AnthropicFoundry // Azure if CLAUDE_CODE_USE_VERTEX → AnthropicVertex // GCP elseAnthropic // 第一方 // 第一方内部再二选一: apiKey: isClaudeAISubscriber() ? null : getAnthropicApiKey(), // API key(env > keychain > helper 脚本) authToken: isClaudeAISubscriber() ? oauthTokens.accessToken : undefined // 订阅走 OAuth,请求前自动刷新

全部用官方 SDK,不自封 fetch(唯一的 wrapper 只注入 request-id 和 debug 日志)。OAuth 登录走 PKCE + 本地 localhost 回调监听(services/oauth/index.ts:32)。

6.2 SSE 流式消费

发起在 claude.ts:1822messages.create({stream:true}).withResponse()——拿原始 Response 是为了读 request_id 和在退出时手动 cancel socket 防泄漏。刻意不用 SDK 的 BetaMessageStream,因为它对每个 input_json_delta 跑 partialParse 是 O(n²),CLI 自己拼字符串。事件循环(claude.ts:1940-2299):

SSE 事件处理
message_start记 TTFT(首 token 延迟),拿初始 usage
content_block_start按类型初始化累加器(tool_use 的 input 初始化为空字符串)
content_block_deltatext_delta 累加文本;input_json_delta 拼 JSON 字符串;thinking_delta 累加思考
content_block_stop完整块规整后包成 AssistantMessage 立即 yield(边流边产出)
message_delta最终 usage + stop_reason;在这里算成本(calculateUSDCost → addToTotalSessionCost);处理 max_tokens / 超窗截断

另有两套看门狗:90 秒无 chunk 主动 abort(idle watchdog),相邻事件间隔 >30s 记 stall 日志。流式彻底失败时降级到非流式请求(max_tokens 上限降到 64000)。

6.3 Prompt Caching:断点打在哪

API 限制每请求最多 4 个 cache_control 断点(源码注释警告「别再加,否则 400」)。策略:

  1. System prompt 分块utils/api.ts:321):按 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 切开——边界前的静态段打 global scope 缓存(跨会话、跨用户共享!),动态段不缓存。有 MCP 工具时退回 org 级(MCP 工具 per-user,没法全局共享)。
  2. 消息断点恰好一个claude.ts:3063):打在最后一条消息的最后一个 content block 上。每轮请求的「新增部分」就成了下轮的缓存前缀。
  3. tool_result 的 cache_reference:配合 cache-editing beta 做「增量删除已缓存的旧工具结果」(见第 7 章)。

beta 头有「sticky latch」机制:某个 beta 一旦发出就整个 session 持续发——中途 toggle 会改变服务端 cache key,炸掉几万 token 的缓存。

6.4 重试与降级(withRetry.ts)

  • 退避:有 retry-after 头就听它的;否则 min(500 × 2^(attempt-1), 32s) + 0~25% 抖动,最多重试 10 次(withRetry.ts:530,52)。
  • 429:API key 用户重试;订阅用户不重试(限流几个小时,重试没意义)。
  • 529 过载:只有前台请求源重试(后台如标题生成直接放弃,避免容量雪崩时重试放大);连续 3 次触发模型 fallback(抛 FallbackTriggeredError 切换到 fallbackModel)。
  • 401:清 API key 缓存 / 强制刷新 OAuth token 后重试。
  • 400 上下文超限:从错误消息里解析出上限,动态下调 max_tokens 重试。

6.5 成本统计

usage 从 message_start + message_delta 合并(>0 才覆盖,防止 0 值冲掉真值)。calculateUSDCostutils/modelCost.ts:177)= 输入 + 输出 + cache 读 + cache 写 + web 搜索次数,按模型查价目表;addToTotalSessionCostcost-tracker.ts:278)按模型累计并喂 OpenTelemetry,会话结束存入 project config,resume 时恢复。token 估算兜底:文本按 length / 4,图片固定按 2000 token 计。

07上下文压缩:三级机制

上下文窗口是有限的,Claude Code 用「全量摘要 + 微清理 + API 侧管理」三级手段续命。

7.1 Auto-compact:全量摘要压缩

1

触发

每轮循环开头检查(autoCompact.ts:160):当前 token 数 ≥ 阈值。阈值 = 模型上下文窗口 − 摘要预留 20000 − 缓冲 13000。连续失败 3 次熔断(防止超长上下文每轮空转烧 API)。

2

让模型自己写摘要

把「旧历史 + 摘要请求」发给模型(compact.ts:387,1136,可复用主对话的缓存前缀)。压缩 prompt(services/compact/prompt.ts:61)要求输出 <analysis> 草稿 + <summary>,含 9 个固定小节:主要意图、技术概念、文件与代码段、错误与修复、用户消息全记录、待办、当前工作、下一步(要求带原文引用防漂移)。若压缩请求本身超长,砍掉最老的消息组重试(最多 3 次)。

3

重建消息列表——不是纯摘要

buildPostCompactMessagescompact.ts:330)拼出新列表 = 边界标记 + 摘要消息 + 重注入压缩前读过的文件(最多 5 个 / 50k token 预算)+ plan 附件 + skill 附件。之后每轮请求只看到「摘要 + 近期」,旧历史被 compact 边界永久切断。

还有被动压缩:API 真的返回 prompt-too-long 时,query.ts:1119 做一次抢救性压缩再重试。

7.2 Microcompact:微清理

如果距上条消息间隔太久(服务端缓存已过期、前缀反正要重写),趁机把旧的工具输出替换成 '[Old tool result content cleared]'——只清 Read/Bash/Grep 等可再生工具的结果(microCompact.ts:253,41)。另有 cache-editing 变体:不改本地消息,而是发 cache_edits 指令让服务端从 KV 缓存里增量删除旧工具结果。

7.3 API 侧原生 context management

请求体带 context_management.editsapiMicrocompact.ts:64):clear_thinking 策略对所有用户生效(空闲超 1 小时只保留最后一轮 thinking);clear_tool_uses(内部实验)在输入超 18 万 token 时让服务端清到 4 万。另外每请求最多 100 个媒体项,超了静默丢最老的图片。

08终端 UI:fork 版 Ink

不是 npm 上的 ink 包,而是 Anthropic 深度改造的 fork(src/ink/),React 是真 React,「浏览器」被换成了终端渲染引擎。

8.1 渲染原理:React → ANSI

渲染管线
React 组件 → react-reconciler(自定义 HostConfig) ink/reconciler.ts:331 createInstance → 自建虚拟 DOM (ink-box / ink-text) ink/dom.ts:20 → Yoga 排版引擎(flexbox 算坐标) ink/render-node-to-output.ts:436 → 单元格屏幕缓冲 Screen(字符+颜色+样式) ink/screen.ts → 前后帧逐格 diff → 最小 ANSI 补丁 ink/log-update.ts:308 diffEach → write(stdout) ~60fps 节流(ink.tsx:213)

关键在最后两步:不是每帧重画整屏,而是像 React 的 DOM diff 一样只更新变化的终端单元格——这就是流式输出不闪屏的原因。fork 版还加了 upstream Ink 没有的:alt-screen 全屏、鼠标追踪、文本选择、OSC8 超链接、双缓冲。

8.2 组件树

从 root 到输入框
<App> components/App.tsx —— 只挂 3 个 Context Provider └ <REPL> screens/REPL.tsx:572 —— 5005 行的主屏幕 └ <FullscreenLayout> 布局骨架,分槽位 ├ scrollable: <Messages>(虚拟滚动) + <SpinnerWithVerb> + <PromptInput> ├ overlay: 权限确认层(toolPermissionOverlay) ├ modal: 居中模态(/model /status …) └ bottom: 粘性底栏

状态分三层:全局 AppState store(useSyncExternalStore 风格,存权限上下文/MCP/后台任务);消息列表在 REPL 组件的本地 useState(流式临时态 streamingToolUses、abortController 也在这);外加十来个小 Context(通知、模态、排队消息)。

8.3 关键渲染细节

  • 流式文本:单独用 <StreamingMarkdown> 渲染,与已完成消息分开,避免每个 token 重排整个列表(Messages.tsx:703)。
  • 工具结果折叠<MessageResponse> 缩进符,用 overflowY="hidden" 限高实现折叠,Ctrl+O 展开。
  • bash 模式:在输入框开头敲 ! 触发 getModeFromInput 切模式(PromptInput.tsx:854);plan mode 不是输入前缀而是权限模式,Shift+Tab 循环切换(PromptInput.tsx:1518)。
  • 斜杠补全useTypeahead hook + 命令/文件/频道多种 trigger;菜单打开时回车先接受补全而不是发消息。
  • ESC 中断:语义键 chat:canceluseCancelRequest.ts:87,164)→ 清空权限队列 → abortController.abort('user-cancel')。没有任务在跑时 ESC 改为弹出排队命令;双击 Ctrl+C 才退出程序。

09扩展生态:七种机制,两种本质

所有扩展最终收敛为两类:「注入对话的文本」或「模型可调用的工具」。Hooks 是唯一的例外——它绕过模型,由 harness 直接执行。

9.1 一张总表

机制本质核心代码
斜杠命令把 markdown 展开成隐藏 user 消息(少数是本地函数/UI)commands.ts:258,449 · processSlashCommand.tsx:550
SkillsSKILL.md 注入上下文(inline)或 fork 子 agent 执行skills/loadSkillsDir.ts · SkillTool.ts:580
Hooks生命周期钩子,绕过模型直接执行 shell/LLM/HTTP/agentutils/hooks.ts(5000+ 行)
MCP连外部 server,动态把远程工具包成 mcp__server__toolservices/mcp/client.ts:595,1745
Subagent递归调用同一个 query(),换 prompt + 受限工具tools/AgentTool/runAgent.ts:748
插件一个目录打包 commands/agents/skills/hooks/MCPutils/plugins/pluginLoader.ts:1348
Agent SDK外部进程经 stdin/stdout 控制协议驱动同一个引擎entrypoints/sdk/controlSchemas.ts

所有磁盘自定义内容走同一个加载器 utils/markdownConfigLoader.ts:297:合并 managed > user(~/.claude)> project(.claude)三来源,能放扩展的五个目录是 commands / agents / output-styles / skills / workflows

9.2 Hooks:exit code 的语义

支持 27 种事件(PreToolUse / PostToolUse / Stop / UserPromptSubmit / SessionStart / PreCompact……),hook 本体有四种后端:shell 命令、LLM prompt 评估、HTTP POST、子 agent 验证。hook 输入 JSON 走 stdin,影响主流程的方式:

输出效果
exit 0成功,stdout 注入上下文
exit 2阻断:PreToolUse 上=阻止工具执行(stderr 反馈给模型);Stop 上=不让 agent 停;UserPromptSubmit 上=拦截用户输入(hooks.ts:2617-2696)
其他非零非阻断错误,仅展示给用户
JSON 输出更精细:直接决定权限(permissionDecision)、改写工具入参(updatedInput)、注入 additionalContext、改写工具输出

9.3 Skills 的两种执行方式

SkillTool 的描述里不含 skill 全文,只有「名字+简述」清单(预算约占上下文 1%)。调用时(SkillTool.ts:622)分两路:inline(默认)——把 SKILL.md 渲染后作为 meta user 消息直接注入当前对话,「加载技能」本质上就是「贴一段说明书」;fork——起独立上下文的子 agent 跑,只回传最终结果。frontmatter 支持参数替换($1/$ARGUMENTS)、${CLAUDE_SKILL_DIR}、内联 shell 执行(MCP 来源的 skill 出于安全禁止)。

9.4 MCP 工具的动态包装

客户端用官方 @modelcontextprotocol/sdk,transport 覆盖 stdio(子进程)/ SSE / Streamable HTTP / WebSocket / in-process。连上后 tools/list 拉工具列表,对每个工具动态生成一个 Tool 对象(client.ts:1767-1988):名字 = mcp__server__tool,inputSchema 直接用 server 给的 JSON Schema,isReadOnly 等读 server 的 annotations。MCP 的 prompts 则被包装成 /server:prompt 斜杠命令。OAuth 用 step-up 模式:先看到 403 再触发认证刷新。

10文件索引速查

按「我想看 X」检索,路径相对 restored-src/src/。

我想看…去这里
进程入口 / flag 分流entrypoints/cli.tsx:33,302 · main.tsx:585,884,1006
启动初始化 / 迁移entrypoints/init.ts:57 · main.tsx:326 · setup.ts:56
主循环query.ts:241(queryLoop)· QueryEngine.ts:209
System prompt 各段constants/prompts.ts:444 · utils/queryContext.ts:44 · utils/systemPrompt.ts:41
CLAUDE.md 读取/注入utils/claudemd.ts:790 · context.ts:155
Tool 接口 / 注册表Tool.ts:362,757 · tools.ts:193,271
工具执行 / 校验 / 权限插入点services/tools/toolExecution.ts:599,615,921
串行/并发分批services/tools/toolOrchestration.ts:19,91
Bash / Edit / Read / Agenttools/BashTool · FileEditTool.ts:137,387 · FileReadTool.ts:804 · AgentTool/runAgent.ts:248
权限规则匹配utils/permissions/permissions.ts · tools/BashTool/bashPermissions.ts
权限弹窗握手hooks/useCanUseTool.tsx:28 · hooks/toolPermission/handlers/interactiveHandler.ts:92
API 客户端 / 认证services/api/client.ts:88
请求组装 / SSE / 缓存断点services/api/claude.ts:1538,1940,3063 · utils/api.ts:321
重试 / 退避 / fallbackservices/api/withRetry.ts:170,530,696
成本统计utils/modelCost.ts:177 · cost-tracker.ts:278
压缩三级机制services/compact/autoCompact.ts:160 · compact.ts:387 · microCompact.ts:253 · prompt.ts:61
Ink 渲染管线ink/reconciler.ts:331 · ink/log-update.ts:308 · ink/ink.tsx:420
REPL / 输入链 / 消息渲染screens/REPL.tsx:572,3142,2793 · PromptInput.tsx:984 · Messages.tsx:700 · Message.tsx:406
ESC 中断hooks/useCancelRequest.ts:87
斜杠命令 / Hooks / Skillscommands.ts:449 · processSlashCommand.tsx:550 · utils/hooks.ts:2617 · skills/loadSkillsDir.ts
MCP / 插件 / SDKservices/mcp/client.ts:595 · utils/plugins/pluginLoader.ts:1348 · entrypoints/sdk/