Claude Code 源码分析

对照 nanobot 逆向理解 Claude Code Agent 架构。

目录


Ch 00 - 全局流程图

方括号为章节引用:Ch04 · Ch05 · Ch06 · Ch07 · Ch08 · Ch09 · Ch10 · Ch11 · Ch14

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
① [Ch04] main.tsx 启动
├─ 并行预取:MDM 企业策略 / API Key / Feature Flags(~40ms,串行需 135ms+)
└─ Commander.js 解析参数,按子命令分发 → launchRepl() 进入主 REPL

② [Ch10] 用户输入
├─ 斜杠命令(/commit、/review 等)→ Skill 执行
└─ 普通输入 → 进入 ③

③ [Ch05] QueryEngine.submitMessage()
初始化会话状态、封装 canUseTool 记录拒绝情况 → 进入 queryLoop()

↻ [Ch05] Agent Loop:queryLoop() 主循环 while(true),每轮调一次 LLM,有工具就执行再循环

④ [Ch11] Compact 预处理(每轮必跑)
├─ Microcompaction → 每轮必跑;移除旧轮次图片,缩小 tool_result 体积
├─ Autocompact → context 接近上限时触发
│ ├─ 先试 Session Memory Compaction → 直接存 Session Memory,最快,不调 LLM
│ └─ 失败则 Traditional Compaction → fork subAgent 做全量摘要
└─ blockingLimit 超限(Autocompact 之后判断)→ 退出 loop

⑤ [Ch05] 调 LLM API,SSE 流式接收响应
├─ text block → 逐 token yield 给 UI 实时显示
├─ [Ch08] thinking block → ThinkingConfig 控制 budget,ultrathink 触发最大 budget
└─ tool_use block → input_json_delta 边收边拼,content_block_stop 后 block 完整
├─ 默认路径 → 推入 toolUseBlocks[],等流结束后统一进入 ⑦
└─ [Ch07] StreamingToolExecutor 开启 → 立即 addTool(),不等流结束直接执行(见 ⑨)

⑥ LLM 流接收结束判断(依据 toolUseBlocks.length,不依赖 stop_reason)
├─ toolUseBlocks 为空 → executePostSamplingHooks(fire-and-forget)
│ → [Ch14] Stop / SubagentStop Hooks → return 'completed' ✓ 结束
├─ 回复被截断(走同一恢复路径)
│ ├─ max_tokens:本条回复超出单次输出上限(默认 32k)
│ └─ model_context_window_exceeded:④ blockingLimit / Reactive Compaction 未拦住,输出中途撞上下文总限
│ 恢复:[默认关] 同轮升至 64k 重试 → 仍超或 flag 关 → 追加隐藏提示让 LLM 续写(最多 3 次)
│ prompt_too_long:先试 context_collapse drain → 再试 reactive compact → 均失败则 return 'prompt_too_long'
└─ toolUseBlocks 非空 → executePostSamplingHooks(fire-and-forget)→ 进入 ⑦

⑦ [Ch14] PreToolUse Hooks 触发,可修改参数或阻断本次执行

⑧ [Ch06] 权限判断
├─ deny 规则命中 → 直接拒绝 ✗
├─ ask 规则命中 → 弹窗(沙箱自动允许例外)
├─ tool.checkPermissions() → allow(只读工具在此返回)/ ask / deny
├─ bypassPermissions 模式 → 直通
├─ alwaysAllowed 规则命中 → 直通
└─ 其余 → 弹窗等用户确认;denied → 返回 error tool_result,跳过执行 ✗

⑨ 工具调度 + 执行(feature flag 决定走哪条路)
├─ 默认 [Ch06] toolOrchestration:连续 safe → 并发批(上限 10),unsafe → 串行
├─ 实验性 [Ch07] StreamingToolExecutor(默认关闭)
│ LLM 流式中即启动,动态 safe/unsafe,无并发上限
│ 出错 → 级联取消同批其他工具
└─ 每个工具经 runToolUse() → tool.call() 执行,返回 tool_result

⑩ AgentTool 特殊路径
├─ tool.call() → runAgent() → query() → 子 queryLoop,独立 context 和工具池
└─ [Ch09] Task 系统:多 Agent 协调基础设施
JSON 文件 + 文件锁存储任务状态,父 Agent / subAgent 通过 TaskCreate/TaskUpdate 工具共享进度

⑪ [Ch14] PostToolUse / PostToolUseFailure Hooks 触发
├─ PostToolUse(执行成功)→ 可替换 MCP 输出或追加额外消息
└─ PostToolUseFailure(执行失败)→ 可处理错误结果

⑫ [Ch06] 结果大小检查
├─ 单条 > 工具自设上限(最高 50k chars)→ 写磁盘,模型收到路径 + 2000 字节预览
│ └─ 例外:Read 工具不设上限,永不落盘
└─ 本轮总量 > 200k chars → 持久化最大几条,降至阈值以下

⑬ toolResults[] 拼入 messages,作为下轮 LLM 的输入历史
├─ maxTurns 超限 → 退出 loop
└─ continue ──→ 回到 ④

退出条件:
├─ completed 正常结束(无 tool_use)
├─ aborted_tools 工具执行中用户中断
├─ aborted_streaming 流式响应中用户中断
├─ max_turns 超过最大轮次
├─ blockingLimit token 超限(Autocompact 后仍超)
├─ prompt_too_long context 过长且压缩恢复失败
├─ image_error 图片大小/resize 错误
├─ hook_stopped PostToolUse Hook 阻断(continuation 标志)
├─ stop_hook_prevented Stop Hook 阻断
└─ model_error API 报错

Reactive Compaction(非预处理步骤):
└─ LLM 返回 prompt_too_long → 拦截响应 → 触发压缩 → 重试本轮 API 调用

Ch 01 - 项目概览

nanobot Claude Code
语言 Python TypeScript (Bun)
规模 小型 ~50万行,生产级
工具数 ~10 40+ 内置 + 动态 MCP
入口 loop.py src/query.ts(1732行)
UI React+Ink 终端(前端,跳过)

定位:terminal-native agentic system,核心是 Agent Loop 直接操作本地环境。

Agent 核心文件

文件 职责
src/query.ts Agent Loop 核心
src/QueryEngine.ts 会话容器,包裹 query()
src/Tool.ts Tool 类型定义
src/tools.ts 工具注册 & 池化
src/services/tools/toolExecution.ts 权限检查 + hooks + 执行
src/services/tools/toolOrchestration.ts 并发/串行调度
src/utils/tasks.ts Task 系统
src/services/compact/ 对话压缩
src/utils/hooks.ts Hooks 系统
src/utils/thinking.ts Thinking 模式

UI 层只消费 query.ts 的流式事件,核心逻辑全在 query.ts。

关键文件速查

1
2
3
4
5
6
7
8
9
10
11
Agent Loop       → src/query.ts + src/QueryEngine.ts
工具系统 → src/Tool.ts + src/tools.ts + src/tools/
权限系统 → src/utils/permissions/
沙盒 → src/utils/sandbox/sandbox-adapter.ts
Hooks → src/utils/hooks/ + src/types/hooks.ts
Compact → src/services/compact/
记忆系统 → src/memdir/ + src/services/extractMemories/
上下文构建 → src/context.ts
Skill 系统 → src/skills/
Task 系统 → src/tools/AgentTool/ + src/utils/task/
多 Agent 实验 → src/coordinator/ + src/utils/swarm/

Ch 02 - 技术栈

Bun:比 Node.js 启动快 ~3x,内置打包器,一套工具链搞定运行/打包/测试。代价是 Windows 支持弱、生态兼容性差一些。

Feature Flag(bun:bundle):打包时注入 flag 值,false 的分支物理删除。目的是同一份代码打出不同版本(内部/灰度/正式),不用维护多个分支。反编译版里 feature() 永远返回 false,实验功能全关。

前端:React + Ink,终端里跑 React 组件。REPL.tsx 调用 QueryEngine,消费流式事件刷新界面。Agent 逻辑与 UI 完全解耦。


Ch 03 - 目录结构

1
2
3
4
5
6
7
8
src/
├── query.ts / QueryEngine.ts / Tool.ts / tools.ts ← Agent 核心
├── tools/ ← 40+ 工具,每个独立目录(定义+执行自包含)
├── services/ ← 外部交互(API、MCP、Compact)
├── utils/ ← hooks、tasks、thinking 等基础设施
├── skills/ ← Skill 系统
├── coordinator/ ← 多 Agent 协调(实验性)
└── screens/ components/ hooks/ ← 前端,跳过

tools/ 结构:每个工具一个独立目录,tools.ts 统一注册。工具列表排序固定,保证 prompt cache 命中率稳定。

