Claude Code 架构全解Claude Code Architecture, in Full第 2 章 · 14 章Chapter 2 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 源码目录逐个解释
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一次完整请求的旅程(全文导航)
    5. 0.5 全文章节索引
  2. 1 · 入口层与启动流程
    1. 1.1 四种启动形态
    2. 1.2 启动的第一个设计:快路径分派
    3. 1.3 命令行参数:60 多个选项
    4. 1.4 --bare:一个值得单独讲的极简模式
    5. 1.5 启动时序
    6. 1.6 系统提示词的三段结构
  3. 2 · 会话层:QueryEngine
    1. 2.1 它解决什么问题
    2. 2.2 它持有哪些状态
    3. 2.3 一次 submitMessage 的完整流程
    4. 2.4 用户消息为什么必须先落盘
    5. 2.5 消费主循环输出:一个大 switch
    6. 2.6 压缩分界点:主动释放内存
    7. 2.7 三种退出结果
    8. 2.8 ask():一次性调用的便捷封装
  4. 3 · 智能体主循环 ★
    1. 3.1 循环的骨架
    2. 3.2 State:把跨轮次状态集中管理
    3. 3.3 transition:只为可测试而存在的字段
    4. 3.4 七条转移边逐条详解
    5. 3.5 错误扣留机制
    6. 3.6 中断处理
    7. 3.7 模型降级:三个动作
    8. 3.8 思考块三定律
    9. 3.9 循环里的其他机制
    10. 3.10 循环的所有退出点
  5. 4 · 工具模型
    1. 4.1 Tool 接口:七组正交能力
    2. 4.2 为什么「安全谓词」值得单独成组
    3. 4.3 失败保守默认值
    4. 4.4 40 个内建工具分类
    5. 4.5 渐进式工具加载
    6. 4.6 工具清单装配:一个关于缓存的隐藏约束
    7. 4.7 backfillObservableInput:一个极致的缓存保护例子
  6. 5 · 工具执行
    1. 5.1 执行链路总览
    2. 5.2 并发分区:贪心算法
    3. 5.3 上下文修改要排队到批次结束
    4. 5.4 单次执行:runToolUse 的完整流程
    5. 5.5 流式工具执行器
    6. 5.6 兄弟中止控制器:最漂亮的一处设计
    7. 5.7 丢弃机制
    8. 5.8 「墓碑」消息
    9. 5.9 结果的最终处理
  7. 6 · 上下文治理 ★
    1. 6.1 五级流水线
    2. 6.2 第 ① 级:工具结果预算
    3. 6.3 第 ③ 级:微压缩与缓存编辑
    4. 6.4 第 ⑤ 级:自动摘要压缩
    5. 6.5 上下文真的超了:三级恢复瀑布
  8. 7 · 权限系统
    1. 7.1 六种权限模式
    2. 7.2 十步决策级联
    3. 7.3 自动模式:模型分类器 + 三级快速通道
    4. 7.4 权限规则的语法
    5. 7.5 沙箱与只读命令判定
    6. 7.6 权限判定的完整数据结构
    7. 7.7 权限决策的可解释性
  9. 8 · 子智能体
    1. 8.1 首要动机是上下文隔离,不是并行
    2. 8.2 三种形态
    3. 8.3 分叉:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 子智能体的上下文构造
    6. 8.6 内建智能体类型
    7. 8.7 后台任务的几种形态
    8. 8.8 中断的级联
  10. 9 · 扩展体系
    1. 9.1 四类扩展点对照
    2. 9.2 技能系统
    3. 9.3 插件系统
    4. 9.4 MCP 客户端
    5. 9.5 钩子:15 类生命周期事件
    6. 9.6 输出样式
  11. 10 · 终端界面层
    1. 10.1 用 React 写终端界面
    2. 10.2 最大的四个组件
    3. 10.3 输入框为什么有 347 KB
    4. 10.4 虚拟消息列表
    5. 10.5 工具结果的六种渲染状态
    6. 10.6 折叠:避免刷屏
    7. 10.7 87 个状态管理单元
    8. 10.8 界面和内核的接口:ToolUseContext 里的回调
    9. 10.9 一个有趣的细节:ANSI 转 PNG
  12. 11 · 持久化与恢复
    1. 11.1 对话记录的格式:JSONL
    2. 11.2 对话记录是一棵树,不是一个列表
    3. 11.3 写入队列
    4. 11.4 子智能体的记录:支链文件
    5. 11.5 恢复:三种方式
    6. 11.6 文件历史:智能体改过的文件可以回滚
    7. 11.7 配置的多来源与迁移
    8. 11.8 记忆目录
  13. 12 · 可观测体系
    1. 12.1 埋点密度
    2. 12.2 事件命名
    3. 12.3 查询链路追踪
    4. 12.4 类型层面的隐私保护
    5. 12.5 缓存断裂检测
    6. 12.6 性能剖析检查点
    7. 12.7 慢操作日志
    8. 12.8 内存错误缓冲区
    9. 12.9 内部错误的响亮日志
    10. 12.10 埋点的成本意识
  14. 13 · 构建与分发
    1. 13.1 Bun 单文件可执行程序
    2. 13.2 编译期特性开关:89 个
    3. 13.3 死代码消除:为什么这不只是「if 判断」
    4. 13.4 编译期宏
    5. 13.5 运行时特性开关:另一套系统
    6. 13.6 版本与更新
    7. 13.7 从构建方式反推的架构约束
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What this software is
    2. 0.2 The source tree, directory by directory
    3. 0.3 Three things you can read off this map
    4. 0.4 The journey of one complete request (a map of the whole book)
    5. 0.5 Chapter index
  2. 1 · The Entry Layer and Startup
    1. 1.1 Four launch modes
    2. 1.2 The first design decision at startup: fast-path dispatch
    3. 1.3 Command-line arguments: 60-plus options
    4. 1.4 --bare: a minimal mode worth its own section
    5. 1.5 Startup sequence
    6. 1.6 The three-part structure of the system prompt
  3. 2 · The Session Layer: QueryEngine
    1. 2.1 The problem it solves
    2. 2.2 What state it holds
    3. 2.3 The full flow of one submitMessage
    4. 2.4 Why the user message must hit disk first
    5. 2.5 Consuming the main loop’s output: one big switch
    6. 2.6 The compact boundary: proactively releasing memory
    7. 2.7 Three kinds of exit result
    8. 2.8 ask(): a convenience wrapper for one-shot calls
  4. 3 · The Agent Main Loop ★
    1. 3.1 The skeleton of the loop
    2. 3.2 State: centralizing cross-iteration state
    3. 3.3 transition: a field that exists purely for testability
    4. 3.4 The seven transition edges, one by one
    5. 3.5 The error-withholding mechanism
    6. 3.6 Interrupt handling
    7. 3.7 Model fallback: three actions
    8. 3.8 The three laws of thinking blocks
    9. 3.9 Other mechanisms in the loop
    10. 3.10 Every exit point of the loop
  5. 4 · The Tool Model
    1. 4.1 The Tool interface: seven orthogonal capability groups
    2. 4.2 Why the “safety predicates” deserve their own group
    3. 4.3 Fail-safe defaults
    4. 4.4 The 40 built-in tools, by category
    5. 4.5 Progressive tool loading
    6. 4.6 Tool list assembly: a hidden constraint about caching
    7. 4.7 backfillObservableInput: an extreme example of cache protection
  6. 5 · Tool Execution
    1. 5.1 The execution pipeline at a glance
    2. 5.2 Concurrency partitioning: a greedy algorithm
    3. 5.3 Context modifications are queued until the batch ends
    4. 5.4 A single execution: the full flow of runToolUse
    5. 5.5 The streaming tool executor
    6. 5.6 The sibling abort controller: the most elegant design in the file
    7. 5.7 The discard mechanism
    8. 5.8 “Tombstone” messages
    9. 5.9 Final processing of results
  7. 6 · Context Management ★
    1. 6.1 The five-tier pipeline
    2. 6.2 Tier ①: the tool result budget
    3. 6.3 Tier ③: micro-compaction and cache edits
    4. 6.4 Tier ⑤: auto summary compaction
    5. 6.5 When the context really does overflow: the three-tier recovery cascade
  8. 7 · The Permission System
    1. 7.1 Six Permission Modes
    2. 7.2 The Ten-Step Decision Cascade
    3. 7.3 Auto Mode: Model Classifier + Three-Tier Fast Path
    4. 7.4 Permission Rule Syntax
    5. 7.5 Sandboxing and Read-Only Command Detection
    6. 7.6 The Full Data Structure Behind a Permission Decision
    7. 7.7 Explainability of Permission Decisions
  9. 8 · Subagents
    1. 8.1 The Primary Motivation Is Context Isolation, Not Parallelism
    2. 8.2 Three Forms
    3. 8.3 Forking: Pushing Prompt Caching to the Limit
    4. 8.4 Tool Restrictions on Subagents
    5. 8.5 Constructing a Subagent's Context
    6. 8.6 Built-in Agent Types
    7. 8.7 The Forms Background Tasks Take
    8. 8.8 Cascading Interruption
  10. 9 · The Extension System
    1. 9.1 The Four Extension Points Compared
    2. 9.2 The Skill System
    3. 9.3 The Plugin System
    4. 9.4 The MCP Client
    5. 9.5 Hooks: 15 Kinds of Lifecycle Events
    6. 9.6 Output Styles
  11. 10 · The Terminal UI Layer
    1. 10.1 Writing a Terminal UI in React
    2. 10.2 The Four Biggest Components
    3. 10.3 Why the Input Box Is 347 KB
    4. 10.4 The Virtualized Message List
    5. 10.5 Six Rendering States for Tool Results
    6. 10.6 Collapsing: Avoiding Screen Flood
    7. 10.7 87 State-Management Units
    8. 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
    9. 10.9 A Fun Detail: ANSI to PNG
  12. 11 · Persistence and Resume
    1. 11.1 The Transcript Format: JSONL
    2. 11.2 The Transcript Is a Tree, Not a List
    3. 11.3 The Write Queue
    4. 11.4 Subagent Records: Sidechain Files
    5. 11.5 Resume: Three Ways
    6. 11.6 File History: Rolling Back Files the Agent Changed
    7. 11.7 Multi-Source Settings and Migrations
    8. 11.8 The Memory Directory
  13. 12 · The Observability System
    1. 12.1 Instrumentation Density
    2. 12.2 Event Naming
    3. 12.3 Query Chain Tracing
    4. 12.4 Privacy Protection at the Type Level
    5. 12.5 Cache-Break Detection
    6. 12.6 Profiling Checkpoints
    7. 12.7 Slow-Operation Logging
    8. 12.8 The In-Memory Error Buffer
    9. 12.9 Loud Logging for Internal Errors
    10. 12.10 Cost-Awareness in Instrumentation
  14. 13 · Build and Distribution
    1. 13.1 The Bun Single-File Executable
    2. 13.2 Compile-Time Feature Flags: 89 of Them
    3. 13.3 Dead-Code Elimination: Why It's Not Just an “if”
    4. 13.4 Compile-Time Macros
    5. 13.5 Runtime Feature Flags: A Separate System
    6. 13.6 Versions and Updates
    7. 13.7 Architectural Constraints Inferred from the Build

