Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Claude Code Harness/第2章

第2章:工程骨架 —— 一个 npm 包里的 Agent OS

12 分钟 · 更新于 2026-09-01

第2章:工程骨架 —— 一个 npm 包里的 Agent OS

本章拆三件事:1902 个文件按什么原则分层、89 个编译期开关怎么把一份源码变成两个产品、运行时在磁盘上留下什么。核心判断是:这个系统的模块边界不是按"功能"划的,是按"什么时候需要它"划的


一、三层:入口 / 引擎 / 服务

先把 src/ 根目录那 18 个文件列出来——根目录放什么,最能暴露一个项目认为什么是核心

text
query.ts              1729 行   主循环
QueryEngine.ts        1295 行   会话级引擎
Tool.ts                792 行   工具接口定义
commands.ts            754 行   命令注册与加载
tools.ts               389 行   工具注册表
context.ts             189 行   上下文(CLAUDE.md / git status 等)
tasks.ts / Task.ts              任务
history.ts / cost-tracker.ts / costHook.ts / setup.ts / ink.ts / …

这里没有 agent.ts、没有 session.ts、没有 runtime.ts最靠近根的三个文件是"循环"、"引擎"、"工具接口"——这就是这个系统认为的三根柱子。

分层长这样:

text
入口层   entrypoints/{cli.tsx, mcp.ts, init.ts, sdk/}     302 行的 cli.tsx
            ↓
引擎层   QueryEngine.ts  ——  一个会话的生命周期        1295 行
            ↓
循环层   query.ts        ——  一次请求的多轮迭代        1729 行
            ↓
服务层   services/{api, mcp, compact, tools, plugins, analytics, lsp, …}
工具层   tools/*/  ×42
治理层   utils/{permissions, hooks, sessionStorage, attachments}

cli.tsx 只有 302 行——入口很薄是个好信号,说明启动逻辑没有和业务逻辑纠缠。真正的会话状态在 QueryEngine,它对外只暴露六个方法 [源码 src/QueryEngine.ts:184]:

ts
export class QueryEngine {
  async *submitMessage(…)      // 提交一条用户消息,吐出消息流
  interrupt(): void            // 打断
  getMessages(): readonly Message[]
  getReadFileState(): FileStateCache
  getSessionId(): string
  setModel(model: string): void
}

一个 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>,这三件事都得另外发明机制。


二、42 个工具、71 个命令、89 个开关

2.1 工具注册表:一个被开关啃得千疮百孔的数组

getAllBaseTools() 是工具的唯一真源 [源码 src/tools.ts:191]。它的注释值得单独看:

ts
/**
 * NOTE: This MUST stay in sync with
 * https://console.statsig.com/…/claude_code_global_system_caching,
 * in order to cache the system prompt across users.
 */
export function getAllBaseTools(): Tools {

工具列表必须和一份线上配置保持同步,否则跨用户的系统提示词缓存会失效。 又是缓存——第 1 章那条主线在这里第六次出现。

数组本身长这样:

ts
return [
  AgentTool,
  TaskOutputTool,
  BashTool,
  ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
  ExitPlanModeV2Tool,
  FileReadTool, FileEditTool, FileWriteTool, NotebookEditTool,
  WebFetchTool, TodoWriteTool, WebSearchTool, TaskStopTool,
  AskUserQuestionTool, SkillTool, EnterPlanModeTool,
  ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
  ...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
  ...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool] : []),
  ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
  ...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []),
  ...cronTools,
  …
  ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]

17 个工具是无条件的,其余全部挂在条件后面。条件分四类:

条件类型例子何时求值
构建期 feature()SleepToolWorkflowToolSnipTool打包时,分支被物理删除
构建期 USER_TYPE === 'ant'ConfigToolREPLTool打包时,外部版没有
运行时能力探测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 内部构建把 bfsugrep 直接编进了 bun 二进制(和内嵌 ripgrep 用同一个 ARGV0 技巧),于是 findgrep 在模型的 shell 里被 alias 成了这两个快速工具,专门的 Glob/Grep 工具反而成了多余的,直接不给

这件事的连锁反应遍布全仓:系统提示词里"搜文件用 Glob"那句要改成"用 Bash 里的 find"[源码 src/constants/prompts.ts:295]、Explore agent 的提示词要改 [源码 src/tools/AgentTool/built-in/exploreAgent.ts:17]、Plan agent 也要改。一个构建期决定,在提示词层面引出至少五处分支。

2.2 命令:71 项基础 + 一整套动态加载

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 / taskssrc/commands/ 下有 101 个目录(差额是条件启用的那些)。