src/tools.ts 三个关键函数:

  • getAllBaseTools() - 返回所有内置工具
  • getTools(context) - 按权限/feature flag 过滤
  • assembleToolPool(context, mcpTools) - 内置 + MCP 合并,去重排序

Ch 04 - 启动流程

并行预取:main.tsx 顶部利用模块加载副作用,在其他 import 还在执行时就并行触发:MDM 企业策略、Keychain(API Key / OAuth)、GrowthBook feature flags。串行需 135ms+,并行只要 ~40ms。

命令分发:Commander.js 解析参数后,所有路径最终都汇入 launchRepl(),差别只是传入的初始消息和配置不同。

1
2
3
4
5
6
main.tsx
├─ 子命令(/mcp、/doctor、/auth…)→ 各自处理
└─ 主命令 → 按 flag 路由 → launchRepl()
├─ --resume 恢复历史会话
├─ --bridge IDE 桥接模式
└─ 默认 普通交互 REPL

CoordinatorModesrc/coordinator/ 是多 Agent 团队协作模式,在启动时通过 feature flag 加载。默认双重关闭:① 构建时 feature('COORDINATOR_MODE') = false,代码不进二进制;② 即使代码存在,还需环境变量 CLAUDE_CODE_COORDINATOR_MODE=true 才能激活。对外版本根本进不了第一层。

多 Agent 两种方式对比

AgentTool CoordinatorMode
默认状态 开启 关闭(实验性,内部专用)
触发 LLM 自己决定调用 专职 Coordinator 主导
定位 普通多 Agent 结构化团队协作,AgentTool 进阶版

日常多 Agent 走 AgentTool,CoordinatorMode 是内部实验功能,对外不可用。


Ch 05 - LLM 流式调用细节

Agent:QueryEngine | queryLoop | State

没有 Agent 类,但是概念存在queryLoop()。运行时只有三层:

实体 跨越范围
Session 层 QueryEngine(类) 跨多次用户输入,持有 messages / 工具列表 / 权限
Loop 层 query()queryLoop()(函数) 单次用户输入,多轮 LLM 调用直到无 tool_use
迭代层 State(数据包) 单轮 LLM 调用结束到下一轮开始之间

State(query.ts:204)是 ReAct 每轮迭代间传递的数据包,不是枚举状态机——字段可同时激活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
工作记忆(每轮必传)
├─ messages 完整对话历史,compact 后缩小,工具结果每轮追加
├─ toolUseContext 工具池、权限回调、agent ID
└─ turnCount 当前第几轮,超 maxTurns → 退出 loop

异常书签(出问题时才激活,正常流程忽略)
├─ maxOutputTokensOverride LLM 输出截断 → 本轮升至 64k 重试,下轮重置 undefined
├─ maxOutputTokensRecoveryCount 升 token 无效 → 注入"请续写"消息重跑,累计到 3 次放弃
├─ hasAttemptedReactiveCompact context 超限 → 触发一次压缩重试,之后置 true 防死循环
├─ stopHookActive Stop Hook 有 blocking error → 携带错误重跑 LLM
├─ autoCompactTracking 主动压缩跟踪数据(已压缩次数、连续失败计数),compact 后重置
└─ pendingToolUseSummary 工具摘要 Promise,下轮开始时 await 并注入 messages

transition(调试用)
└─ 记录"为什么进入这一轮"(next_turn / reactive_compact_retry 等),供测试断言用

异常书签触发场景:

  • maxOutputTokensOverride:stop_reason = max_tokens(LLM 输出被截断)→ 临时升至 64k 重试当前轮,下轮重置。
  • maxOutputTokensRecoveryCount:升 64k 仍不够 → 注入隐藏消息 "Output token limit hit. Resume directly..." 让 LLM 续写,最多 3 次,超过放弃。
  • hasAttemptedReactiveCompact:API 返 prompt_too_long → 压缩历史重试一次,置 true 后再超限直接以 { reason: 'prompt_too_long' } 退出 loop(用户看到提示,程序不卡死)。退出时不跑 Stop Hook,防死循环。
  • stopHookActive:Stop Hook 返回 blocking error(如检测到危险命令)→ 把错误注入 messages 让 LLM 重新回复;标志在重试期间保留,防 compact 清掉。
  • autoCompactTracking:主动压缩连续失败 3 次触发熔断——曾有 session 失败 3272 次、每天浪费 25 万次 API 调用。

Agent Loop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Agent Loop (query.ts)

接收到 AssistantMessage
├─ 推入 assistantMessages[]
├─ block.type === 'tool_use' → 推入 toolUseBlocks[]
└─ 流结束
├─ toolUseBlocks 为空 → loop 结束
└─ 非空 → 进入工具执行


├─ 放入 assistantMessages[] // 本轮 assistant 输出暂存
├─ 若 block.type === 'tool_use'
│ └─ 推入 toolUseBlocks[] // 进入 Ch 06 的"完整工具执行路径"
└─ 继续向外层转发
├─ REPL / UI → 追加到消息列表
└─ SDK / QueryEngine → 输出给调用方 + 写 transcript

本轮结束
├─ toolResults[] 追加进 messages
└─ 调API传messages = [...messagesForQuery, ...assistantMessages, ...toolResults]

Agent Message:AssistantMessage

AssistantMessage 是 LLM => Agent 这一流向的信息层最终包装(当然还有其他流向比如tool和user都是属于UserMessage)。

1
2
3
4
5
claude.ts 拼好的 block,最终大概是这样:
第 1 个 block 收齐 → yield 1 条 AssistantMessage(thinking)
第 2 个 block 收齐 → yield 1 条 AssistantMessage(text)
第 3 个 block 收齐 → yield 1 条 AssistantMessage(tool_use)
当封装成 1 条 AssistantMessage 之后,就 yield 给 query.ts

所以这里的 AssistantMessage 不是”整轮回复最终版”,而是”某一个 block 收齐后的出队结果“。
如果一轮里依次生成了 thinking → text → tool_use,外层就会依次收到 3 条 AssistantMessage,每条里的 message.content 都只放当前这个 block。

外层字段 type / uuid / timestamp / requestId 是 Claude Code 自己补的运行时信息;
里面的 message 基本还是 Anthropic 原生 message 结构,只是 content 此时只装当前这个刚收齐的 block。

AssistantMessage 样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
type: 'assistant',
uuid: 'msg-uuid-1',
timestamp: '2025-...',
requestId: 'req_xxx',
message: {
id: 'api-msg-id',
role: 'assistant',
model: 'claude-opus-4-6-...',
stop_reason: null,
usage: {...},
content: [
{
type: 'tool_use', // 或 text / thinking
id: 'toolu_xxx',
name: 'Read',
input: { ... } // 在 content_block_stop 时已经拼完整
}
]
}
}

这里的 AssistantMessage 还是”模型产出的消息块”;到了 Ch 06,它里面的 tool_use block 才会被当成真正的”待执行工具调用”继续往下处理。

和 nanobot 对比:nanobot 用适配器把 Anthropic 原生格式转成统一 LLMResponse 抹平结构;Claude Code 基本直接沿用原生 message/block 结构,只在外层补本地元数据,并按 block 粒度逐条 yield。

AssistantMessage 组装流程

Block 是什么:Anthropic API 一次回复的 content 是数组,每个元素是一个 block(thinking / text / tool_use),一次回复可同时包含多个 block。SSE 流式时每个 block 分批到达,claude.ts 用 contentBlocks[] 数组逐步拼装,收齐才封包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
经 SDK 3 层处理后到达本处,见「SSE 分层传输机制」

SSE event 到达 claude.ts,准备开始拼装,刚进来样子大概是 {event, content} 这样

event [content_block_start] 新 block 开始,按类型在 contentBlocks[index] 创建空槽等待填充
├─ text → { text: '' }
├─ tool_use → { input: '' }(工具参数是 JSON,后续 delta 逐步拼成完整字符串)
└─ thinking → { thinking: '', signature: '' }(signature 是 API 签发的加密签名)

event [content_block_delta] 增量内容,token by token 追加到对应槽位
├─ text_delta → text += delta.text
├─ input_json_delta → input += delta.partial_json
└─ thinking_delta → thinking += delta.thinking

event [content_block_stop] block 拼装完毕,从槽位取出,封装成 AssistantMessage yield 给 query.ts
本层任务结束
─────────────────────────────
工具执行完成后,结果写回 messages 前做大小检查:

[单条结果]
├─ ≤ 50k chars → 直接放入 messages
└─ > 50k chars → 写磁盘(session/tool-results/{id}.txt)
模型收到:2000字节预览 + 文件路径(<persisted-output> 包裹)
模型可用 FileReadTool 读完整内容

[单轮所有工具结果总量]
├─ ≤ 200k chars → 不处理
└─ > 200k chars → 最大的几条持久化,直到低于阈值

SSE 分层传输机制