2 · 会话层:QueryEngine

这一章讲「一场对话」这个东西在程序里是怎么被表示和管理的

2.1 它解决什么问题

大语言模型没有记忆 —— 每次调用都要把全部历史重发一遍。所以必须有个东西持有这场对话的所有状态,在用户的每一次提问之间保持存活。

这就是 QueryEngine(查询引擎)。源码里的类注释说得很清楚:

「QueryEngine owns the query lifecycle and session state for a conversation. One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage, etc.) persists across turns.」

译:QueryEngine 持有一场对话的查询生命周期和会话状态。一场对话对应一个 QueryEngine 实例。每次调用 submitMessage() 就在同一场对话里开启一个新轮次。状态(消息、文件缓存、用量等)跨轮次保留。

2.2 它持有哪些状态

export class QueryEngine {
  private config: QueryEngineConfig       // 不变的配置(工具、命令、模型等)
  private mutableMessages: Message[]      // ★ 完整的消息历史,会一直增长
  private abortController: AbortController // 中止开关,贯穿整条调用链
  private permissionDenials: SDKPermissionDenial[]  // 被拒绝过的操作记录
  private totalUsage: NonNullableUsage    // 累计 token 用量
  private hasHandledOrphanedPermission = false
  private readFileState: FileStateCache   // ★ 读过哪些文件、什么版本