但命令系统的重点不在这 71 个,而在 loadAllCommands() 之后合并进来的东西 [源码 src/commands.ts:449]:

text
内建命令  +  插件命令  +  skill 目录命令  +  bundled skills
        +  内建插件 skills  +  MCP skills  +  workflow 命令
                            ↓
                    可用性过滤(meetsAvailabilityRequirement)

命令系统就是生态入口。 一个 skill 既是模型可调用的 Skill 工具的参数,也是用户可以敲的 /xxx——第 14 章会看到这两件事是同一份 Command 对象的两个投影。

2.3 89 个 feature():一份源码,两个产品

把所有开关名扫出来,分类之后能读出一张产品路线图 [源码 全仓]:

类别开关在讲什么
上下文压缩REACTIVE_COMPACT CONTEXT_COLLAPSE HISTORY_SNIP CACHED_MICROCOMPACT COMPACTION_REMINDERS第 7 章那五层,每层一个开关
多 agentFORK_SUBAGENT COORDINATOR_MODE BG_SESSIONS TEAMMEM UDS_INBOXfork / 协调者 / 后台会话 / 团队记忆 / 进程间收件箱
自主运行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_DREAMautoDream.tsawaySummary.tsSleepToolPushNotificationToolSubscribePRTool:这是一整套"用户不在的时候 agent 继续干活、干完推送通知"的能力,在外部版里被完整删除。系统提示词里那段 getProactiveSection()(第 5 章会全文引用)就是给这个模式写的。

feature() 的机制细节很关键,因为它反过来约束了代码写法:

ts
// feature() only works in if/ternary conditions (bun:bundle tree-shaking
// constraint), so the collapse check is nested rather than composed.
if (feature('CONTEXT_COLLAPSE')) {
  if (contextCollapse?.isWithheldPromptTooLong(message, …)) { withheld = true }
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) { withheld = true }

[源码 src/query.ts:796-813]

正常写法是 withheld = a() || b() || c()。但那样打包器认不出常量条件,删不掉分支。所以只能写成一串独立的嵌套 if代码可读性给构建产物体积让了路。

同样的道理还解释了一个到处出现的怪写法——条件 require

ts
const snipModule = feature('HISTORY_SNIP')
  ? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
  : null

