本章拆三件事:1902 个文件按什么原则分层、89 个编译期开关怎么把一份源码变成两个产品、运行时在磁盘上留下什么。核心判断是:这个系统的模块边界不是按"功能"划的,是按"什么时候需要它"划的。
先把 src/ 根目录那 18 个文件列出来——根目录放什么,最能暴露一个项目认为什么是核心:
这里没有 agent.ts、没有 session.ts、没有 runtime.ts。最靠近根的三个文件是"循环"、"引擎"、"工具接口"——这就是这个系统认为的三根柱子。
分层长这样:
cli.tsx 只有 302 行——入口很薄是个好信号,说明启动逻辑没有和业务逻辑纠缠。真正的会话状态在 QueryEngine,它对外只暴露六个方法 [源码 src/QueryEngine.ts:184]:
一个 1295 行的类只暴露六个方法,其中一个是异步生成器。这个签名基本上把整个系统的交互模型定死了:
前端不是"调用 agent 然后拿结果",而是"订阅一条消息流"。
Codex 用一份提交/事件队列协议达到同样效果(第 3 章那份 SQ/EQ);Claude Code 用的是语言原生的 async generator。差别在于:Codex 的协议是跨进程可序列化的(所以 IDE 插件、桌面 App 能直接接),Claude Code 的 generator 是进程内的,跨进程那一层由 SDK 和 entrypoints/sdk/ 里的控制协议另行铺设。
可迁移的判断 ② 让"一次 agent 交互"的返回类型是流,不是值。
AsyncGenerator<StreamEvent | Message | …, Terminal> 这个签名同时表达了三件事:中间过程是可观测的、终止有明确原因(Terminal 是个带 reason 字段的联合类型)、消费者可以随时 .return() 提前退出。如果返回的是 Promise<Result>,这三件事都得另外发明机制。
getAllBaseTools() 是工具的唯一真源 [源码 src/tools.ts:191]。它的注释值得单独看:
工具列表必须和一份线上配置保持同步,否则跨用户的系统提示词缓存会失效。 又是缓存——第 1 章那条主线在这里第六次出现。
数组本身长这样:
17 个工具是无条件的,其余全部挂在条件后面。条件分四类:
| 条件类型 | 例子 | 何时求值 |
|---|---|---|
| 构建期 feature() | SleepTool、WorkflowTool、SnipTool | 打包时,分支被物理删除 |
| 构建期 USER_TYPE === 'ant' | ConfigTool、REPLTool | 打包时,外部版没有 |
| 运行时能力探测 | hasEmbeddedSearchTools() | 进程启动,内嵌 bfs/ugrep 时不给 Glob/Grep |
| 运行时远程配置 | isTodoV2Enabled()、isAgentSwarmsEnabled() | GrowthBook A/B |
对照本会话你手里的工具 本教程写作时这个会话拿到的工具里就有 EnterWorktree / ExitWorktree / CronCreate / TaskStop / ToolSearch —— 分别对应上面 isWorktreeModeEnabled()、feature('AGENT_TRIGGERS')、无条件、isToolSearchEnabledOptimistic() 四条路径。快照源码和本机 2.1.241 在这一层是对得上的。
hasEmbeddedSearchTools() 那条特别有意思——注释说 Anthropic 内部构建把 bfs 和 ugrep 直接编进了 bun 二进制(和内嵌 ripgrep 用同一个 ARGV0 技巧),于是 find 和 grep 在模型的 shell 里被 alias 成了这两个快速工具,专门的 Glob/Grep 工具反而成了多余的,直接不给。
这件事的连锁反应遍布全仓:系统提示词里"搜文件用 Glob"那句要改成"用 Bash 里的 find"[源码 src/constants/prompts.ts:295]、Explore agent 的提示词要改 [源码 src/tools/AgentTool/built-in/exploreAgent.ts:17]、Plan agent 也要改。一个构建期决定,在提示词层面引出至少五处分支。
COMMANDS() 是个 memoized 函数,返回 71 个基础命令 [源码 src/commands.ts:258]:addDir / agents / clear / compact / config / context / cost / doctor / hooks / mcp / memory / model / permissions / plan / plugin / resume / review / skills / status / tasks … src/commands/ 下有 101 个目录(差额是条件启用的那些)。
但命令系统的重点不在这 71 个,而在 loadAllCommands() 之后合并进来的东西 [源码 src/commands.ts:449]:
命令系统就是生态入口。 一个 skill 既是模型可调用的 Skill 工具的参数,也是用户可以敲的 /xxx——第 14 章会看到这两件事是同一份 Command 对象的两个投影。
把所有开关名扫出来,分类之后能读出一张产品路线图 [源码 全仓]:
| 类别 | 开关 | 在讲什么 |
|---|---|---|
| 上下文压缩 | REACTIVE_COMPACT CONTEXT_COLLAPSE HISTORY_SNIP CACHED_MICROCOMPACT COMPACTION_REMINDERS | 第 7 章那五层,每层一个开关 |
| 多 agent | FORK_SUBAGENT COORDINATOR_MODE BG_SESSIONS TEAMMEM UDS_INBOX | fork / 协调者 / 后台会话 / 团队记忆 / 进程间收件箱 |
| 自主运行 | PROACTIVE KAIROS KAIROS_BRIEF KAIROS_DREAM KAIROS_CHANNELS KAIROS_GITHUB_WEBHOOKS AGENT_TRIGGERS | 一整条"让 agent 自己醒过来干活"的产品线 |
| 安全 | TRANSCRIPT_CLASSIFIER BASH_CLASSIFIER HARD_FAIL ANTI_DISTILLATION_CC | 分类器与防蒸馏 |
| 工具面 | WORKFLOW_SCRIPTS MONITOR_TOOL WEB_BROWSER_TOOL TERMINAL_PANEL MCP_SKILLS EXPERIMENTAL_SKILL_SEARCH | 实验中的工具 |
| 遥测 | PERFETTO_TRACING SHOT_STATS MEMORY_SHAPE_TELEMETRY SLOW_OPERATION_LOGGING | 性能与形态观测 |
| 构建目标 | IS_LIBC_GLIBC IS_LIBC_MUSL NATIVE_CLIENT_ATTESTATION | 平台变体 |
KAIROS 那一组尤其能说明问题——KAIROS_DREAM、autoDream.ts、awaySummary.ts、SleepTool、PushNotificationTool、SubscribePRTool:这是一整套"用户不在的时候 agent 继续干活、干完推送通知"的能力,在外部版里被完整删除。系统提示词里那段 getProactiveSection()(第 5 章会全文引用)就是给这个模式写的。
feature() 的机制细节很关键,因为它反过来约束了代码写法:
[源码 src/query.ts:796-813]
正常写法是 withheld = a() || b() || c()。但那样打包器认不出常量条件,删不掉分支。所以只能写成一串独立的嵌套 if。代码可读性给构建产物体积让了路。
同样的道理还解释了一个到处出现的怪写法——条件 require:
[源码 src/query.ts:115]
用 require 而不是 import,是因为 import 是静态的、模块一定进依赖图;require 在常量条件里可以被整体消除。那个 as typeof import(...) 是为了在类型层面骗回 TypeScript 的类型推断——运行时用动态 require,类型层面用静态 import。
可迁移的判断 ③ 当"产物里不能有这段代码"成为硬需求时,接受为它扭曲代码写法——但要把扭曲的理由写在注释里。
Claude Code 在这件事上做得很干净:每一处非常规写法旁边都有一句解释("bun:bundle tree-shaking constraint"、"MUST be inlined at each callsite (not hoisted to a const)")。没有这些注释,下一个维护者一定会"顺手重构"成正常写法,然后在某次发版时发现产物大了 3MB、内部提示词泄漏到外部版了。
这是本教程一个重要的方法论边界,也是 DCE 真实生效的直接证据。
query.ts 里条件 require 了 6 个模块。在 extracted-source/ 里逐个找,6 个全都不存在:
再扩大范围查,同样缺失的还有 services/compact/cachedMicrocompact、services/compact/cachedMCConfig、proactive/index、tools/SleepTool/、tools/REPLTool/、tools/WorkflowTool/、tools/DiscoverSkillsTool/、coordinator/workerAgent……
这份 sourcemap 来自外部构建。 被 feature() 关掉的分支连同它 require 的模块一起被物理删除了,所以它们的 sourcesContent 根本没进 map 文件。
几个佐证:src/coordinator/ 只剩 1 个文件(coordinatorMode.ts,因为它被无条件 import 了)、src/bootstrap/ 只剩 state.ts、src/assistant/ 只剩 1 个文件。第 1 章那张"顶层目录按文件数排序"的表里,那些只有 1 个文件的目录,正是被 DCE 掏空的目录。
本教程的一条硬边界 凡是被 feature() 关掉的子系统,本教程只能从调用点、类型标注和注释去描述它,看不到实现。
受影响最大的是第 7 章的五层压缩——其中 snip、context-collapse、reactive compact、cached microcompact 四层的实现代码都不在,只能通过 query.ts 里的调用签名和注释来还原设计意图。本教程会在那一章逐条标明哪些是看得见的、哪些是推断的。
反过来说,这也是 DCE 有效性最直接的证明:外部版用户拿到的产物里,确实一行内部实验代码都没有。
权限规则、钩子、MCP 服务器、沙箱配置全部走同一套设置合并 [源码 src/utils/settings/constants.ts:6]:
五层,后面覆盖前面。但权限规则不是简单覆盖——第 10 章会看到,deny 规则从任何一层来都生效,allow 规则则要受 deny/ask 约束,而 policySettings 那一层还能反过来收紧(allowManagedDomainsOnly 之类)。
每条权限规则都记得自己从哪来:
[源码 src/types/permissions.ts:53]
这个来源信息一路带到遥测里,被翻译成 OTel 词表 [源码 src/services/tools/toolExecution.ts:181]:
"用户临时批准" / "用户永久批准" / "用户拒绝" / "配置决定"——四个语义清晰的桶。这样才能回答"有多少工具调用是被用户手动放行的"这种问题。
会话落盘的位置 [源码 src/utils/sessionStorage.ts:198]:
也就是 ~/.claude/projects/<项目路径转义>/<sessionId>.jsonl。
[实测] 本机对应目录的实际形态:
那个同名子目录来自工具接口上的一个字段 [源码 src/Tool.ts:466]:
工具结果太大就写文件、只把预览和路径给模型——这是上下文预算的第一道闸(第 8 章详述)。Read 工具必须设成 Infinity,否则会出现 Read→落盘→再 Read 的循环。
JSONL 每行一条记录。[实测] 把本机 48 个会话全部扫一遍,条目类型分布是:
| 类型 | 条数 | 是什么 |
|---|---|---|
| assistant | 3499 | 助手消息 |
| attachment | 3336 | 附件(system-reminder 的载体) |
| user | 2046 | 用户消息 + 工具结果 |
| last-prompt | 584 | 上一条提示词快照 |
| mode | 333 | 模式切换 |
| queue-operation | 290 | 消息队列操作 |
| atis-latch | 180 | 内部状态锁存 |
| ai-title | 78 | 自动生成的会话标题 |
| permission-mode | 65 | 权限模式变更 |
| system | 33 | 系统消息 |
| file-history-snapshot / -delta | 41 | 文件历史(支撑撤销) |
| pr-link | 17 | PR 关联 |
注意第二行:附件的数量和助手消息几乎一样多,比用户消息还多 63%。
这个数字是本教程最重要的一条实测证据。它说明 Claude Code 的上下文里,有接近一半的"消息"不是对话,是系统往里塞的运行时状态。第 6 章整章讲这件事。
会话存储层还有一条防御性常量值得看:
[源码 src/utils/sessionStorage.ts:230]
inc-3930 是个事故编号。单个会话的 transcript 能长到几个 GB。
[源码 src/utils/sessionStorage.ts:1451, 247, 236]
子 agent 的对话叫 sidechain,写到会话目录下的子目录里,而且可以再分组(注释举的例子是 workflow 子 agent 写到 subagents/workflows/<runId>/)。
这就是"fork 的输出不污染主上下文"在存储层的落地:主线程的 transcript 里只有 fork 的最终报告,全过程在另一个文件里。模型侧的提示词还专门强调了不许去读它:
Don't peek. The tool result includes an output_file path — do not Read or tail it unless the user explicitly asks for a progress check. … Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
[源码 src/tools/AgentTool/prompt.ts:91]
存储层做了隔离,提示词层再把门锁上一次。 这种"机制 + 规范"的双保险在 Claude Code 里非常常见(第 11 章的钩子权限、第 13 章的验证 agent 都是同一手法)。
回头看这一章的几个片段,有个共同点:
传统分层(controller / service / repository)分割的是职责。Claude Code 里最硬的那几条边界,分割的都是**"这东西什么时候需要存在"**。
原因不难理解:这个系统的稀缺资源不是 CPU 也不是内存,是上下文窗口里的位置和缓存前缀的稳定性。这两样东西的单位都是"时机"——什么时候进去、什么时候变、什么时候被清掉。
可迁移的判断 ④ 当你的系统里存在一个"随时间累积、且累积会带来成本"的资源时(上下文、缓存、日志、状态),按时机分层通常比按职责分层更有解释力。
具体做法:给每个会进入这个资源的东西回答三个问题——什么时候进去、进去之后会不会变、什么时候能清掉。三个答案不同的东西,不要放在同一层。
本机侧 [实测] 复现第四节那张表:
下一章进主循环:query.ts 那个 while(true) 里为什么会有 7 个 continue 分支和 10 种终止原因。