  // 下面两个是"轮次内追踪",每轮开头清空
  private discoveredSkillNames = new Set<string>()   // 本轮发现了哪些技能
  private loadedNestedMemoryPaths = new Set<string>() // 本轮加载了哪些记忆文件
}

claude-code/src/QueryEngine.ts

其中两个字段值得单独说

readFileState(文件读取状态缓存)记录「模型读过哪些文件、读的是哪个版本」。它有三个用途:

  1. 防止重复注入。记忆系统预取到一个文件时,如果模型自己已经读过它,就不再作为「记忆」注入一遍。
  2. 检测文件被外部修改。模型读过 a.ts,后来用户在编辑器里改了它,那么模型手里的内容就过期了。系统会检测到并注入一条提示。
  3. 编辑前的安全检查。模型要改一个它从没读过的文件时,工具会拒绝并要求它先读 —— 因为盲改极易出错。

abortController(中止控制器)是那个「取消开关」。它被传递到每一个工具调用、每一个网络请求。用户按 Ctrl+C 时拉一下,整条链路都能感知到。第 3 章会讲它的正确处理姿势。

2.3 一次 submitMessage 的完整流程

submitMessage() 是这个类的核心方法。它是一个异步生成器 —— 也就是说它不是「算完再返回」,而是一边算一边往外吐消息,调用方可以实时消费。

async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>

async * 是 JavaScript 的异步生成器语法。yield 一个值就等于「先把这个吐出去,调用方拿到之后我再继续」。这是流式界面能实时更新的基础。)

完整流程:

① 清空轮次级追踪状态 discoveredSkillNames.clear() setCwd(cwd) 设置当前工作目录 ↓ ② 包装权限检查函数 把外部传入的 canUseTool 包一层,用来记录所有被拒绝的操作 (这些记录最终会出现在返回结果的 permission_denials 字段里) ↓ ③ 构造系统提示词 fetchSystemPromptParts() → 三段结构(见第 1.6 节) + 可选的记忆机制说明 + 可选的 --append-system-prompt 追加内容 ↓ ④ 处理用户输入 processUserInput() · 解析斜杠命令(如果是 /compact 就不用调模型了) · 处理 @文件提及 → 读文件内容作为附件 · 处理粘贴的图片 · 返回:要追加的消息、是否需要调模型、允许的工具、指定的模型 ↓ ⑤ ★ 把用户消息先落盘(这一步的时机很关键,见 2.4) ↓ ⑥ 发出 system_init 消息 告诉调用方:这次用什么模型、有哪些工具、哪些 MCP 服务、哪些技能、哪些插件 ↓ ⑦ 分支: ├─ shouldQuery === false(纯本地斜杠命令) │ → 直接把命令的输出吐出去,发一个 result 消息,返回 │ └─ 需要调模型 → 进入 query() 主循环(第 3 章) → 一边消费主循环吐出的消息,一边做记录、转换、预算检查

2.4 用户消息为什么必须先落盘

第 ⑤ 步有一段很长的注释,讲的是一个真实的线上问题:

「Persist the user's message(s) to transcript BEFORE entering the query loop. The for-await below only calls recordTranscript when ask() yields an assistant/user/compact_boundary message — which doesn't happen until the API responds. If the process is killed before that (e.g. user clicks Stop in cowork seconds after send), the transcript is left with only queue-operation entries; getLastSessionLog filters those out, returns null, and --resume fails with "No conversation found".」

译:在进入查询循环之前就把用户消息写入对话记录。因为下面那个循环只有在生成器吐出模型消息 / 用户消息 / 压缩分界点消息时才会调用记录函数 —— 而这要等到接口响应回来才会发生。如果进程在那之前就被杀掉(比如用户点了发送之后几秒就点了停止),对话记录里就只剩下队列操作条目;而读取上次会话记录的函数会把这些过滤掉、返回空,于是 --resume 会报「找不到对话」。

翻译成人话:用户发出消息后、模型还没回复的那几秒钟里,如果程序被杀掉,这次对话就彻底找不回来了。因为落盘的时机在模型回复之后。

修复方式是把落盘提前到「用户消息被接受」的那一刻。但这里又冒出一个性能权衡:

if (persistSession && messagesFromUserInput.length > 0) {
  const transcriptPromise = recordTranscript(messages)
  if (isBareMode()) {
    void transcriptPromise          // ★ 极简模式:发射后不管,不等它写完
  } else {
    await transcriptPromise         // 正常模式:等写完再继续
    ...
  }
}

注释解释了为什么极简模式要特殊处理:

「--bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention — the single largest controllable critical-path cost after module eval.」

译:极简模式下发射后不管。脚本化调用不会在请求中途被杀之后去恢复。这个 await 在固态硬盘上约 4 毫秒,在磁盘竞争时约 30 毫秒 —— 是继模块加载之后关键路径上最大的可控开销

这句话的信息量很大:他们把关键路径上的每一项开销都量化过,4 到 30 毫秒已经是「最大的可控开销」了。

2.5 消费主循环输出:一个大 switch

进入主循环后,QueryEngine 用一个 for await 循环消费主循环吐出的每一条消息,按类型分别处理:

消息类型QueryEngine 做什么
assistant
模型回复
记录停止原因、追加到历史、用「发射后不管」的方式落盘(原因见下)、转换成标准格式吐给调用方
user
用户消息 / 工具结果
追加到历史、同步等待落盘、轮次计数 +1
progress
进度
追加到历史并立刻落盘(原因见下)
attachment
附件
追加、立刻落盘。如果是「结构化输出」附件则提取结果;如果是「达到最大轮次」则发一个错误结果并返回
stream_event
流式事件
累计 token 用量。只有开了 --include-partial-messages 才吐给调用方
system
系统消息
压缩分界点 → 释放分界点之前的消息供垃圾回收;接口错误 → 转成重试通知
tombstone
墓碑
控制信号,表示「删除某条消息」,直接跳过不处理
tool_use_summary
工具摘要
转发给调用方(用于移动端界面显示「刚才做了什么」)

为什么模型消息要「发射后不管」,而用户消息要同步等待

「Fire-and-forget for assistant messages. claude.ts yields one assistant message per content block, then mutates the last one's message.usage/stop_reason on message_delta — relying on the write queue's 100ms lazy jsonStringify. Awaiting here blocks ask()'s generator, so message_delta can't run until every block is consumed; the drain timer (started at block 1) elapses first.」

译:模型消息用发射后不管。接口层为每个内容块吐出一条模型消息,然后在收到 message_delta 事件时修改最后那条消息的用量和停止原因字段 —— 这依赖写入队列 100 毫秒的延迟序列化。如果在这里等待,就会阻塞生成器,导致 message_delta 事件要等所有内容块被消费完才能处理;而排空定时器(从第 1 个块就开始计时了)会先到期。

这一段涉及一个精巧的机制,值得展开:

模型流式返回时,接口层的行为: · 每收到一个内容块,就 yield 一条模型消息 · 最后收到 message_delta 事件时,回头修改"最后那条消息"的用量字段 ↓ 落盘的写入队列有一个 100 毫秒的延迟: 消息进队列后不立刻序列化,等 100 毫秒再统一处理 ★ 这个延迟正是为了让 message_delta 有机会先把字段补全 ↓ 如果这里 await(同步等待落盘): 生成器被阻塞 → 后续内容块无法被消费 → message_delta 永远轮不到 → 100 毫秒的排空定时器先到期 → 用量字段还是空的就被序列化了 ↓ 所以必须"发射后不管"。而写入队列本身是保序的,所以不会乱序。

progress(进度)消息要「立刻落盘」,也有专门的注释:

「Record inline so the dedup loop in the next ask() call sees it as already-recorded. Without this, deferred progress interleaves with already-recorded tool_results in mutableMessages, and the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.」

译:就地记录,这样下一次调用时的去重循环才能看到它已被记录。否则延迟的进度消息会和已记录的工具结果交错,导致去重遍历把「起始父节点」固定在错误的消息上 —— 从而分叉出一条支链,让对话在恢复时变成孤儿。

这里透露了对话记录的一个重要结构:它不是一个线性列表,而是一棵通过「父节点 ID」串起来的树。第 11 章会详细讲。

2.6 压缩分界点:主动释放内存

当主循环发出「压缩分界点」消息时,QueryEngine 做一件很重要的事:

if (message.subtype === 'compact_boundary' && message.compactMetadata) {
  // 分界点之前的消息已经被摘要替代了,可以释放给垃圾回收器
  const mutableBoundaryIdx = this.mutableMessages.length - 1
  if (mutableBoundaryIdx > 0) {
    this.mutableMessages.splice(0, mutableBoundaryIdx)   // ★ 直接从数组里删掉
  }
  const localBoundaryIdx = messages.length - 1
  if (localBoundaryIdx > 0) {
    messages.splice(0, localBoundaryIdx)
  }
  yield { type:'system', subtype:'compact_boundary', ... }
}

注释:「Release pre-compaction messages for GC. query.ts already uses getMessagesAfterCompactBoundary() internally, so only post-boundary messages are needed going forward.」(把压缩前的消息释放给垃圾回收。主循环内部已经只用分界点之后的消息了,所以往后只需要保留这些。)

为什么要专门做这件事?因为一场长会话的消息历史可能有几百 MB。压缩之后前面那些消息在逻辑上已经没用了,但只要数组还引用着它们,垃圾回收器就不会回收 —— 内存会一直涨到进程被系统杀掉。

但落盘要在释放之前完成

而且顺序不能错。在删除之前,有一段专门的落盘逻辑:

if (persistSession && message.type === 'system' &&
    message.subtype === 'compact_boundary') {
  const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
  if (tailUuid) {
    const tailIdx = this.mutableMessages.findLastIndex(m => m.uuid === tailUuid)
    if (tailIdx !== -1) {
      await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
    }
  }
}

注释解释了不这么做的后果:「If the SDK subprocess restarts before then (claude-desktop kills between turns), tailUuid points to a never-written message → applyPreservedSegmentRelinks fails its tail→head walk → returns without pruning → resume loads full pre-compact history.」

译:如果子进程在那之前重启(桌面应用会在轮次之间杀进程),保留段的尾节点就指向了一条从未被写入的消息 → 重新串联函数的「从尾到头」遍历失败 → 直接返回不做裁剪 → 于是恢复时会加载完整的压缩前历史。

症状是:用户压缩过的会话,恢复之后又变回了压缩前的样子,上下文立刻爆掉。

2.7 三种退出结果

submitMessage 最终会发出一个 result 消息,标明这次轮次是怎么结束的:

结果类型什么时候发生
success正常完成
error_max_turns达到 --max-turns 上限
error_max_budget_usd达到 --max-budget-usd 上限
error_max_structured_output_retries要求结构化输出,但模型连续 5 次都产出不合格的结果
error_during_execution执行过程中出了没能恢复的错

最后那个错误类型带了一个专门的诊断前缀