A社的 Anthropic API 服务端返回每条消息长这样(event+data):

1
2
3
4
5
6
7
8
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"我来分析"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

SDK 在 claude.ts 和 TCP 之间做了 3 层处理,每层自己做缓冲,不依赖下层,最终确保每一个 block 都是完整的:

1
2
3
4
5
6
7
8
9
10
11
12
13
TCP 字节流(可在任意字节截断)

第1层 iterSSEChunks
字节缓冲区累积,扫到 \n\n 才 yield 一条完整 SSE 块,否则继续等

第2层 SSEDecoder
逐行读:event: 行存类型,data: 行存内容
遇到空行(\n\n 的第二个 \n)→ 攒好的字段组成 {event, data} yield 出去

第3层 fromSSEResponse
按 event 类型白名单过滤 + 反序列化 JSON.parse(data) → yield {type, index, ...}

claude.ts 收到完整事件对象,进入 block 拼装(见「Message 组装」)

每层自己做缓冲保证完整性,不依赖下层。claude.ts 拿到的是事件流,不是字节流,也不是组装好的 block。

与 nanobot 对比

维度 nanobot Claude Code 优劣
文字输出 等整个响应结束才返回 每个 token 立即 yield CC ✅ 体验更好
工具参数传输 响应结束后整体解析 input_json_delta 边收边拼 CC ✅ 更早可用
工具结果超限 直接截断,内容丢失 持久化磁盘,模型可完整读取 CC ✅ 信息不丢失
Thinking 支持 作为字段返回,无流式 thinking block 独立流式累加 CC ✅
复杂度 简单直接 SSE 状态机,多 block 并行 nanobot ✅ 更易理解
多 block 回复 单次响应只有一段内容 一次回复可含 thinking+text+多工具 CC ✅ 表达能力强

Ch 06 - 工具调用系统

Tool 接口设计

定义在 src/Tool.ts

字段 用途 nanobot 有?
name 工具名,LLM 按名字调用
prompt() 生成给 LLM 的工具说明,注入 API 的 tools 字段 ✅ 但静态字符串
description() 生成给用户看的操作描述,用于权限弹窗展示
inputSchema (Zod) 参数结构校验 ✅ JSON Schema
call() 实际执行函数
isConcurrencySafe() 调度层用,决定能否并发执行
isReadOnly() 权限判断,只读命令不需用户确认
isDestructive() UI 警示用,MCP 列表标红,不影响执行
interruptBehavior() Ctrl+C 时 cancel 立刻停 / block 等完成
maxResultSizeChars 结果大小上限,超限持久化磁盘

nanobot 工具接口扁平,调度层不知道工具”性格”,只能串行。Claude Code 工具自己声明行为,调度层据此决定并发还是串行。

完整工具执行路径(LLM 返回 → 结果追回)

[1] SSE 流识别 tool_use(claude.ts)

1
前面已经讲过

[2] query.ts 收集(query.ts)

1
前面已经讲过,存在AssistantMessage 的 block.type === 'tool_use' → 推入 toolUseBlocks[]

[3] 分批调度(toolOrchestration.ts)

1
2
3
4
5
6
runTools(toolUseBlocks)
→ partitionToolCalls() 按 isConcurrencySafe 分批
├─ [Grep, Glob] → 并发(连续 safe,最大并发 10)
├─ [Bash] → 串行(unsafe)
└─ [Grep] → 并发
→ 每批结果 yield → query.ts 收集进 toolResults[]

isConcurrencySafe 由工具自己实现,三种模式:

  • 纯读工具 → 硬编码 true;状态变更工具 → 硬编码 false;未实现 → 默认 false
  • BashTool 特殊:解析实际命令动态判断,git log 为 true,rm 为 false
  • 调度层只调用,不主观判断

[4] 单工具执行(toolExecution.ts)

1
2
3
4
5
6
7
8
9
10
11
12
runToolUse(单个 block)
├─ 按 name 查找工具
├─ Zod 校验参数
├─ PreToolUse hooks → 可修改 input,或 block 执行
├─ canUseTool() 权限判断
│ ├─ isReadOnly() → 静态白名单(git log/grep/ls 等),不弹窗
│ └─ 非只读 → 弹窗让用户确认
├─ denied → 返回 error tool_result
├─ allowed → tool.call() 真正执行
└─ PostToolUse hooks
├─ MCP tool → 可替换 toolOutput
└─ 普通 tool → 追加额外消息

[5] 结果追回(query.ts)

1
toolResults[] 追加进 messages → continue → 下一轮调 LLM

nanobot 对比:无权限系统、无 hooks、无并发调度,工具直接串行 call()。


Ch 07 - 流式执行引擎(默认关闭)

实验性引擎(config.gates.streamingToolExecution默认关闭),LLM 流式返回期间 tool_use block 一到手就立即入队执行,不等流结束。

StreamingToolExecutor 完整流程

1
2
3
4
5
6
7
8
9
10
创建:queryLoop 开始时 new StreamingToolExecutor()

LLM 流式期间:
├─ 收到 tool_use block → addTool() 入队(status = 'queued')
├─ 工具后台并发执行(按并发规则)
└─ query.ts 定期调用 getCompletedResults() 输出已完成的(非阻塞)

LLM 流式结束:
└─ query.ts 调用 getRemainingResults()(阻塞)
└─ 内部循环调用 getCompletedResults() 直到所有工具完成

单个 Tool 状态机

ToolStatus

状态 含义 进入条件 流转到
queued 已入队,等待调度 addTool() 调用 executing
executing 执行中 canExecuteTool() 为 true completed
completed 执行结束,结果就绪 collectResults() 完成(含合成错误) yielded
yielded 结果已消费,终态 getCompletedResults() 输出后

无失败状态——失败工具收到合成 error tool_result 后直接进入 completed

并发规则canExecuteTool):无 executing 工具时任何工具可执行;有 executing 时,新工具和所有 executing 都是 safe 才可并发,否则等待。unsafe 工具独占执行。

工具执行与输出流程

确保流式执行的主要的逻辑在 getCompletedResults 这个方法里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
核心总结:
├─ unsafe 工具独占执行 + 阻塞后续输出(保证执行顺序和安全)
├─ safe 工具可并发 + 按完成顺序输出(可乱序)
└─ 外部拼接 tool 结果时,通过 tool_use_id 将 tool_result 匹配到对应的 tool_use,并不依赖输出顺序

详细流程:
getRemainingResults() 主循环
├─ while (还有未完成的工具)
│ ├─ processQueue() - 按并发规则启动 queued 工具
│ │ └─ for (tool of tools 按接收顺序)
│ │ ├─ if (status !== 'queued') → continue
│ │ ├─ 并发规则检查:
│ │ │ executingTools = 筛选 status === 'executing' 的工具
│ │ │ canExecute = executingTools 为空
│ │ │ || (tool 是 safe && 所有 executingTools 都是 safe)
│ │ ├─ if (canExecute)
│ │ │ ├─ status = 'executing'
│ │ │ └─ 启动 tool.call()
│ │ └─ else if (!tool.isConcurrencySafe)
│ │ └─ break - unsafe 工具等待,阻塞后续调度
│ │
│ ├─ getCompletedResults() - 非阻塞(每次循环都调用)
│ │ └─ for (tool of tools 按接收顺序)
│ │ ├─ 优先输出进度消息(while pendingProgress 非空 → yield)
│ │ ├─ if (status === 'yielded') → continue
│ │ ├─ if (status === 'completed')
│ │ │ ├─ yield tool.results
│ │ │ └─ status = 'yielded'
│ │ └─ else if (status === 'executing' && !tool.isConcurrencySafe)
│ │ └─ break - unsafe 工具执行中,阻塞后续输出
│ │
│ └─ if (有工具在执行 && 没有完成的 && 没有进度)
│ └─ await Promise.race(所有执行中的 promise) - 等待任一完成

└─ 最后再调一次 getCompletedResults() - 输出剩余结果

三层 AbortController 架构

重要:StreamingToolExecutor 由 config.gates.streamingToolExecution 控制,默认关闭。关闭时走 runTools() 路径,只有第 1 层 toolUseContext.abortController,无三层结构。

概念层级与 AbortController 对应(父子单向传播:父 abort → 子自动 abort;子 abort → 父不受影响):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Agent 层(整个对话轮次)
├─ query.ts: queryLoop()
├─ AbortController: toolUseContext.abortController
└─ 生命周期:整个 query 循环
↓ 创建子级

工具编排层(兄弟工具批次:多个工具可能共享一个批次)
├─ StreamingToolExecutor.ts: 构造函数
├─ AbortController: siblingAbortController
└─ 生命周期:单批次工具(单次 assistant message)
↓ 创建子级

单个工具层(单个工具执行)
├─ StreamingToolExecutor.ts: processQueue()
├─ AbortController: toolAbortController
└─ 生命周期:单个工具执行
↓ 传递 signal

