Claude Code 源码解剖

Claude Code 的整体架构

从 main.tsx、feature()、REPL.tsx、AppState 和工具注册入口出发,理解 Claude Code 为什么是一套终端里的 Agent Runtime。

第 1 章 14 分钟

Claude Code 的整体架构

这一章先解决一个定位问题:Claude Code 不是“CLI 外壳包一个模型调用”,而是一套运行在终端里的 Agent Runtime

它的关键不在 claude 这个命令本身,而在这几条运行时链路:

  • 入口层提前发起配置和凭证读取,压低首屏延迟。
  • 构建期 feature() 决定哪些能力进入产物,也决定模型能看见哪些工具 schema。
  • REPL.tsx 把终端当 React Ink UI 渲染,而不是到处 console.log()
  • AppState 让 UI、工具执行、权限、MCP、团队上下文读写同一份事实。
  • query.ts 的 Agent Loop 把模型输出、工具执行结果和下一轮上下文接起来。

核心直觉

普通 CLI 的控制权在程序参数和函数调用里。Claude Code 的控制权在“模型可见能力 + 当前状态 + 工具结果回写”之间流动。

先看源码入口

读这套架构,先不要从组件树或工具目录开始。更稳的入口是下面这些文件:

源码定位点关键符号负责什么读它要看什么
restored-src/src/main.tsxprofileCheckpoint()startMdmRawRead()startKeychainPrefetch()CLI 启动编排哪些 I/O 被提前发起,哪些模块被延迟加载
restored-src/src/main.tsxfeature()require()构建期能力门控哪些模块在 flag 关闭时根本不进 bundle
restored-src/src/tools.tsgetAllBaseTools()getTools()assembleToolPool()工具池装配最终发送给模型的工具列表如何产生
restored-src/src/screens/REPL.tsxuseAppState(...)终端 UIUI 如何订阅状态切片并响应任务变化
restored-src/src/state/store.tscreateStore()外部状态容器状态如何更新、通知和触发副作用
restored-src/src/state/AppStateStore.tsAppState全局运行事实权限、MCP、任务、插件、团队上下文存在哪些字段
restored-src/src/query.tsquery()queryLoop()Agent Loop工具结果如何回写并驱动下一轮模型调用

这几个点串起来,才是 Claude Code 的骨架。Tool.ts、提示词、权限、压缩、缓存都挂在这条骨架上。

总流程图

下面这张图不是概念分层,而是从启动到模型下一轮决策的运行路径:

flowchart TB A["main.tsx<br/>profileCheckpoint('main_tsx_entry')"] --> B["并行预取<br/>startMdmRawRead()<br/>startKeychainPrefetch()"] B --> C["feature() + 条件 require<br/>构建期裁剪模块树"] C --> D["getAllBaseTools()/getTools()<br/>装配模型可见工具"] C --> E["REPL.tsx<br/>React Ink 终端界面"] E --> F["AppState store<br/>settings / permissions / mcp / tasks"] F --> G["query() / queryLoop()<br/>推进 Agent Loop"] D --> G G --> H["callModel()<br/>system prompt + user context + tools"] H --> I{"模型是否返回 tool_use?"} I -->|是| J["StreamingToolExecutor / runTools<br/>执行工具"] J --> K["tool_result 写回 messages"] K --> F K --> G I -->|否| L["恢复 / stop hooks / 终止判断"] L --> F

这条路径里最重要的架构判断是:模型不能直接操作世界,它只能提出 tool_use;工具执行结果必须变成 tool_result 回到下一轮上下文。

启动阶段:顶层副作用不是随手写的

main.tsx 的前几行就能看出 Claude Code 对 CLI 启动延迟的处理方式。原文给出的关键片段是:

import { profileCheckpoint, profileReport } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');

import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();

import {
  ensureKeychainPrefetchCompleted,
  startKeychainPrefetch,
} from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();

这段代码证明的不是“入口文件会 import 一些工具函数”,而是三个具体行为:

调用读取什么为什么放在入口顶层
profileCheckpoint(&#39;main_tsx_entry&#39;)启动时间轴在重量级 import 评估之前记录起点,后面才能定位启动慢点
startMdmRawRead()MDM 托管配置,macOS 上可能调用 plutil,Windows 上可能调用 reg query让设备策略读取和后续模块加载并行
startKeychainPrefetch()OAuth token 和旧版 API key避免首次鉴权路径同步读 Keychain,原文提到可少约 65ms

这里还有一个容易漏掉的信号:原文指出源码为这些调用豁免了 custom-rules/no-top-level-side-effects。也就是说,团队不是不知道顶层副作用会让测试和依赖关系更难管理,而是明确选择在入口处承担这部分复杂度。

容易误解

这不是“随便在 import 后面发起 I/O”。它只适合确定会用到、可重试、失败安全、可超时回退的启动依赖。否则顶层副作用会把启动路径变成不可控的隐式依赖网。

启动链路可以拆成:

进入 main.tsx
-> 记录 startup checkpoint
-> 发起 MDM raw read
-> 发起 Keychain prefetch
-> 继续评估后续 import
-> 需要凭证或策略时 ensureXxxCompleted()
-> 预取成功则复用结果,失败则走同步/交互式回退

源码体现出来的取舍很清楚:入口层用可控副作用换首屏响应速度。对终端 Agent 来说,用户输入 claude 后的第一秒非常敏感;把确定会发生的 I/O 藏进模块加载窗口,是比“代码纯净”更优先的产品目标。

能力裁剪:模型能看见什么,比 UI 有什么按钮更重要

Claude Code 的能力边界首先在构建期被裁剪。入口层有这样的条件 require:

const coordinatorModeModule = feature('COORDINATOR_MODE')
  ? require('./coordinator/coordinatorMode.js')
  : null;

const assistantModule = feature('KAIROS')
  ? require('./assistant/index.js')
  : null;

工具层也有同样模式:

const WebBrowserTool = feature('WEB_BROWSER_TOOL')
  ? require('./tools/WebBrowserTool/WebBrowserTool.js').WebBrowserTool
  : null;

这些片段说明两个事实:

  1. feature() 不是普通运行时配置。原文明确把它定位为来自 bun:bundle 的构建期常量,flag 关闭时分支可以被 DCE 移除。
  2. 对 Agent 来说,模块有没有进 bundle 会进一步影响工具列表,最后影响模型能看到的 tool schema。

能力不是在一个地方一次性决定的,而是经过多层过滤:

层级典型源码判断问题影响
构建期feature(&#39;KAIROS&#39;) ? require(...) : null这个模块是否进入产物关闭时模块树可能不存在
内部/环境process.env.USER_TYPE === &#39;ant&#39;当前运行身份是否能使用内部能力代码存在,但普通用户不可见
工具注册getAllBaseTools()基础工具池包含哪些工具形成候选工具列表
权限过滤getTools(permissionContext)filterToolsByDenyRules()当前会话 deny 规则是否拒绝工具被拒工具不发送给模型
MCP 融合assembleToolPool(permissionContext, mcpTools)内置工具和 MCP 工具如何合并名称冲突时内置工具优先

tools.ts 里有一个很关键的装配入口:

export function assembleToolPool(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext);
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext);
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name);
  return uniqBy(
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
    'name',
  );
}