errors: (() => {
  const all = getInMemoryErrors()
  const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
  return [
    // ★ 诊断前缀:直接说明"判定失败"的那三个条件各自是什么值
    `[ede_diagnostic] result_type=${edeResultType} ` +
    `last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
    ...all.slice(start).map(_ => _.error),
  ]
})()

而且错误列表是按轮次范围截取的 —— 用了一个「水位标记」:

// 用引用而不是下标作为水位标记,这样 error_during_execution 的 errors 数组
// 是轮次范围内的。用长度下标会在 100 条环形缓冲区发生位移时失效 —— 下标会滑走。
// 如果这条标记本身被轮换出去了,lastIndexOf 返回 -1,就包含全部(安全兜底)。
const errorLogWatermark = getInMemoryErrors().at(-1)
这段代码体现的工程习惯

内存里的错误日志是一个只保留最近 100 条的环形缓冲区。想标记「本轮开始的位置」,最直觉的做法是记下当时的数组长度。

但环形缓冲区在满了之后会从头部丢弃元素 —— 你记的那个下标会「滑走」,指向别的位置。

正确做法是记住那个元素本身的引用,之后用 lastIndexOf 反查它现在在哪。如果它已经被挤出去了,反查返回 -1,代码就退化为「包含全部错误」—— 这是一个安全的降级行为,宁可多报也不漏报。

2.8 ask():一次性调用的便捷封装

文件末尾还导出了一个 ask() 函数,是 QueryEngine 的一次性封装 —— 创建实例、跑一轮、把文件缓存交还给调用方:

export async function* ask({...}) {
  const engine = new QueryEngine({
    ...,
    readFileCache: cloneFileStateCache(getReadFileCache()),   // ★ 传入的是克隆
  })
  try {
    yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
  } finally {
    setReadFileCache(engine.getReadFileState())   // ★ 无论如何都要交还
  }
}

两个细节:

  • 传入的文件缓存是克隆的。这样这次调用对缓存的修改不会立刻影响外部 —— 直到最后显式交还。
  • finally 块保证交还。即使中间抛异常、被中断,「模型读过哪些文件」这个信息也不会丢。丢了会导致下一轮重复注入记忆或者误判文件新鲜度。

2 · The Session Layer: QueryEngine

This chapter covers how “a conversation” is represented and managed inside the program.

2.1 The problem it solves

A large language model has no memory — every call has to resend the entire history. So something must hold all the state of the conversation and stay alive between one user question and the next.

That something is QueryEngine. The class comment in the source puts it plainly:

“QueryEngine owns the query lifecycle and session state for a conversation. One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage, etc.) persists across turns.”

In other words: QueryEngine owns the query lifecycle and session state for one conversation. One conversation maps to one QueryEngine instance. Every call to submitMessage() opens a new turn within the same conversation. State (messages, file cache, usage, and so on) carries over across turns.

2.2 What state it holds

export class QueryEngine {
  private config: QueryEngineConfig       // immutable configuration (tools, commands, model, etc.)
  private mutableMessages: Message[]      // ★ the full message history; grows forever
  private abortController: AbortController // the abort switch, threaded through the entire call chain
  private permissionDenials: SDKPermissionDenial[]  // record of operations that were denied
  private totalUsage: NonNullableUsage    // cumulative token usage
  private hasHandledOrphanedPermission = false
  private readFileState: FileStateCache   // ★ which files have been read, and which version

  // The two below are "per-turn tracking," cleared at the start of each turn
  private discoveredSkillNames = new Set<string>()   // skills discovered this turn
  private loadedNestedMemoryPaths = new Set<string>() // memory files loaded this turn
}

claude-code/src/QueryEngine.ts

Two of these fields deserve a closer look

readFileState (the file-read state cache) records “which files the model has read, and which version.” It serves three purposes:

  1. Preventing duplicate injection. When the memory system prefetches a file, and the model has already read it on its own, it is not injected again as “memory.”
  2. Detecting external modification. The model read a.ts; later the user changed it in their editor; now the content the model is holding is stale. The system detects this and injects a notice.
  3. A safety check before editing. When the model tries to modify a file it has never read, the tool refuses and tells it to read first — because editing blind is a recipe for mistakes.

abortController is the “cancel switch.” It is passed into every tool call and every network request. When the user presses Ctrl+C, it is pulled, and the whole chain can feel it. Chapter 3 covers how to handle it correctly.

2.3 The full flow of one submitMessage

submitMessage() is the core method of this class. It is an async generator — meaning it does not “finish computing, then return”; it emits messages while it works, and the caller can consume them in real time.

async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>

(async * is JavaScript’s async generator syntax. To yield a value means “hand this out first; once the caller has it, I continue.” This is the foundation that lets a streaming interface update live.)

The full flow:

① Clear per-turn tracking state discoveredSkillNames.clear() setCwd(cwd) set the current working directory ↓ ② Wrap the permission-check function Wrap the externally supplied canUseTool in a layer that records every denied operation (these records end up in the permission_denials field of the result) ↓ ③ Build the system prompt fetchSystemPromptParts() → the three-part structure (see Section 1.6) + optional description of the memory mechanism + optional --append-system-prompt content ↓ ④ Process the user's input processUserInput() · Parse slash commands (if it's /compact, no model call is needed) · Handle @file mentions → read file contents as attachments · Handle pasted images · Return: messages to append, whether a model call is needed, allowed tools, the specified model ↓ ⑤ ★ Write the user message to disk first (the timing here is critical, see 2.4) ↓ ⑥ Emit a system_init message Tells the caller: which model this uses, which tools, which MCP servers, which skills, which plugins ↓ ⑦ Branch: ├─ shouldQuery === false (a purely local slash command) │ → emit the command's output directly, send a result message, return │ └─ A model call is needed → enter the query() main loop (Chapter 3) → consume the messages the main loop emits while recording, converting, and checking the budget

2.4 Why the user message must hit disk first

Step ⑤ carries a long comment describing a real production issue:

“Persist the user's message(s) to transcript BEFORE entering the query loop. The for-await below only calls recordTranscript when ask() yields an assistant/user/compact_boundary message — which doesn't happen until the API responds. If the process is killed before that (e.g. user clicks Stop in cowork seconds after send), the transcript is left with only queue-operation entries; getLastSessionLog filters those out, returns null, and --resume fails with "No conversation found".”

In other words: write the user message to the transcript before entering the query loop. The loop below only calls the recording function when the generator emits an assistant / user / compact-boundary message — and that does not happen until the API responds. If the process is killed before then (say the user hits Send and then Stop a few seconds later), the transcript is left with nothing but queue-operation entries; the function that reads the last session log filters those out and returns null, so --resume fails with “No conversation found.”

In plain English: if the program is killed during the few seconds between the user sending a message and the model replying, that conversation is gone for good. Because the write to disk happened only after the model’s reply.

The fix is to move the write forward to the moment “the user message is accepted.” But that surfaces a performance trade-off:

if (persistSession && messagesFromUserInput.length > 0) {
  const transcriptPromise = recordTranscript(messages)
  if (isBareMode()) {
    void transcriptPromise          // ★ bare mode: fire and forget; don't wait for the write
  } else {
    await transcriptPromise         // normal mode: wait for the write before continuing
    ...
  }
}

The comment explains why bare mode gets special treatment:

“--bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention — the single largest controllable critical-path cost after module eval.”

In other words: in bare mode, fire and forget. Scripted calls never resume after being killed mid-request. That await costs about 4 ms on an SSD and about 30 ms under disk contention — the single largest controllable cost on the critical path after module loading.

That sentence carries a lot of information: they have quantified every cost on the critical path, and 4 to 30 milliseconds already counts as “the largest controllable cost.”

2.5 Consuming the main loop’s output: one big switch

Once inside the main loop, QueryEngine uses a for await loop to consume each message the main loop emits, handling each type differently:

Message typeWhat QueryEngine does
assistant
model reply
Records the stop reason, appends to history, writes to disk fire-and-forget (reason below), converts to the standard format and emits to the caller
user
user message / tool result
Appends to history, waits synchronously for the disk write, turn count +1
progress
progress
Appends to history and writes to disk immediately (reason below)
attachment
attachment
Appends, writes immediately. If it is a “structured output” attachment, extracts the result; if it is “max turns reached,” emits an error result and returns
stream_event
streaming event
Accumulates token usage. Emitted to the caller only if --include-partial-messages is on
system
system message
Compact boundary → releases the messages before the boundary for garbage collection; API error → converted into a retry notification
tombstone
tombstone
A control signal meaning “delete a given message”; skipped without processing
tool_use_summary
tool summary
Forwarded to the caller (used by the mobile UI to show “what just happened”)

Why model messages are fire-and-forget while user messages wait synchronously

“Fire-and-forget for assistant messages. claude.ts yields one assistant message per content block, then mutates the last one's message.usage/stop_reason on message_delta — relying on the write queue's 100ms lazy jsonStringify. Awaiting here blocks ask()'s generator, so message_delta can't run until every block is consumed; the drain timer (started at block 1) elapses first.”

In other words: model messages are fire-and-forget. The API layer emits one model message per content block, then, when the message_delta event arrives, mutates the usage and stop-reason fields of that last message — relying on the write queue’s 100 ms lazy serialization. Awaiting here would block the generator, so message_delta could not be processed until every content block had been consumed; the drain timer (started at block 1) would expire first.

There is a subtle mechanism in here that is worth unpacking:

While the model streams, the API layer behaves like this: · Each time a content block arrives, it yields one model message · When the final message_delta event arrives, it goes back and mutates "that last message's" usage field ↓ The disk-write queue has a 100 ms delay: A message entering the queue is not serialized right away; it waits 100 ms and is processed in a batch ★ That delay exists precisely to give message_delta a chance to fill in the fields first ↓ If we await here (wait synchronously for the write): The generator blocks → later content blocks can't be consumed → message_delta never gets its turn → the 100 ms drain timer expires first → the usage field is serialized while still empty ↓ So it must be "fire and forget." And the write queue itself preserves order, so nothing gets reordered.

The progress message’s “write immediately” also has a dedicated comment:

“Record inline so the dedup loop in the next ask() call sees it as already-recorded. Without this, deferred progress interleaves with already-recorded tool_results in mutableMessages, and the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.”

In other words: record it inline, so the dedup loop in the next call sees it as already recorded. Otherwise deferred progress messages interleave with already-recorded tool results, and the dedup walk pins the “starting parent node” to the wrong message — forking off a side branch and orphaning the conversation on resume.

This reveals an important structural fact about the transcript: it is not a linear list; it is a tree linked together by “parent node IDs.” Chapter 11 covers this in detail.

2.6 The compact boundary: proactively releasing memory

When the main loop emits a “compact boundary” message, QueryEngine does something important:

if (message.subtype === 'compact_boundary' && message.compactMetadata) {
  // Messages before the boundary have been replaced by a summary; release them to the garbage collector
  const mutableBoundaryIdx = this.mutableMessages.length - 1
  if (mutableBoundaryIdx > 0) {
    this.mutableMessages.splice(0, mutableBoundaryIdx)   // ★ delete them from the array outright
  }
  const localBoundaryIdx = messages.length - 1
  if (localBoundaryIdx > 0) {
    messages.splice(0, localBoundaryIdx)
  }
  yield { type:'system', subtype:'compact_boundary', ... }
}

Comment: “Release pre-compaction messages for GC. query.ts already uses getMessagesAfterCompactBoundary() internally, so only post-boundary messages are needed going forward.” (Release the pre-compaction messages to the garbage collector. The main loop already only uses messages after the boundary internally, so only those need to be kept from here on.)

Why go out of the way to do this? Because the message history of a long session can run to hundreds of MB. After compaction, the earlier messages are logically useless, but as long as the array still references them, the garbage collector will not reclaim them — memory keeps climbing until the OS kills the process.

But the disk write must finish before the release

And the order cannot be wrong. Right before the deletion, there is a dedicated piece of disk-write logic:

if (persistSession && message.type === 'system' &&
    message.subtype === 'compact_boundary') {
  const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
  if (tailUuid) {
    const tailIdx = this.mutableMessages.findLastIndex(m => m.uuid === tailUuid)
    if (tailIdx !== -1) {
      await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
    }
  }
}

The comment explains the consequence of not doing this: “If the SDK subprocess restarts before then (claude-desktop kills between turns), tailUuid points to a never-written message → applyPreservedSegmentRelinks fails its tail→head walk → returns without pruning → resume loads full pre-compact history.”

In other words: if the subprocess restarts before then (the desktop app kills the process between turns), the tail node of the preserved segment points to a message that was never written → the relinking function’s tail-to-head walk fails → it returns without pruning → and resume loads the full pre-compaction history.

The symptom: a session the user had compacted comes back after resume looking the way it did before compaction, and the context blows up immediately.

2.7 Three kinds of exit result

submitMessage ends by emitting a result message indicating how the turn finished:

Result typeWhen it happens
successCompleted normally
error_max_turnsHit the --max-turns limit
error_max_budget_usdHit the --max-budget-usd limit
error_max_structured_output_retriesStructured output was requested, but the model produced a non-conforming result 5 times in a row
error_during_executionAn unrecoverable error occurred during execution

That last error type carries a dedicated diagnostic prefix

errors: (() => {
  const all = getInMemoryErrors()
  const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
  return [
    // ★ Diagnostic prefix: states outright what the three "failure verdict" conditions each evaluated to
    `[ede_diagnostic] result_type=${edeResultType} ` +
    `last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
    ...all.slice(start).map(_ => _.error),
  ]
})()