Tool 实现(BashTool/GrepTool)
└─ 接收 signal(只读),监听 signal.aborted

为什么分三层

1
2
3
用户 Ctrl+C → 停所有(第 1 层 abort,全链自动 abort)
Bash 出错 → 停兄弟工具,query 继续(第 2 层 abort,第 1 层不受影响)
权限拒绝 → 停整个 query(第 3 层主动冒泡到第 1 层)

Ch 08 - Thinking 推理模式

Thinking Config 与模型支持

定义在 src/utils/thinking.ts,三种类型:

类型 含义 适用
adaptive 模型自决推理量 新模型默认
enabled + budgetTokens 固定 token 预算 旧模型
disabled 关闭 thinking -

模型支持范围

  • thinking:1P/Foundry 全部 Claude 4+;3P(Bedrock/Vertex)仅 Sonnet 4+ / Opus 4+
  • adaptive 模式:仅 opus-4-6 / sonnet-4-6(未知新 1P 模型默认 true,向前兼容)

API 构造:支持 adaptive 的模型发 { type: 'adaptive' };否则发 { budget_tokens: min(maxOutput-1, budgetTokens) }

发送给 LLM 的 Thinking Block 约束(3条)

给LLM发的 API 硬约束(违反返回 400):

  1. 若上下文含 thinking block 的历史,那么本次 budget 必须 > 0
  2. thinking block 不能是 AssistantMessage 的最后一个 block
  3. 有工具调用时,thinking → tool_use → tool_result → 下轮 assistant 整链不能断

签名机制:thinking block 内嵌加密签名,绑定模型 + API Key,不可跨模型/跨账号复用。

清除时机:切换 fallback 模型、/login 换账号 → stripSignatureBlocks() 清除历史 thinking block;streaming 中途重试 → yield tombstone 从 UI 和历史中删除。

服务端保留context-management-2025-06-27 beta,Claude 4+ 且 provider 为 firstParty/Foundry 时启用;Bedrock/Vertex 不触发;compaction 时由服务端保留 thinking block。


Ch 09 - Task 系统

持久化任务看板

不同于 toolUseBlocks[](当轮执行即丢弃),Task 是跨轮次、跨进程的持久化协调机制。核心用途:LLM 将复杂请求拆解为多条 Task,由多个 subAgent 自主认领并行执行。

1
2
3
4
5
6
7
8
9
Task 数据结构(src/utils/tasks.ts)
├─ id 自增数字(文件名即 ID)
├─ subject 任务标题
├─ description 详细说明(跨 Agent 传递上下文的唯一载体)
├─ status pending → in_progress → completed
├─ owner 认领它的 agent ID
├─ blocks 本任务阻塞的其他任务 ID[]
├─ blockedBy 阻塞本任务的任务 ID[](DAG 依赖图)
└─ metadata 任意附加数据

High Water Mark:max(文件最大ID, 历史记录ID),防止 reset 后 ID 复用。

taskListId 优先级

存储:~/.claude/tasks/<taskListId>/,每条 Task 一个 JSON 文件,session 结束不清理。taskListId 对 LLM 透明,框架在每次工具调用时内部静默解析。

1
2
3
4
5
6
优先级(高→低)
├─ CLAUDE_CODE_TASK_LIST_ID 环境变量 外部强制指定
├─ in-process teammate 上下文 同进程 teammate 用 leader teamName
├─ CLAUDE_CODE_TEAM_NAME 环境变量 进程外 teammate(tmux/iTerm2 注入)
├─ leaderTeamName(内存变量) leader 调 TeamCreate 时设置
└─ sessionId 兜底,单 Agent 独立隔离

两级锁保证任务原子性

锁完全靠磁盘竞争实现,内存中无锁变量。proper-lockfile 在目标文件旁创建 .lock 文件,OS 保证创建原子性,进程崩溃后有 stale lock 检测(检查 mtime)。

锁粒度 锁的目标文件 用于
任务级锁 <id>.json(旁边创建 <id>.json.lock updateTask / 普通认领
列表级锁 .lock(tasks 目录下预建空文件) createTask / resetTask / 严格认领

失败时指数退避重试,最多 30 次:

1
2
3
等待时间 = min(5 × 2^n, 100) ms
第1次 10ms → 第2次 20ms → … → 第5次起 100ms(触顶)
总上限 ≈ 2.6s,目标:支撑 10+ 并发 swarm Agent

Agent 领取任务执行流程

涉及方法:

方法 工具 作用
createTask() TaskCreateTool 创建,初始 pending,blocks/blockedBy 为空
listTasks() TaskListTool 列出所有 Task(无锁读)
getTask() TaskGetTool 读取单条完整 Task
updateTask() TaskUpdateTool 更新字段(status/owner/description 等)| 内部可调 deleteTask 删除
blockTask() TaskUpdateTool(addBlocks) 双向写 blocks/blockedBy 建立依赖
claimTask() - tasks.ts 内部函数,swarm 严格认领用,含原子 checkAgentBusy;TaskUpdateTool 日常直接用 updateTask 不走此路径

以 Task #2 依赖 Task #1 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
LLM 拆解请求 → 决定创建两个串行 Task

TaskCreateTool × 2
├─ createTask() → 获列表锁 → 写 ~/.claude/tasks/<id>/1.json { status:pending, blockedBy:[] }
└─ createTask() → 获列表锁 → 写 2.json { status:pending, blockedBy:["1"] }

TaskUpdateTool → blockTask() → 双向写 1.blocks=["2"], 2.blockedBy=["1"]

AgentTool 派出 Agent A、Agent B 并发启动

├─ Agent A
│ TaskListTool → listTasks()(无锁读所有 .json)
│ 找到 Task #1(pending,blockedBy 空)
│ TaskUpdateTool → updateTask() → 获任务级锁 → 写 in_progress + owner=A
│ [执行工作:toolUseBlocks 多轮 Read/Grep/Edit/Bash]
│ 结果写入文件(FileWriteTool)
│ TaskUpdateTool → updateTask() → 写 completed

└─ Agent B
TaskListTool → listTasks()
找到 Task #2,blockedBy=["1"],Task #1 未 completed → 阻断,轮询等待
Task #1 completed 后:
TaskUpdateTool → updateTask() → 获任务级锁 → 写 in_progress + owner=B
TaskGetTool → getTask("2") → 读 description(含 A 写的文件路径)
Read(文件路径) → 拿到 Task #1 输出
[执行工作]
TaskUpdateTool → updateTask() → 写 completed

框架只查验 blockedBy,不传递结果:跨 Agent 输出靠 description 里约定路径 + 主动读文件,语义层全托管给 LLM。


Ch 10 - Skill 系统

Skill 是 Markdown 模板 + 执行器。用户输入 /simplify,框架把模板展开成 ContentBlockParam[],以 role: user 注入对话——等价于用户手动粘贴一段长 prompt。代码类型是 Command.type === 'prompt'PromptCommand),关键字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
PromptCommand 关键字段
├─ name 斜杠命令名(/simplify → name: 'simplify')
├─ description typeahead 显示文字
├─ source 来源:bundled / userSettings / projectSettings / mcp / plugin
├─ loadedFrom 加载方式:bundled / skills / mcp / plugin / managed
├─ allowedTools 此 skill 额外授权的工具列表
├─ context 执行模式:'inline'(默认)/ 'fork'
├─ agent fork 时使用的subAgent 类型(默认 'general-purpose')
├─ model 指定模型,不填继承当前会话
├─ userInvocable false → 仅模型可调用,用户 /skill 报错
├─ hooks skill 激活时挂载的 hooks
├─ effort 传给subAgent 的 effort 级别
└─ getPromptForCommand(args, ctx) → ContentBlockParam[]

/simplify 触发后往 messages 插 3 条:标题行(用户可见)、Skill 正文(isMeta: true,仅 LLM 可见)、权限包(allowedTools / model)。

三种来源loadedFrom):bundled(编译进二进制,启动注册)、disk(.claude/skills/ Markdown 文件)、mcp(stub,跳过)。disk skill 三层优先级:policy > user(~/.claude/skills/)> project(.claude/skills/)。

SkillTool 是模型主动触发的路径:LLM 像调工具一样 { name: 'Skill', input: { name: 'simplify' } },走 fork subAgent,结果以 tool_result 返回。user-invocable: false 的 Skill 只走此路径,用户直接 /skill 报错。

nanobot Claude Code
可复用工作流 Skill 系统
subAgent 触发 手动构造 fork 自动 / SkillTool
模型主动触发 不支持 SkillTool

Skill 自定义(disk skill)