这段代码证明了一个很具体的行为:最终工具池不是“内置工具 + MCP 工具直接拼接”,而是先各自按名称排序,再拼接,再用 uniqBy(..., &#39;name&#39;) 去重。因为内置工具排在前面,所以名称冲突时内置工具胜出。

这个排序也不只是审美。原文指出它服务于 prompt cache 稳定性:内置工具作为稳定前缀,MCP 工具变化时不至于把内置工具顺序全部冲散。

核心直觉

Agent 的“能力边界”不是 UI 层决定的,而是 tool schema 暴露边界决定的。模型看不见某个工具,就不会把它纳入计划;模型看见了但后续被权限拒绝,就会浪费一轮推理和上下文。

终端 UI:REPL.tsx 是状态订阅者,不是事实来源

Claude Code 用 React Ink 渲染终端 UI。这个选择的价值不在“终端变漂亮”,而在它需要同时承载这些变化:

  • 模型流式输出。
  • 多个工具的运行进度。
  • 权限确认和拒绝。
  • 用户中断。
  • MCP 连接和团队上下文变化。
  • 历史消息折叠、隐藏、回填和重绘。

如果用传统 readline + console.log(),这些状态会互相抢输出流。React Ink 的意义是把终端界面变成一棵可重渲染的 UI 树。

REPL.tsx 不应该成为系统事实来源。它通过 useAppState() 订阅切片:

export function useAppState(selector) {
  const store = useAppStore();
  const get = () => selector(store.getState());
  return useSyncExternalStore(store.subscribe, get, get);
}

这段代码证明了 REPL.tsx 和状态层的边界:

AppState 变化
-> store.subscribe 通知
-> useSyncExternalStore 读取 selector(getState())
-> React Ink 重渲染对应组件
-> 用户输入、确认、中断再通过 action 写回状态或执行层

UI 是状态变化的投影,不是唯一状态容器。这个边界非常重要,因为工具执行器、MCP 管理、hook 回调并不都在 React 组件树里,它们也需要读同一份事实。

全局状态:把运行事实放进 AppState

原文给出的 store.ts 片段非常短,但它解释了 Claude Code 为什么能把 React UI 和非 React 执行逻辑接到一起:

export function createStore<T>(
  initialState: T,
  onChange?: OnChange<T>,
): Store<T> {
  let state = initialState;
  const listeners = new Set<Listener>();

  return {
    getState: () => state,
    setState: (updater: (prev: T) => T) => {
      const prev = state;
      const next = updater(prev);
      if (Object.is(next, prev)) return;

      state = next;
      onChange?.({ newState: next, oldState: prev });
      for (const listener of listeners) listener();
    },
    subscribe: (listener: Listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

这段代码证明了四个设计点:

机制源码位置作用
getState()返回闭包里的 state非 React 逻辑可以命令式读取当前事实
setState(updater)prev -&gt; next 更新强制更新逻辑显式依赖旧状态
Object.is(next, prev)引用没变就跳过避免无意义通知,也鼓励不可变更新
onChange?.(...)状态变化边界触发把权限模式同步、凭证缓存清理等副作用集中到状态变化处
subscribe()注册 listenerReact 或其他订阅者能响应变化

AppStateStore.ts 的类型体量也说明它不是普通 UI state。原文提到它有 60+ 个顶层字段,覆盖 settingstoolPermissionContextmcppluginstasksteamContextspeculation 等。

状态区域影响的运行行为
settings模型、权限模式、用户配置、实验开关等运行参数
toolPermissionContext工具是否 allow / ask / deny
mcpMCP 客户端连接和外部工具可用性
plugins插件能力、技能、扩展入口
tasks当前任务和可取消请求
teamContext团队/协作上下文注入
speculation推测执行相关状态

读源码抓手

看到某个模块调用 store.getState(),不要把它当“全局变量偷懒”。先问:它读取的字段会不会影响工具执行、权限、模型上下文或 UI 展示?如果会,它属于运行事实,而不是组件私有状态。

任务回流:Agent Runtime 的闭环

前面几层最后都要接到 query.ts。这里先不展开 Agent Loop 的细节,第二章会专门讲。第一章只需要抓住闭环:

用户输入
-> REPL 收集并写入任务现场
-> queryLoop 构造模型请求
-> 模型返回 text / tool_use
-> 工具系统执行 tool_use
-> tool_result 写回 messages
-> AppState / UI 看到新状态
-> queryLoop 带着新证据进入下一轮

这就是 Claude Code 和普通 CLI 的分界线。普通 CLI 的输出是终点;Claude Code 里的输出经常只是下一轮模型决策的输入。

query() 的薄包装也能看出边界:

export async function* query(params: QueryParams) {
  const consumedCommandUuids: string[] = [];
  const terminal = yield* queryLoop(params, consumedCommandUuids);

  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed');
  }

  return terminal;
}

这段代码证明:query() 负责外层生命周期收尾,真正推进任务状态机的是 queryLoop()。入口、UI、状态、工具都不是彼此平行的“模块介绍”,它们在运行时都汇入这个循环。

设计取舍

取舍点源码体现为什么这么做代价
顶层预取main.tsx 里紧跟 import 的 startMdmRawRead()startKeychainPrefetch()把配置/凭证 I/O 藏进模块加载窗口,降低 CLI 首屏等待顶层副作用让测试和依赖关系更敏感
构建期裁剪feature(&#39;X&#39;) ? require(...) : nullflag 关闭时模块树不进入产物,模型也看不见相关工具构建系统和 bun:bundle 定制能力耦合
工具可见性前置过滤getTools()filterToolsByDenyRules()被 deny 的工具不浪费 schema token,也不诱导模型计划不可执行动作需要保证过滤逻辑和权限系统语义一致
UI 订阅外部 storeuseSyncExternalStore(store.subscribe, get, get)React Ink UI 和非 React 执行逻辑共享同一状态源状态结构会变大,需要清晰字段边界
内置工具稳定前缀sort().concat(...).uniqBy(&#39;name&#39;)降低 MCP 工具变化对 prompt cache 的扰动工具排序策略成为缓存协议的一部分

这里最值得保留的判断是:Claude Code 在模型决策之前,尽量先缩小模型可见世界;在模型决策之后,再把现实反馈完整回流。

读源码抓手

按下面顺序读,不容易被 40+ 工具和 5000+ 行 UI 组件冲散:

  1. 先看 restored-src/src/main.tsx 前 100 行,标出哪些动作在 UI 挂载前发生。
  2. 再找所有 feature() 守卫的 require(),区分构建期能力和运行期配置。
  3. 进入 restored-src/src/tools.ts,只追 getAllBaseTools()getTools()assembleToolPool() 三个函数。
  4. restored-src/src/state/store.tsAppState.tsxuseAppState(),确认 UI 怎么订阅外部状态。
  5. AppStateStore.ts 顶层字段,建立“哪些事实会影响任务推进”的地图。
  6. 最后进入 query.ts,只找 query() 如何把控制权交给 queryLoop()

小结

- Claude Code 的启动入口用并行预取处理 CLI 首屏延迟。 - feature() 和工具注册决定模型能看见什么能力,这比 UI 呈现更关键。 - REPL.tsx 是状态投影,AppState 才是 UI 和执行层共享的事实来源。 - 工具结果必须写回下一轮上下文,Agent 才能基于真实世界继续判断。 - 这套架构的核心不是“调用模型”,而是维护一个可裁剪、可观察、可回流的 Agent Runtime。