And the error list is sliced to the range of this turn — using a “watermark”:

// Use a reference, not an index, as the watermark, so the errors array for error_during_execution
// is scoped to this turn. A length index breaks when the 100-entry ring buffer shifts — the index slides away.
// If the marker itself has been rotated out, lastIndexOf returns -1 and we include everything (safe fallback).
const errorLogWatermark = getInMemoryErrors().at(-1)
The engineering habit this code reflects

The in-memory error log is a ring buffer that keeps only the most recent 100 entries. To mark “where this turn began,” the most intuitive approach is to note the array length at that moment.

But once a ring buffer is full, it drops elements from the head — the index you noted “slides away” and points somewhere else.

The right approach is to remember a reference to the element itself, then use lastIndexOf to look up where it is now. If it has already been pushed out, the lookup returns -1 and the code degrades to “include every error” — a safe fallback: better to over-report than to miss something.

2.8 ask(): a convenience wrapper for one-shot calls

The end of the file also exports an ask() function, a one-shot wrapper around QueryEngine — create an instance, run one round, hand the file cache back to the caller:

export async function* ask({...}) {
  const engine = new QueryEngine({
    ...,
    readFileCache: cloneFileStateCache(getReadFileCache()),   // ★ what gets passed in is a clone
  })
  try {
    yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
  } finally {
    setReadFileCache(engine.getReadFileState())   // ★ hand it back no matter what
  }
}

Two details:

  • The file cache passed in is a clone. So this call’s changes to the cache do not affect the outside world right away — not until it is explicitly handed back at the end.
  • The finally block guarantees the hand-back. Even if an exception is thrown or the call is interrupted midway, the knowledge of “which files the model has read” is not lost. Losing it would cause the next turn to inject memory twice or misjudge file freshness.