Markdown 文件放入 .claude/skills/ 即可,文件名即命令名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
---
description: 代码审查工具 # typeahead 显示,必填
allowed-tools: Bash, Read, Grep # 额外授权工具
argument-hint: <PR号> # 输入占位提示
when_to_use: PR 合并前运行 # 告诉模型何时自动触发
context: fork # inline(默认)/ fork
model: claude-opus-4-6 # 不填则继承当前会话
user-invocable: true # false → 仅模型可调用
---

# 正文是给 LLM 的指令

你的任务是审查 $ARGS 这个 PR...

!`git log --oneline -10` # 内联 shell,加载时执行并内联进 prompt

变量替换:$ARGS / $1 $2 …(用户参数)、${CLAUDE_SKILL_DIR}(skill 所在目录)、${CLAUDE_SESSION_ID}(当前 session)。内联 shell MCP skill 禁用。

执行模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用户输入 /simplify

processSlashCommand() 找到 Command(type: 'prompt')

├─ 分支1 context: 'inline'(默认)
│ ↓
│ getPromptForCommand(args) → ContentBlockParam[]
│ ↓
│ 插入 messages(标题 + 正文 + 权限包)
│ ↓
│ shouldQuery: true → 进入正常 queryLoop

└─ 分支2 context: 'fork' 在注册skill时就配置好用fork或者前端显式调用

prepareForkedCommandContext() 准备subAgent 上下文

runAgent() → 独立 queryLoop(独立 context + token budget)

extractResultText() → 只取最后一条 AssistantMessage 文本
(中间所有 tool_use / tool_result / thinking 全丢)

包进 <scheduled-task-result>

isMeta 消息重进主会话队列,主 LLM 下一轮可见

Ch 11 - Compact:对话压缩

state.messages 随对话积累,发给 API 前经 getMessagesAfterCompactBoundary() 从最后一个 compact boundary 切片,控制实际 token 量。超限时触发压缩,把旧历史替换为摘要 + boundary marker,之后 LLM 只看到摘要和近期对话。

四层压缩结构

第1层 Microcompaction - 每轮 API 调用前静默运行,不生成摘要

公开版:时间触发路径仍运行;ant-only 路径和 legacy 路径已移除。

1
2
3
4
5
6
7
8
9
时间触发(距上次 assistant 消息超阈值,cache 已冷)
└─ 把旧工具结果替换为占位符 [Old tool result content cleared]
保留最近 N 条不动,直接修改 messages 内容

计数触发(ant-only cached 路径)
└─ 用 cache_edits API 从服务端删除旧工具结果
不修改本地 messages,只改服务端 cache,不失效 prompt cache

公开版:legacy path 已移除(源码注释明确),直接 return { messages },什么都不做

第2-3层 Autocompact - token 超阈值时触发,生成摘要

触发阈值(以 200k 模型为例):

水位线:

1
2
3
147k warningThreshold     ← 显示用量警告
167k autoCompactThreshold ← 触发 Autocompact
177k blockingLimit ← 阻断新轮次,须手动 /compact

推导来源(均基于 effectiveContextWindow = 200k 总值 - 20k 回复预留 = 180k):

1
2
3
4
180k - 20k(WARNING_THRESHOLD_BUFFER_TOKENS) = 160k(Autocompact 关时)
147k(Autocompact 开时,基准为 167k)
180k - 13k(AUTOCOMPACT_BUFFER_TOKENS) = 167k
180k - 3k(MANUAL_COMPACT_BUFFER_TOKENS) = 177k

为什么减 20k:200k context_window 是读写共用总额度(input_tokens + max_tokens ≤ 200k),摘要调用需预留 20k 给输出,故 input 上限收缩至 180k。

正常流程:147k warningThreshold 触发警告 → 167k Autocompact 介入 → 连续失败 3 次熔断(MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES)→ 之后跳过压缩直到撞 177k blockingLimit → 退出 loop,须手动 /compact

触发方式:自动(token 用量超阈值)或手动 /compact(可附自定义摘要指令)。

压缩路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
├─ 先试 Session Memory Compaction
│ └─ 直接读 Session Memory 文件组装摘要,不调 LLM,最快
│ ❌ feature flag 默认 false,公开版跳过
└─ Traditional Compaction(公开版实际走这里)
├─ ① PreCompact hooks
├─ ② fork subAgent 调 LLM 生成摘要
│ └─ 摘要请求本身撞 prompt_too_long
│ → 丢最老 API round 重试,最多 3 次(MAX_PTL_RETRIES)
│ API round = 一次 LLM 调用的 [assistant回复 + tool_results]
├─ ③ snapshot readFileState → clear()
│ readFileState:LRU Map(path → content/timestamp),FileReadTool 读文件后写入
│ compact 后历史变了,旧缓存失效故清空;先 snapshot 供后续恢复
├─ ④ 并行准备 post-compact 附件
│ ├─ 最近读过的文件(≤5个):从 snapshot 取路径重新读,防 LLM"失忆"
│ ├─ plan / plan mode 附件:恢复 /plan 状态
│ └─ deferred tools / MCP instructions:重新声明被压缩吃掉的工具列表
├─ ⑤ 创建 boundaryMarker(SystemCompactBoundaryMessage)+ summaryMessages(UserMessage)
│ summaryMessages:isCompactSummary=true,isVisibleInTranscriptOnly=true(REPL 不显示)
│ 内容:"This session is being continued...\nSummary:\n[摘要]\n\ntranscript 路径"
├─ ⑥ SessionStart hooks 重跑 / PostCompact hooks
└─ ⑦ 返回 CompactionResult

第4层 Reactive Compaction - API 真正返回 413 后触发

1
2
3
4
5
6
7
8
LLM 返回 prompt_too_long(HTTP 413)
├─ 先尝试 context_collapse drain(❌ feature flag,公开版关)
└─ tryReactiveCompact()
├─ hasAttemptedReactiveCompact = false → 执行压缩 → 重试当前轮
└─ hasAttemptedReactiveCompact = true → return { reason: 'prompt_too_long' }

只试 1 次
❌ feature('REACTIVE_COMPACT') = false,公开版整层 stub

压缩后结构

buildPostCompactMessages()CompactionResult 组装成新的 state.messages

1
2
3
4
5
[boundaryMarker]     type='system',getMessagesAfterCompactBoundary 的切片锚点
[summaryMessages] type='user',LLM 摘要
[messagesToKeep] 近期保留原始消息(Session Memory 路径才有)
[attachments] 文件内容 / plan / tools 声明
[hookResults] SessionStart hooks 重跑的结果

下一轮 normalizeMessagesForAPI 过滤后,LLM 实际收到

1
2
3
4
5
{ role: 'user', content: 'This session is being continued...\nSummary:\n[摘要]' }
{ role: 'user', content: [...文件内容...] }
{ role: 'user', content: [...tools delta...] }
... messagesToKeep 里的原始 user/assistant 消息 ...
{ role: 'user', content: '新的用户输入' }

boundary marker(type=’system’)被过滤掉,LLM 完全看不到,只感知到对话从一条摘要 user 消息开始。

完整对话历史同时实时 append 到磁盘 transcript 文件(~/.claude/projects/<项目>/<sessionId>.jsonl),compact 不删除它。summaryMessage 里注明了路径,LLM 需要时可主动读取找回压缩前细节。

1
2
state.messages    内存,compact 后替换为摘要
transcript.jsonl 磁盘,只 append,压缩不影响

Ch 12 - 记忆系统

不管怎么样,记忆系统想做到的永远是跨Session的上下文保存。

三种记忆共同点:用文件存储,靠 prompt 注入读取。写的触发方式各不同——Auto Memory 和 Agent Memory 靠 LLM 自主决定,Session Memory 靠框架 PostSampling hook 自动提取;读的筛选由 Sonnet 做语义匹配,框架负责注入。

三种记忆对比

Auto Memory Session Memory Agent Memory
作用域 一个 git repo 一次会话 user / project / local
存储 ~/.claude/projects/<repo>/memory/ ~/.claude/session-memory/<sessionId>.md user: ~/.claude/agent-memory/<type>/ project: .claude/agent-memory/<type>/ local: .claude/agent-memory-local/<type>/
记什么 关于用户和项目的知识(偏好、约定、外部系统) 当前会话关键进展(纯笔记,无 frontmatter) 该类型 Agent 执行任务中积累的专属经验
LLM 自主用 Write 工具 PostSampling hook 自动 fork subAgent 提取 subAgent 自主用 Write 工具
每轮 Sonnet 筛选注入(≤5 个) Compact 时 Session Memory Compaction 路径读取 Agent spawn 时一次性注入系统 prompt
公开版

Auto Memory 和 Agent Memory 用同一套文件格式(frontmatter + 正文),共用 buildMemoryLines prompt 模板;内容视角不同——前者记”关于用户/项目的知识”,后者记”这类 Agent 执行任务时学到的东西”。

触发流程

① 会话启动(一次性)