[源码 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、内部提示词泄漏到外部版了。

2.4 DCE 的效果,在这份源码里能直接看见

这是本教程一个重要的方法论边界,也是 DCE 真实生效的直接证据。

query.ts 里条件 require 了 6 个模块。在 extracted-source/ 里逐个找,6 个全都不存在

text
缺失  services/compact/reactiveCompact      ← feature('REACTIVE_COMPACT')
缺失  services/compact/snipCompact          ← feature('HISTORY_SNIP')
缺失  services/contextCollapse/index        ← feature('CONTEXT_COLLAPSE')
缺失  services/skillSearch/prefetch         ← feature('EXPERIMENTAL_SKILL_SEARCH')
缺失  jobs/classifier                       ← feature('TEMPLATES')
缺失  utils/taskSummary                     ← feature('BG_SESSIONS')

再扩大范围查,同样缺失的还有 services/compact/cachedMicrocompactservices/compact/cachedMCConfigproactive/indextools/SleepTool/tools/REPLTool/tools/WorkflowTool/tools/DiscoverSkillsTool/coordinator/workerAgent……

这份 sourcemap 来自外部构建。feature() 关掉的分支连同它 require 的模块一起被物理删除了,所以它们的 sourcesContent 根本没进 map 文件。

几个佐证:src/coordinator/ 只剩 1 个文件(coordinatorMode.ts,因为它被无条件 import 了)、src/bootstrap/ 只剩 state.tssrc/assistant/ 只剩 1 个文件。第 1 章那张"顶层目录按文件数排序"的表里,那些只有 1 个文件的目录,正是被 DCE 掏空的目录。

本教程的一条硬边界 凡是被 feature() 关掉的子系统,本教程只能从调用点、类型标注和注释去描述它,看不到实现。

受影响最大的是第 7 章的五层压缩——其中 snip、context-collapse、reactive compact、cached microcompact 四层的实现代码都不在,只能通过 query.ts 里的调用签名和注释来还原设计意图。本教程会在那一章逐条标明哪些是看得见的、哪些是推断的。

反过来说,这也是 DCE 有效性最直接的证明:外部版用户拿到的产物里,确实一行内部实验代码都没有。


三、配置的五层来源

权限规则、钩子、MCP 服务器、沙箱配置全部走同一套设置合并 [源码 src/utils/settings/constants.ts:6]:

ts
/**
 * All possible sources where settings can come from
 * Order matters - later sources override earlier ones
 */
export const SETTING_SOURCES = [
  'userSettings',      // 全局(~/.claude/settings.json)
  'projectSettings',   // 项目共享(.claude/settings.json,入库)
  'localSettings',     // 项目本地(.claude/settings.local.json,gitignore)
  'flagSettings',      // --settings 传入
  'policySettings',    // 管理端(managed-settings.json / 远端下发)
] as const

五层,后面覆盖前面。但权限规则不是简单覆盖——第 10 章会看到,deny 规则从任何一层来都生效,allow 规则则要受 deny/ask 约束,而 policySettings 那一层还能反过来收紧(allowManagedDomainsOnly 之类)。

每条权限规则都记得自己从哪来

ts
export type PermissionRuleSource =
  | 'userSettings' | 'projectSettings' | 'localSettings'
  | 'flagSettings' | 'policySettings' | 'cliArg' | …

[源码 src/types/permissions.ts:53]

这个来源信息一路带到遥测里,被翻译成 OTel 词表 [源码 src/services/tools/toolExecution.ts:181]:

ts
case 'session':       return behavior === 'allow' ? 'user_temporary' : 'user_reject'
case 'localSettings':
case 'userSettings':  return behavior === 'allow' ? 'user_permanent' : 'user_reject'
default:              return 'config'

"用户临时批准" / "用户永久批准" / "用户拒绝" / "配置决定"——四个语义清晰的桶。这样才能回答"有多少工具调用是被用户手动放行的"这种问题。


四、运行时在磁盘上留下什么

会话落盘的位置 [源码 src/utils/sessionStorage.ts:198]:

ts
export function getProjectsDir(): string {
  return join(getClaudeConfigHomeDir(), 'projects')
}
export function getTranscriptPath(): string {
  const projectDir = getSessionProjectDir() ?? getProjectDir(getOriginalCwd())
  return join(projectDir, `${getSessionId()}.jsonl`)
}

也就是 ~/.claude/projects/<项目路径转义>/<sessionId>.jsonl

[实测] 本机对应目录的实际形态:

text
~/.claude/projects/-home-chenkun-variFlight-work-VariFlightWork/
├── 001d58fc-….jsonl          ← 48 个会话,共 28 MB
├── 006d807e-….jsonl
├── 006d807e-…/               ← 同名子目录:这个会话的副产物
│   └── tool-results/         ← 超长工具结果被落盘到这里
├── memory/                   ← 跨会话记忆
└── …

那个同名子目录来自工具接口上的一个字段 [源码 src/Tool.ts:466]:

ts
/**
 * 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 …).
 */
maxResultSizeChars: number

工具结果太大就写文件、只把预览和路径给模型——这是上下文预算的第一道闸(第 8 章详述)。Read 工具必须设成 Infinity,否则会出现 Read→落盘→再 Read 的循环。

4.1 transcript 里到底记了什么

JSONL 每行一条记录。[实测] 把本机 48 个会话全部扫一遍,条目类型分布是:

类型条数是什么
assistant3499助手消息
attachment3336附件(system-reminder 的载体)
user2046用户消息 + 工具结果
last-prompt584上一条提示词快照
mode333模式切换
queue-operation290消息队列操作
atis-latch180内部状态锁存
ai-title78自动生成的会话标题
permission-mode65权限模式变更
system33系统消息
file-history-snapshot / -delta41文件历史(支撑撤销)
pr-link17PR 关联

注意第二行:附件的数量和助手消息几乎一样多,比用户消息还多 63%。

这个数字是本教程最重要的一条实测证据。它说明 Claude Code 的上下文里,有接近一半的"消息"不是对话,是系统往里塞的运行时状态。第 6 章整章讲这件事。

会话存储层还有一条防御性常量值得看:

ts
// 50 MB — session JSONL can grow to multiple GB (inc-3930). Callers that
// read the raw transcript must bail out above this threshold to avoid OOM.
export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024

[源码 src/utils/sessionStorage.ts:230]

inc-3930 是个事故编号。单个会话的 transcript 能长到几个 GB。

4.2 子 agent 的 transcript 是分开的

ts
export async function recordSidechainTranscript(…)
export function getAgentTranscriptPath(agentId: AgentId): string
export function setAgentTranscriptSubdir(agentId: string, subdir: string): void

[源码 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 都是同一手法)。


五、一个观察:模块边界按"时机"划,不按"功能"划

回头看这一章的几个片段,有个共同点:

  • feature() 分割的是构建时机
  • SETTING_SOURCES 分割的是配置加载时机
  • systemPromptSection vs DANGEROUS_uncached... 分割的是重算时机(第 5 章)
  • maxResultSizeChars 分割的是落盘时机
  • 主 transcript vs sidechain 分割的是上下文进入时机
  • 工具的 shouldDefer 分割的是schema 发送时机(第 8 章)

传统分层(controller / service / repository)分割的是职责。Claude Code 里最硬的那几条边界,分割的都是**"这东西什么时候需要存在"**。

原因不难理解:这个系统的稀缺资源不是 CPU 也不是内存,是上下文窗口里的位置缓存前缀的稳定性。这两样东西的单位都是"时机"——什么时候进去、什么时候变、什么时候被清掉。

可迁移的判断 ④ 当你的系统里存在一个"随时间累积、且累积会带来成本"的资源时(上下文、缓存、日志、状态),按时机分层通常比按职责分层更有解释力。

具体做法:给每个会进入这个资源的东西回答三个问题——什么时候进去、进去之后会不会变、什么时候能清掉。三个答案不同的东西,不要放在同一层。


六、动手复核

bash
cd claude-code-deep-dive/extracted-source

# 1. 根目录 18 个文件——最靠近核心的东西
ls src/*.ts src/*.tsx | xargs wc -l | sort -rn

# 2. QueryEngine 的六个公开方法
grep -n 'async \*submitMessage\|  interrupt()\|  getMessages()\|  getReadFileState()\|  getSessionId()\|  setModel(' src/QueryEngine.ts

# 3. 工具注册表与它的条件
sed -n '191,250p' src/tools.ts

# 4. 71 个基础命令
sed -n '258,346p' src/commands.ts | grep -oP '^\s{2}\K[a-zA-Z_][a-zA-Z0-9_]*' | wc -l

# 5. 89 个开关,按你关心的类别筛
grep -rho "feature('[A-Z_0-9]*')" src/ | sort -u

# 6. 那条「工具列表必须和线上配置同步」的注释
grep -n -B4 'export function getAllBaseTools' src/tools.ts

# 7. 五层设置来源
cat src/utils/settings/constants.ts | head -25

本机侧 [实测] 复现第四节那张表:

bash
D=~/.claude/projects/$(pwd | sed 's#/#-#g')   # 项目目录名是 cwd 转义来的
ls "$D" | head; du -sh "$D"

python3 -c "
import json, collections, glob, sys
c = collections.Counter()
for f in glob.glob(sys.argv[1] + '/*.jsonl'):
    for line in open(f):
        try: c[json.loads(line).get('type')] += 1
        except: pass
print(dict(c.most_common()))
" "$D"

七、总结

  1. 三层:入口薄(cli.tsx 302 行)、引擎中(QueryEngine 六个方法)、循环厚(query.ts 1729 行)。 一次交互的返回类型是 AsyncGenerator,不是 Promise——这决定了所有前端都是"订阅消息流"
  2. 42 个工具里只有 17 个是无条件的,其余挂在构建期开关、内部版标记、能力探测、远程 A/B 四类条件上;工具列表必须和一份线上缓存配置逐字节同步
  3. 89 个 feature() 是构建期常量折叠,它反向约束了代码写法(嵌套 if、条件 require、禁止把 USER_TYPE 提到常量);每一处扭曲旁边都有解释注释
  4. 配置五层来源,后覆盖前,且每条权限规则记得自己的出身,一路带到遥测词表里
  5. [实测] 48 个真实会话里,附件(3336 条)几乎和助手消息(3499 条)一样多——上下文里将近一半的条目是系统注入的运行时状态,不是对话
  6. 最硬的模块边界分割的是"时机"而不是"职责"——构建时机、重算时机、落盘时机、进入上下文的时机

下一章进主循环:query.ts 那个 while(true) 里为什么会有 7 个 continue 分支和 10 种终止原因。


  • 第1章-开篇-Claude-Code-Harness总览
  • 第3章-Agent-Loop-query这一个循环
  • 第6章-附件与system-reminder-第二条注入通道 —— 本章第四节那 3336 条附件的去向
  • 第8章-工具系统-47个成员的接口与延迟加载 —— maxResultSizeCharsshouldDefer 的完整故事
  • Codex 教程第 2 章 —— 102 个 crate 的对照
  • Pi 教程第 2 章 —— 4 个包的对照

本章目录
一、三层:入口 / 引擎 / 服务二、42 个工具、71 个命令、89 个开关三、配置的五层来源四、运行时在磁盘上留下什么五、一个观察:模块边界按"时机"划,不按"功能"划六、动手复核七、总结Related Documents
苏ICP备2025204887号-2