1
2
3
4
5
6
7
8
CLAUDE.md 文件加载 → getUserContext()
Managed → User → Project/Local(从根到 CWD)→ AutoMem(MEMORY.md)
└─ 注入 userContext,以第一条 user 消息固定在对话头部,全程不变

Agent Memory Snapshot 检查(仅 user scope agent,loadAgentsDir.ts)
├─ action: 'none' → 跳过
├─ action: 'initialize' → 从 snapshot 覆盖复制初始化
└─ action: 'prompt-update' → 仅提示,不自动覆盖

② 每次用户输入(每轮开始)

1
2
3
4
5
6
startRelevantMemoryPrefetch() 异步发起(不阻塞)
└─ Sonnet 扫 memory 目录所有文件的 filename + description
选 ≤5 个相关文件;已展示过的排除;单词输入跳过
结果挂在 pendingMemoryPrefetch 等待消费

MEMORY.md 索引已在 userContext,无需额外处理

③ 工具执行循环中(每轮内部)

1
2
3
4
5
6
7
8
pendingMemoryPrefetch settled?
├─ 是 → filterDuplicateMemoryAttachments(过滤 readFileState 中已读文件)
│ → 注入为 attachment 消息,LLM 下一个 iteration 可见
└─ 否 → 跳过

Auto Memory 写(LLM 自主)
└─ Write 工具 → ~/.claude/projects/<repo>/memory/<topic>.md
同步在 MEMORY.md 加一行索引(两步缺一不可)

④ 每次 LLM 响应后(PostSampling hook)

1
2
3
4
5
6
extractSessionMemory()(❌ 公开版 feature flag 关)
shouldExtractMemory() 判断(同时满足 token 增量阈值,再看下面任一):
├─ 工具调用数 >= 阈值 → 触发
└─ 上一轮无工具调用(自然断点) → 触发

触发 → fork subAgent 读 session 文件 → 提取关键信息 → 写回

⑤ subAgent spawn 时

1
2
3
4
5
loadAgentMemoryPrompt(agentType, scope)(同步,在 getSystemPrompt 回调里)
└─ 读 <agentMemoryDir>/MEMORY.md + 目录内所有文件
注入系统 prompt,spawn 后固定不变

Agent Memory 写(subAgent 自主,同 Auto Memory 写法)

四类文件格式

Auto Memory 和 Agent Memory 均使用相同格式。两步保存:① 写 .md 文件 ② 在 MEMORY.md 加索引;MEMORY.md 超 200 行截断,每条约 150 字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# MEMORY.md(索引,始终加载)
- [用户背景](user_background.md) - 后端出身,React 新手
- [回复风格](feedback_response.md) - 不加末尾总结
- [合并冻结](project_freeze.md) - 冻结至 2026-04-10
- [Bug 追踪](reference_bugs.md) - Linear INGEST 项目

# user_background.md
---
name: 用户技术背景
description: 用户后端出身,React 新手
type: user
---
深度 Go 经验,首次接触 React。解释前端概念时用后端类比。

# feedback_response.md
---
name: 回复风格
description: 不在回复末尾加总结
type: feedback
---
不加总结。Why: 用户说"我能看懂 diff"。How to apply: 任何回复均不加总结段落。

# project_freeze.md
---
name: 合并冻结
description: 冻结非关键合并至 2026-04-10
type: project
---
冻结至 2026-04-10。Why: 移动端切 release 分支。How to apply: 建议合并 PR 时先确认是否关键。

# reference_bugs.md
---
name: Bug 追踪位置
description: pipeline bug 在 Linear INGEST 项目追踪
type: reference
---
Linear 项目 "INGEST" 追踪所有 pipeline bug。

使用前验证:memory 里提到的文件/函数可能已不存在,先 grep/check 再引用。

Snapshot 机制

团队协作用途:将预置的 Agent 记忆提交到 git,新成员 clone 后 spawn 该类 Agent 时自动初始化,无需手动配置。

1
2
3
4
5
6
7
8
9
10
11
# 团队在 git 里提交预置记忆
.claude/agent-memory-snapshots/<agentType>/snapshot.json ← 版本戳
.claude/agent-memory-snapshots/<agentType>/*.md ← 预置记忆文件

# Agent spawn 时 checkAgentMemorySnapshot()
├─ action: 'none' 本地已是最新,跳过
├─ action: 'initialize' 本地无记忆 + snapshot 存在 → 覆盖复制到本地
└─ action: 'prompt-update' snapshot 比本地新 → 仅提示,不自动覆盖

# 本地同步标记(不提交 git)
.snapshot-synced.json → { "syncedFrom": "2026-04-01T10:00:00Z" }

replaceFromSnapshot 是覆盖不是合并——先删光本地所有 .md 再复制,所以 prompt-update 不自动执行,避免覆盖成员自己积累的记忆。


Ch 13 - 上下文管理

每次调用 LLM,Claude Code 把上下文分成三个区域组装进请求——system 字段、首条特殊消息、对话历史——不同类型的上下文注入位置不同。

完整请求结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
── API Request ──────────────────────────────────

[system]
① attribution header 版本指纹,x-anthropic-billing-header 开头
② CLI sysprompt prefix 身份声明,三选一:
交互模式 → "You are Claude Code…"
非交互 + 有外部 SP → "…running within Agent SDK"
非交互 + 无外部 SP → "You are a Claude agent…"
③ ...systemPrompt 调用方传入的主体(已含 systemContext)
④ ADVISOR_INSTRUCTIONS 按需追加(advisor 模式)
⑤ CHROME_INSTRUCTIONS 按需追加(tool search + Chrome 工具)

[messages[0]] 仅含特殊消息,prependUserContext() 注入
⑥ userContext,<system-reminder> 包裹
├─ claudeMd 所有层级 CLAUDE.md 拼合
└─ currentDate 今天的日期

[messages[1..N]] 对话历史 (1 = 最新一条)
⑦ 历史消息(user/assistant 轮次)
⑧ 工具结果(tool_result block)
⑨ deferred tools 列表(可选,tool search 模式)

─────────────────────────────────────────────────

上面提到的systemContext 和 userContext:

systemContext → appendSystemContext() → system 字段末尾
└─ gitStatus 当前分支、主分支、git user、文件状态、最近5条 commit(会话开始快照,全程不变)

userContext → prependUserContext() → messages[0] <system-reminder>
├─ claudeMd 所有层级 CLAUDE.md 拼合
└─ currentDate "Today's date is 2026-04-05."

CLAUDE.md 加载机制

getMemoryFiles() 按优先级从低到高依次加载,越靠后加载的文件 LLM 越关注:

1
2
3
4
5
6
① Managed   /etc/claude-code/CLAUDE.md + .claude/rules/*.md   企业统一策略
② User ~/.claude/CLAUDE.md + ~/.claude/rules/*.md 用户全局配置
③ Project CLAUDE.md / .claude/CLAUDE.md + .claude/rules/*.md 项目规范,检入 git
④ Local CLAUDE.local.md 个人项目配置,不检入 git

Project / Local 从 git root 向 CWD 逐层发现,CWD 更近的优先级更高

这里的 User / Project / Local 和 Ch 12 Agent Memory 的 scope 是同一套作用域模型:User 全局、Project 团队共享、Local 个人私有。

区别是 CLAUDE.md 是开发者预先写好的静态指令,框架没有任何机制引导 LLM 自动写入(对比 Auto Memory 有完整的自动写入路径)。

@include 指令:文件内用 @path 引用其他文件,被引用文件插入到引用文件之前。代码块内无效,循环引用有保护,支持相对/绝对/~/ 路径。

getClaudeMds() 把所有文件拼合,格式为 Contents of <path> (<类型描述>):\n\n<内容>,整体注入 userContext.claudeMd

工具权限上下文

ToolPermissionContext 挂在 AppState 上,把所有来源的权限配置汇总成一个快照,工具调用前统一查这里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ToolPermissionContext
├─ mode 全局权限模式
│ ├─ default 敏感操作弹窗询问
│ ├─ acceptEdits 自动接受文件编辑
│ ├─ bypassPermissions 跳过所有检查(--dangerously-skip-permissions)
│ ├─ dontAsk 自动允许,不弹窗
│ ├─ plan 只读,/plan 命令进入,退出时从 prePlanMode 恢复
│ └─ bubble subAgent 向上冒泡权限请求

├─ alwaysAllowRules "永远允许"规则集,按来源分桶存储
├─ alwaysDenyRules "永远拒绝"规则集,同结构
├─ alwaysAskRules "强制询问"规则集,同结构

│ 结构:{ [source]: string[] },例:
│ alwaysAllowRules = {
│ session: ["Bash(git *)"], ← 对话中点"永远允许"
│ projectSettings: ["Write(src/*)"], ← .claude/settings.json
│ }
│ source: userSettings / projectSettings / localSettings /
│ policySettings / flagSettings / cliArg / command / session

├─ additionalWorkingDirectories 允许操作的额外目录
└─ shouldAvoidPermissionPrompts true 时自动拒绝弹窗(后台 Agent 用)

对话中点「永远允许」→ 往 alwaysAllowRules.session 追加;/plan 进入只读模式,原 mode 存入 prePlanMode 退出时恢复。

subAgent 的上下文裁剪与隔离

AgentTool spawn subAgent 时,对上下文做一系列裁剪和隔离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
父 Agent 上下文

① userContext 处理
├─ 只读标志位omitClaudeMd=true 的 Agent(当前只有 Explore / Plan)
│ └─ 去掉 claudeMd,省 ~5-15 Gtok/周(34M+ 次 spawn)
└─ 其他 Agent → 完整传递

② systemContext 处理
├─ Explore / Plan 类型的 Agent
│ └─ 去掉 gitStatus(过期快照,需要时自己跑 git status)
└─ 其他 Agent → 完整传递

③ 权限模式隔离
├─ Agent 定义了专属权限模式?
│ ├─ 父级是 bypassPermissions / acceptEdits → 父级优先,不覆盖
│ └─ 其他 → 用 Agent 定义的模式覆盖
└─ 后台异步 Agent → 自动禁止弹权限窗(无 UI)
后台异步但可弹窗 → 等自动检查完再弹(避免无谓打扰用户)

④ allowedTools 范围限制(有传入时)
└─ 清掉父 Agent 会话级规则,换成传入的工具白名单
保留 cliArg 规则(SDK 显式授权,应对所有subAgent 生效)

⑤ AbortController
├─ 同步 Agent → 共享父 AbortController(父取消子也取消)
└─ 异步 Agent → 独立 AbortController(独立运行)

Ch 14 - Hooks 扩展

Hook 是什么:在约定时机启动子进程,主流程把上下文序列化成 JSON 传入,读取子进程输出决定是否继续——本质是”可执行文件 + stdin/stdout 协议”的旁路回调机制。CC 扩展了一种 prompt hook:触发时机相同,但执行体换成自然语言条件,$ARGUMENTS 替换为工具调用 JSON 后整体作为 UserMessage 发给 Haiku,返回 {ok: true/false} 决定放行或阻断。

来源:settings.json(用户配置)/ Agent·Skill frontmatter(subAgent 启动时注册)/ FunctionHook(CC 内部 TS 回调,不走 shell,session 级临时)

执行器:command(子进程)/ prompt(Haiku)/ agent(subAgent)/ http(外部服务)

事件 × 返回值

事件挂在工具调用前后(PreToolUse / PostToolUse / PostToolUseFailure)、会话生命周期(SessionStart / Stop)、subAgent 生命周期(SubagentStart / SubagentStop)、权限(PermissionRequest / PermissionDenied)、文件变更、MCP 交互等节点。

返回 JSON 控制主流程:{decision:"block"} 阻断工具 / {continue:false} 终止 Agent / {async:true} 不阻塞后台跑 / hookSpecificOutput 可修改入参或 MCP 输出。**approve 不能绕过 settings.json deny 规则。**

与MAS 交叉部分:subAgent 的 Stop frontmatter hook 自动转为 SubagentStop,可在subAgent 结束后做验证。


Ch 15 - 沙盒与权限系统

Agent 为什么比普通程序更需要沙盒

普通程序攻击面固定可静态分析;Agent 多了一条链路:提示注入 → LLM 行为改变 → 工具产生真实副作用。读一个恶意文件就能触发 rm -rf,这在普通程序里不存在。

三个根源:LLM 输出不可预测 / 工具有真实副作用 / 任意外部内容都能变成”指令”。

权限系统可被说服绕过,沙盒是不可绕过的 OS 级硬边界。

沙盒的两个核心维度与安全兜底

Agent 能造成破坏的只有两条路:写坏文件 或 发出网络请求。

文件系统:进程启动时重新挂载,allowWrite / denyWrite / denyRead / allowRead 四向控制,deny 永远优先。

网络:默认拒绝所有出站,allowedDomains + WebFetch(domain:x) 权限规则合并成白名单。

CC 只做规则收集,实际执行由 @anthropic-ai/sandbox-runtime 负责。

安全兜底:

  • 硬编码 denyWrite:settings.json / .claude/skills/ / bare git repo 特征文件写死只读,防止 Agent 改权限配置或植入恶意 skill
  • 命令后清理core.fsmonitor 是 git 的合法 hook,git 运行时自动执行并继承 CC 权限,可被提示注入利用。对策:启动时记录 cwd 中不存在的特征文件,沙盒命令结束后立刻删除新增的,在 unsandboxed git 运行前关掉时间窗口
  • failIfUnavailable:沙盒启不来默认静默降级——安全系统失败要吵闹不能静默
  • autoAllowBashIfSandboxed:沙盒启用时跳过 Bash 权限弹窗,OS 已在执行,再弹窗是多余摩擦

权限系统 vs 沙盒:两层防御

沙盒的 OS 实现:限制进程系统调用的内核机制。macOS 用 sandbox-exec,Linux 用 bwrap + socat,Windows 不支持。CC 底层交给闭源的 @anthropic-ai/sandbox-runtime,adapter 只做配置转换。

两层的本质区别:权限系统是 CC 应用层软拦截(可被社会工程学绕过——LLM 说服用户在弹窗点允许);沙盒是 OS 层硬拦截,启动后固化在内核,运行时无法修改。

权限系统的执行形态:工具调用触发时 CC 弹出 3 选 1 交互:Yes(放行一次)/ Yes, during this session(本 session 内全部放行)/ No(拒绝)。用户确认后产生一条 allow 规则,存入五个 destination 之一:

1
2
3
4
5
├─ session         → 纯内存,session 结束消失
├─ localSettings → 写入 .claude/settings.local.json(gitignored)
├─ projectSettings → 写入项目 .claude/settings.json
├─ userSettings → 写入 ~/.claude/settings.json(全局)
└─ cliArg → 来自命令行参数,只读

两层联动:权限规则自动同步进沙盒——Edit(/src/**)allowWriteWebFetch(domain:x) → 网络白名单。一套规则,两层执行。

默认关闭sandbox.enabled ?? false):开启需在 settings.json 加 "sandbox": {"enabled": true},仅支持 macOS / Linux / WSL2。

自建 MAS 沙盒的实现路径

CC 用的方案:Linux/WSL2 用 bubblewrap(bwrap)+ socat,macOS 用 sandbox-exec,Windows 不支持。

MAS 特有的问题:沙盒粒度

单 Agent 沙盒粒度清晰(一个进程一个沙盒),MAS 里需要额外决定:

1
2
3
4
5
6
7
├─ 每个subAgent 独立沙盒
│ 优点:隔离彻底,一个 Agent 被攻击不影响其他
│ 缺点:Agent 间共享文件需要跨沙盒通信,复杂度高

└─ 共享沙盒 + 应用层权限分层
优点:Agent 间通信简单
缺点:一个 Agent 逃逸可能影响整个沙盒

CC 的选择:共享沙盒。subAgent 运行在父 Agent 的沙盒内部(enableWeakerNestedSandbox 配置项印证嵌套沙盒存在),AgentTool 启动subAgent 是同进程内调用,不创建新沙盒实例。subAgent 的隔离靠应用层权限上下文,不是 OS 层。


Ch 16 - MAS 转向 Harness Engineering

CC 用 bun:bundlefeature() 在构建时判断 flag,为 false 的代码直接从 bundle 里删掉。对外发布版根本不含这些代码,不是运行时 if/else。以下特性均默认关闭,仅内部版本可用。

Agent 分工的三个维度

协调模式(多 Agent 如何分工)

1
2
3
4
├─ 单 Agent 顺序执行   → 默认 REPL
├─ 主从委托 → AgentTool 基础用法
├─ Coordinator/Worker → COORDINATOR_MODE(默认关闭)
└─ 对等并行 → Swarm tmux(默认关闭)

执行机制(Agent 怎么跑)

1
2
3
├─ 同进程同步   → AgentTool 默认
├─ 同进程异步 → AgentTool async 模式
└─ 独立进程 → Swarm tmux

Agent 特化(角色是什么)

1
2
3
4
5
6
7
├─ 通用          → general-purpose
├─ 探索专用 → explore(feature-gated)
├─ 规划专用 → plan(feature-gated)
├─ 验证专用 → verification(feature-gated)
├─ 调度者 → Coordinator(COORDINATOR_MODE)
├─ 执行者 → Worker(COORDINATOR_MODE)
└─ 自定义 → .claude/agents/ 目录加载

Coordinator / Worker 详解

是什么:把”思考调度”和”动手执行”拆给两个角色,防止同一 Agent 上下文被执行细节淹没。

能力边界是物理强制的,不靠 prompt 约定

  • Coordinator:无执行工具(无 Bash/Edit/Read),只有 AgentTool / SendMessageTool / TaskStopTool,物理上无法直接操作
  • Worker:无全局对话上下文,只收到一个自包含任务,无法影响整体方向

实现方式:同一二进制,CLAUDE_CODE_COORDINATOR_MODE=1 切换角色,注入不同 system prompt + 工具集。

执行流程

1
2
3
4
用户 → Coordinator 拆解任务,并行派 Worker
Worker 异步执行,结果以 <task-notification> XML 注入为"用户消息"
Coordinator 读取、自己综合(不能转包理解),决定 Continue 或 Spawn 新 Worker
所有任务完成 → Coordinator 汇总告知用户

Continue vs Spawn:本质是”Worker 的上下文值不值得复用”

1
2
3
4
探索文件 = 要修改文件     →  Continue
探索范围广但实现窄 → Spawn(避免噪音)
验证另一个 Worker 的代码 → Spawn(需要全新视角)
失败重试 → Continue(错误上下文有用)

并发规则:读操作随意并行 / 写操作同组文件同时只跑一个 Worker / 验证可与不同文件区域的实现并行。

MAS 启示:调度者不执行是约束不是建议;Worker 结果异步事件驱动不是同步调用;综合是 Coordinator 唯一不可外包的职责;上下文是资源,Continue/Spawn 是复用决策。

Swarm:tmux 多进程并行

我认为这是Harness Engineering的方向。

创建专属 tmux session(claude-swarm),每个 Agent 对应一个 tmux pane,tmux split-window 切分,每个 pane 是独立 Claude 进程。pane 颜色不同便于区分,创建时有互斥锁防并发竞争,shell 初始化后等 200ms 再启动 Agent。Agent 间通过 SendMessageTool 通信。

KAIROS:事件驱动唤醒

Agent 不被用户主动触发,而是订阅事件后自动唤醒。CC 里两种实现:

  • PR 事件:Coordinator 调用 subscribe_pr_activity,GitHub PR 评论 / CI 结果以”用户消息”形式注入对话
  • 时间门控(autoDream):满足”距上次执行超过 N 小时 + 积累 N 个 session”自动触发后台记忆整合

Ch 17 - MCP 集成

MCP(Model Context Protocol) 是 Anthropic 主导的开放协议,规范 LLM 与外部工具/数据源之间的通信格式。CC 是纯 MCP Client,不托管 Server。

传输层

6 种传输类型,按使用场景分:

1
2
3
4
5
6
├─ stdio      本地进程,CC spawn 常驻子进程,管道通信,90% 场景
├─ sse 远程 HTTP,Server-Sent Events 单向推送
├─ sse-ide IDE 插件专用 SSE 变体
├─ http StreamableHTTP,新标准,支持双向流
├─ ws WebSocket,双向实时
└─ sdk 进程内直接引用,测试/嵌入

stdio 本质:CC 用 child_process spawn 子进程(如 npx some-mcp-server),往其 stdin 写 JSON 请求,从 stdout 读响应,会话结束才关闭——常驻进程,不是跑完退出的命令。

通信格式

底层协议 JSON-RPC 2.0(所有传输层通用)。工具命名规则 mcp__{serverName}__{toolName}(双下划线),server name 经 normalizeNameForMCP() 净化(非 [a-zA-Z0-9_-] 字符转 _)。工具描述上限 2048 字符,超出截断(OpenAPI 生成的 server 常有 60KB 描述)。

调用示例(CC → github server 的 list_repos):

1
2
3
4
请求 CC → stdin:   { "jsonrpc":"2.0", "id":1, "method":"tools/call",
"params": { "name":"list_repos", "arguments":{"org":"anthropics"} } }
响应 stdout → CC: { "jsonrpc":"2.0", "id":1,
"result": { "content":[{"type":"text","text":"[...]"}], "isError":false } }

CC 内部用 mcp__github__list_repos 路由,但下发给 server 时用原始名 list_repos,LLM 对来源无感知。

工具注册与合并

启动时 CC 并发连接所有配置 server,各 server 执行 ListTools 后封装为 MCPTool 注入工具池,与内置 Bash/Write 同级,LLM 感知不到来源差异

配置来源(ConfigScope 6 个):

1
2
3
4
5
6
├─ local       项目本地(不提交 git)
├─ project 项目共享(提交 git,团队共用)
├─ user 用户全局
├─ enterprise 企业下发
├─ claudeai claude.ai 云端
└─ managed 程序动态注入

连接状态:pending → connected / failed / needs-auth / disabled,各 server 独立,互不影响。

权限与认证

Channel 权限(Telegram/iMessage/Discord):CC 触发权限对话框时同步向 channel server 推送审批请求,人类在手机上回复 yes xxxxx / no xxxxx,server 解析后发结构化事件 notifications/claude/channel/permission,CC 收到后完成审批。批准方是人类,CC 无法自批准。若 channel server 被攻破,攻击者可伪造审批——已知可接受风险(被攻破的 channel 本就能无限注入对话,自批准只是更快,能力上限不变)。

OAuth / XAA:OAuth 用于远程 server 认证,CC 触发浏览器授权流,token 存 keychain,过期自动刷新。XAA(Cross-App Access)用于 claude.ai 托管 server,基于 Claude 账号做跨应用授权,不走标准 OAuth 流。


Ch 18 - 总结:Claude Code 架构全景

1. MAS 调度
CC 支持四种协调模式:单 Agent / 主从委托 / Coordinator-Worker / Swarm 对等并行。Coordinator 模式通过物理约束分离调度与执行——Coordinator 无执行工具,Worker 无全局上下文,分工靠工具集而非 prompt 约定。Swarm 用 tmux 实现真正的独立进程并行,是目前最重的多 Agent 方案。

2. Agent Loop
核心是 query.tswhile(true) ReAct 循环,每轮调一次 LLM,有工具调用就执行再循环,无工具则结束。所有扩展能力(Hooks、Thinking、Compact、工具调度)都叠加在这个循环上,不替代它。退出条件有 10 种,覆盖正常结束、用户中断、token 超限等全部场景。

3. 上下文与 Compact
CC 把上下文视为有限资源,设计了四层压缩策略:Microcompaction(每轮必跑,移除图片缩小结果)/ Session Memory(直接存记忆,不调 LLM)/ Traditional(fork subAgent 做全量摘要)/ Reactive(LLM 报错时实时触发)。四种策略按成本从低到高依次尝试,压缩失败才升级。

4. 持久化 Memory
记忆系统分两层:CLAUDE.md 是项目级静态知识,每次请求注入 system prompt;auto memory 是跨会话动态积累的用户/项目偏好,存在 ~/.claude/projects/ 目录下,按 MEMORY.md 索引。两者都是纯文件,LLM 通过读写文件工具维护,没有向量数据库。

6. Task 系统
Task 系统是多 Agent 协作的状态共享基础设施,用 JSON 文件 + 文件锁实现跨进程任务状态同步。父 Agent 通过 TaskCreate 创建任务,subAgent 通过 TaskUpdate 更新进度,父 Agent 轮询感知完成情况。文件锁保证并发写入安全,是轻量级的进程间通信方案。

7. Tool 系统
工具是 CC 的能力单元,每个工具自包含(定义、schema、权限检查、执行、UI 渲染)。调度层支持两种模式:默认 toolOrchestration(safe 工具并发批处理,unsafe 串行,上限 10)和实验性 StreamingToolExecutor(流式中即启动,无并发上限,支持级联取消)。MCP 工具通过标准协议动态接入,与内置工具统一调度。

8. Skill 系统
Skill 是可复用的提示词工作流,以 Markdown 文件形式存储,frontmatter 定义元数据。用户通过斜杠命令触发,CC 读取文件内容展开为完整 prompt 注入对话。Skill 可以定义自己的 Hooks,在subAgent 启动时自动注册,实现工作流级别的生命周期控制。

9. MCP 系统
MCP(Model Context Protocol)是 CC 的工具扩展协议,允许外部服务以标准化方式向 CC 注册工具。CC 作为 MCP 客户端,在启动时连接配置的 MCP 服务器,动态获取工具列表并合并进工具池。MCP 工具与内置工具的权限系统、Hooks、调度机制完全统一。

10. 权限系统与沙盒
权限系统是应用层软拦截,工具调用前检查 settings.json 规则,不符合则弹窗询问用户,决策结果存入五个 destination(session / localSettings / projectSettings / userSettings / cliArg)。沙盒是 OS 层硬拦截,macOS 用 sandbox-exec,Linux 用 bwrap,两者通过规则自动同步联动——权限规则驱动沙盒配置,一套规则两层执行。