Prompt Cache 机制

这份文档只回答一个问题:

这个仓库里,prompt cache 到底是怎么被设计、命中、保护、失效和观测的?

先说结论:

  • 这里的 prompt cache 不是一个单点功能,而是一整套约束。
  • 真正被当成“缓存键前缀”的,不只是 system prompt,还包括工具 schema、消息前缀、模型、thinking 配置,以及一部分 beta/header/body 参数。
  • 很多看起来和 cache 无关的实现,其实都在服务“让发给模型的字节尽量稳定”。

1. 仓库把什么当成 cache key 的核心

src/utils/forkedAgent.ts 对共享父会话 cache 的要求写得最直接:

  • system prompt
  • tools
  • model
  • messages prefix
  • thinking config

也就是说,这个仓库默认把 Anthropic 侧 prompt cache 理解成“前缀级缓存”,而不是“整次请求是否一样”的黑盒。

在此基础上,src/services/api/promptCacheBreakDetection.ts 又把下面这些也当成会影响服务端 cache 命中的因素去追踪:

  • cache_control 的 scope / ttl
  • beta headers
  • fast mode / AFK / cache editing / thinking clear 这些 sticky header 状态
  • effort
  • extra body params
  • global cache strategy

所以,这个项目里的“prompt cache”概念,实际比“system prompt 是否相同”更宽。

2. 系统 prompt 是如何为 cache 拆层的

核心入口是 src/constants/prompts.ts 的 getSystemPrompt()。

它不是返回一个大字符串,而是返回 string[],然后交给 src/utils/api.ts 的 splitSysPromptPrefix() 和 buildSystemPromptBlocks() 再切成 API block。

这里最关键的设计有三层。

2.1 静态段和动态段被显式分开

getSystemPrompt() 在静态内容和动态内容之间插入了:

SYSTEM_PROMPT_DYNAMIC_BOUNDARY

它的语义是:

  • boundary 之前的静态段,允许走更激进的 cache
  • boundary 之后的动态段,不应被当成跨会话稳定前缀

这个 boundary 是 prompt cache 设计里的硬约束。prompts.ts 还明确写了“不要移动或删除”。

2.2 动态 section 不是每 turn 都重算

src/constants/systemPromptSections.ts 提供两类 section:

  • systemPromptSection():会缓存到 bootstrap/state.ts 的 systemPromptSectionCache
  • DANGEROUS_uncachedSystemPromptSection():每 turn 重算,值变化时会打碎 prompt cache

当前 getSystemPrompt() 里,绝大多数动态 section 都是缓存的,比如:

  • memory
  • env_info_simple
  • language
  • output_style
  • scratchpad
  • frc
  • token_budget

真正被显式标成危险未缓存的,是 mcp_instructions,原因也写得很明确:MCP server 可能在 turn 之间连接或断开。

这说明这里的原则是:

  • 默认先保 cache 稳定
  • 只有确实必须每 turn 看见的新信息,才允许破坏 cache

2.3 /clear 和 /compact 会重置这层缓存

clearSystemPromptSections() 会清空 section cache,同时清掉 beta header latches。

src/services/compact/postCompactCleanup.ts 和 src/commands/clear/caches.ts 会在 compaction 或 clear 后调用这套清理逻辑。

所以 section cache 的生命周期大致是:

  • 会话内稳定
  • /compact 或 /clear 后重建

3. system prompt block 是怎么映射成 API cache_control 的

src/utils/api.ts 的 splitSysPromptPrefix() 定义了 3 种模式。

3.1 first-party + boundary 存在

这是最激进的路径:

  • attribution header:cacheScope = null
  • system prompt prefix:cacheScope = null
  • boundary 前静态内容:cacheScope = 'global'
  • boundary 后动态内容:cacheScope = null

对应到 buildSystemPromptBlocks() 后,只有静态块会带:

cache_control: getCacheControl({ scope: 'global', ... })

3.2 first-party,但当前工具池里有会实际渲染的 MCP 工具

src/services/api/claude.ts 会先算:

needsToolBasedCacheMarker =
  useGlobalCacheFeature &&
  filteredTools.some(t => t.isMcp === true && !willDefer(t))

语义是:

  • MCP 工具是 per-user、会变的
  • 如果它真的进了工具列表,就不能再把 system prompt 当成可全局稳定复用的前缀

这时 buildSystemPromptBlocks() 会带 skipGlobalCacheForSystemPrompt: true,splitSysPromptPrefix() 会退化成:

  • attribution header:不缓存
  • system prompt prefix:org
  • 其他内容:org

注意一个细节:

  • src/services/api/logging.ts / promptCacheBreakDetection.ts 的类型和注释里还保留了 tool_based
  • 但当前 src/services/api/claude.ts 里实际写入的 globalCacheStrategy 只有 system_prompt 或 none

也就是说,代码语义上仍然承认“工具影响全局 cache 策略”,但当前日志枚举已经简化了。

3.3 其他情况

比如:

  • 3P provider
  • boundary 不存在

这时 system prompt 退回到 org 级 cache:

  • prefix:org
  • rest:org

3.4 这里对 block 数量是很谨慎的

buildSystemPromptBlocks() 上方有一句很重要的注释:

Do not add any more blocks for caching or you will get a 400

说明这里不仅在做语义拆分,也在受 API 侧 cache_control block 数量限制约束。

4. message prefix 上还会再打一个 cache breakpoint

system prompt block 之外,src/services/api/claude.ts 的 addCacheBreakpoints() 还会在消息数组里再放一个 message-level 的 cache_control。

这是主链路里最重要的第二层 cache 标记。

4.1 只允许一个 message-level marker

函数注释写得很强:

  • 每个请求只允许一个 message-level cache_control
  • 否则底层 page/local-attention 的回收行为会变差

正常情况下,这个 marker 放在最后一条消息。

4.2 skipCacheWrite 会把 marker 前移一条

如果是 fire-and-forget 的 fork(例如 side question、prompt suggestion 一类),skipCacheWrite = true 时 marker 会移到倒数第二条消息。

目的不是为了改 cache key,而是:

  • 继续复用已经共享的前缀
  • 但不要把这个短命分支自己的尾巴写进新的 cache entry

4.3 user / assistant 的 marker 落点不完全一样

userMessageToMessageParam() / assistantMessageToMessageParam() 的策略是:

  • 字符串内容:把整条消息包成单个 text block,并给这个 block 打 cache_control
  • 数组内容:只给最后一个 content block 打 cache_control

assistant 还有额外约束:

  • 不会把 marker 打在 thinking
  • 不会打在 redacted_thinking
  • CONNECTOR_TEXT 打开时,也不会打在 connector text block 上

这说明这里默认把“真正适合作为可复用前缀边界的可见内容块”与“thinking/特殊块”区分开了。

5. cache_control 的 scope / ttl 是怎么决定的

src/services/api/claude.ts 的 getCacheControl() 返回的基础结构是:

{ type: 'ephemeral' }

然后按条件再叠加:

  • ttl: '1h'
  • scope: 'global'

5.1 global scope 只在 first-party 打开

src/utils/betas.ts 的 shouldUseGlobalCacheScope() 要求:

  • provider 必须是 firstParty
  • 不能显式关闭 experimental betas

所以 global-scope prompt caching 在这个仓库里不是通用能力,而是 first-party 路径特化。

5.2 1h TTL 是按 querySource allowlist 决定的

should1hCacheTTL() 的条件有两层:

  • 用户是否有资格拿 1h TTL
  • 当前 querySource 是否命中 GrowthBook allowlist

还做了两个 session-stable latch:

  • promptCache1hEligible
  • promptCache1hAllowlist

原因都一样:避免中途 overage 或 GrowthBook 刷新把 TTL 从 1h 切回 5m,从而直接打碎 prompt cache。

5.3 fast / AFK / cache editing / thinking clear 都做了 sticky latch

src/services/api/claude.ts 在真正发请求前,会把这些 header 状态“粘住”:

  • afkModeHeaderLatched
  • fastModeHeaderLatched
  • cacheEditingHeaderLatched
  • thinkingClearLatched

这样做的目的是:

  • 功能可以在运行时变化
  • 但一旦某个会话已经把相关 header 发出去,就不要因为 UI toggle、冷却状态、GrowthBook 翻转而让 cache key 来回变

一个很典型的例子是 fast mode:

  • header 会 sticky-on
  • 真正的 speed='fast' body 参数仍然保持动态

也就是“保 cache key 稳定”和“保实时行为正确”被拆成两层处理。

6. tools 本身也是 prompt cache 的一部分

这里有几条非常硬的实现。

6.1 tool schema 会做 session 级缓存

src/utils/api.ts 的 toolToAPISchema() 会把 base schema 缓存到 src/utils/toolSchemaCache.ts。

代码注释直接说明原因:

  • tools 位于 system prompt 之前
  • tool schema 的任何字节变化,都会打碎后面整段 cached prefix

缓存的内容包括:

  • name
  • description
  • input_schema
  • strict
  • eager_input_streaming

而 defer_loading / cache_control 这种 per-request 变化则只做 overlay,不回写缓存。

6.2 schema cache key 不是只按工具名

如果工具带 inputJSONSchema,cache key 会变成:

${tool.name}:${jsonStringify(tool.inputJSONSchema)}

原因是有些 StructuredOutput 风格工具名字相同,但 schema 不同;只按名字缓存会把旧 schema 错复用回来。

6.3 工具池排序是专门为了 cache 稳定

src/tools.ts 的 assembleToolPool() 做了两件事:

  • built-in tools 按名字排序
  • MCP tools 单独按名字排序,然后整体拼到 built-in 后面

注释写得很清楚:

  • built-in 要保持连续前缀
  • 不能让 MCP 工具夹进 built-in 中间
  • 否则只要某个 MCP 工具的名字排序位置变化,就会导致后续所有工具的 cache key 整体漂移

6.4 有些工具 prompt 会被挪到 attachment,目的也是保 cache

比如 src/tools/AgentTool/prompt.ts:

  • 当 agent list delta 打开时
  • 可用 agent 列表不再内联到 AgentTool 的 prompt
  • 而是改从 attachment 注入

理由也写得很直接:保持工具描述稳定,避免 tools block 因 agent/MCP/plugin 变化而频繁打碎 cache。

7. 这个仓库里有很多“看起来不是 cache,其实是在保 cache”的实现

下面这些都值得单独记住。

7.1 getUserContext() / getSystemContext() 是 memoized 的

src/context.ts 把两者都做成了 memoize(...):

  • getSystemContext() 包含 git status 和可选的 cache breaker
  • getUserContext() 包含 claudeMd 和 currentDate

所以主交互路径下:

  • 这些内容默认是“会话内稳定”的
  • 不会每 turn 重新算出新字节

7.2 日期故意允许“轻微陈旧”

src/constants/common.ts 里:

  • getSessionStartDate = memoize(getLocalISODate)

注释直接说了取舍:

  • 午夜后日期可能陈旧
  • 但比起让整个 prompt prefix 在午夜整体失效,这个代价更小

src/memdir/memdir.ts 也用了同样思路:

  • memory prompt 里写的是 YYYY/MM/DD 路径模式
  • 不直接内联“今天的真实路径”

7.3 attachment 头部会预计算,避免“3 days ago”变成“4 days ago”

src/utils/attachments.ts 对 memory attachment header 的说明很典型:

  • 如果每次 render 时重新算相对时间
  • 文本会跨 turn 发生字节变化
  • 直接 bust prompt cache

所以这类 header 会在 attachment 创建时一次性算好。

7.4 settings 临时文件路径用内容哈希,不用随机 UUID

src/main.tsx 在处理 --settings 的 JSON 字符串时,不用随机临时文件名,而是用内容哈希路径。

原因是:

  • settings 路径会进入 Bash 工具的 sandbox 描述
  • 工具描述又会进入 API tools
  • 如果每次子进程路径都变,tool schema 字节就变,cache 前缀也跟着失效

7.5 tool result 预算替换状态也要稳定

src/utils/toolResultStorage.ts 的 ContentReplacementState 不是单纯为了省 token。

它还有一个明确目标:

  • 同一个 tool_use_id 进入预算替换后,后续命运必须固定
  • preview 文本也必须固定
  • fork 时还要 clone 这份状态

否则不同 turn / 不同 fork 对同一个 tool result 做出不同替换决策,就会让前缀字节不一致,导致 cache miss。

8. fork / subagent 是怎样复用父会话 prompt cache 的

这套机制几乎是仓库里第二重要的 prompt cache 场景。

8.1 共享 cache 用的是 CacheSafeParams

src/utils/forkedAgent.ts 的 CacheSafeParams 包含:

  • systemPrompt
  • userContext
  • systemContext
  • toolUseContext
  • forkContextMessages

这就是 fork 子任务时要尽量保持 byte-identical 的那部分。

8.2 thinking config 也必须一致

forkedAgent.ts 特别强调:

  • thinking config 是 cache key 的一部分
  • 如果 fork 设置了不同的 maxOutputTokens
  • claude.ts 里会因此 clamp budget_tokens
  • thinking config 就变了
  • cache sharing 也就失效了

所以很多 fork 调用都反复强调:

  • 不要改 model
  • 不要改 tools
  • 不要改 thinking
  • 不要改 effort
  • 不要改 maxOutputTokens

8.3 “禁用工具”也不能通过改工具列表来做

很多 fork 场景都会这么写:

  • 保留和父会话一样的 tools
  • 通过 canUseTool 回调把工具 deny 掉

而不是直接把 tools: [] 传给 fork。

原因只有一个:tools 是 cache key 的一部分。

8.4 stop hooks 会保存一份最近的 cache-safe 快照

src/query/stopHooks.ts 在主线程和 SDK 路径下会:

  • saveCacheSafeParams(createCacheSafeParams(stopHookContext))

这样 /btw、prompt suggestion、一些后台 fork 就能直接借用最后一次主线程请求的前缀,而不必自己重构。

8.5 forkSubagent.ts 连“子任务占位 tool_result”都做成了同字节

src/tools/AgentTool/forkSubagent.ts 的做法很极端,但非常符合这个仓库的思路:

  • 保留完整父 assistant message
  • 为所有 tool_use block 生成相同的 placeholder tool_result
  • 只让最后那段 directive 文本因 child 而异

目的就是最大化 fork children 之间的共享前缀。

9. cached microcompact / cache editing 是 prompt cache 的另一条主线

这部分主要在:

  • src/services/compact/microCompact.ts
  • src/services/api/claude.ts
  • src/query.ts

9.1 query() 里,microcompact 在 autocompact 之前

执行顺序是:

  • snip
  • microcompact
  • context collapse
  • autocompact

其中 cached microcompact 的目标不是“本地改消息”,而是“尽量不改前缀内容,同时让服务端删掉老 tool result 的缓存内容”。

9.2 cached microcompact 不直接改本地消息

microCompact.ts 里写得很明白:

  • 它不会直接修改本地 message content
  • 它只会登记 tool result
  • 然后产出 pendingCacheEdits

真正的 cache_edits / cache_reference 注入发生在 API 层的 addCacheBreakpoints()。

9.3 API 层会把 cache_edits 插回固定位置并持久复用

addCacheBreakpoints() 做了几件关键事情:

  • 先把历史 pinned cache_edits 按原位置重新插回去
  • 再把这次新的 cache_edits 插进最后一个 user message
  • 对删除引用做去重
  • 然后把新的 block pin 住,保证后续请求还能在同样位置重发

这说明 cached microcompact 不是一次性 patch,而是“缓存删除指令本身也变成前缀的一部分,需要稳定重放”。

9.4 cache_reference 只会加在最后 cache marker 之前的 tool_result 上

同一个函数还会把:

cache_reference: block.tool_use_id

加到位于最后 cache_control 之前的 tool_result block 上。

注释里还特别说明了为什么用“严格在前面”,而不是“前面或同位置”:

  • 避免 cache_edits 插入后产生 block index 边界问题

9.5 boundary message 要等 API 返回后再发

src/query.ts 不会在 microcompact 当下就立刻发 microcompact_boundary。

它会等 API 响应回来后,用真实的:

  • cache_deleted_input_tokens

减去前一个基线值,算出本次真正删掉了多少 cached token,再生成边界消息。

这比客户端本地估算更准确。

9.6 如果 cache 大概率已经过期,就不用 cache editing 了

microCompact.ts 还有一条 time-based path:

  • 如果距离上一次主线程 assistant 消息已经超过阈值
  • 说明服务端 cache 很可能已经冷掉
  • 这时就直接内容清空老 tool result

因为:

  • 反正前缀已经要重写
  • 与其保持旧内容,不如提前缩小即将被重写的 prompt

触发后还会:

  • resetMicrocompactState()
  • notifyCacheDeletion(querySource)

避免 cached MC 状态和 cache break 检测出现误报。

9.7 ant-only 的内部状态机在这份 checkout 里不可见

当前仓库里能看到这些动态导入:

  • src/services/compact/cachedMicrocompact.js
  • src/services/compact/cachedMCConfig.js

但实际文件不在这份 checkout 里。

因此当前能从代码里直接确认的是“外部契约”:

  • 存在 pendingCacheEdits
  • 存在 pinnedEdits
  • 有 triggerThreshold / keepRecent / supportedModels
  • 有 tool result 注册、删除候选选择、cache edit block 创建这套流程

但“具体删除算法”和“具体配置来源”在当前可见代码里是缺失的。

10. API context management 也在服务 prompt cache

src/services/compact/apiMicrocompact.ts 提供了 server-side context_management 策略。

它做的事情有两类:

  • 清理 thinking
  • 清理部分 tool uses / tool inputs

和 prompt cache 最相关的是 thinking 清理:

  • 如果 thinkingClearLatched 变成 true
  • getAPIContextManagement() 就会把旧 thinking turn 清到只剩 1 个

触发条件是:

  • src/services/api/claude.ts
  • 距离上次成功 API completion 超过 CACHE_TTL_1HOUR_MS

逻辑含义是:

  • 既然 1h cache 已经确定失效
  • 继续保留大量旧 thinking 已经没有 cache hit 价值
  • 那就顺手把 thinking 也压掉

11. prompt cache 的观测与失效检测

11.1 使用量会单独记录 cache read / cache write / cache delete

claude.ts 的 usage 更新和累加,会追踪:

  • cache_read_input_tokens
  • cache_creation_input_tokens
  • cache_deleted_input_tokens

cost-tracker.ts 也会把这些分别计入:

  • per-model usage
  • session total
  • token counter metrics

所以这里不是只关心“命中没命中”,而是关心:

  • 读了多少 cache
  • 写了多少 cache
  • cache editing 实际删了多少 token

11.2 失效检测是“两阶段”的

src/services/api/promptCacheBreakDetection.ts 的流程是:

  1. 请求前 recordPromptState(snapshot)
  2. 响应后 checkResponseForCacheBreak(...)

第一阶段记录:

  • system hash
  • tools hash
  • 含 cache_control 的 hash
  • betas
  • effort
  • extra body
  • global cache strategy
  • fast/auto/cachedMC 等状态

第二阶段看:

  • cache_read_input_tokens 是否相较上一轮下降超过 5%
  • 绝对下降值是否超过 2000 token

满足才认为是真的 cache break。

11.3 检测器会主动避开几类误报

它会显式跳过或降权这些情况:

  • 首次调用
  • haiku
  • cached microcompact 刚做过 cache_edits
  • compaction 之后
  • 间隔超过 5min / 1h 的 TTL 过期

如果 prompt 没变、时间又没超 TTL,它甚至会把原因归到:

  • likely server-side (prompt unchanged, <5min gap)

说明这个检测器并不假设“所有 miss 都是客户端问题”。

11.4 还会落 diff 文件

如果检测到 break,代码会把前后 prompt/tool 状态写成 diff 文件,方便调试。

12. 哪些操作会重置 prompt cache 相关状态

12.1 /clear

src/commands/clear/caches.ts 会清掉:

  • prompt cache break detection state
  • system prompt injection
  • last emitted date
  • post-compact cleanup 涉及的 section cache / microcompact state / beta latches

12.2 /compact 或 auto-compact 后清理

runPostCompactCleanup() 会清掉:

  • microcompact state
  • main-thread 的 getUserContext() memo cache
  • system prompt section cache
  • classifier approvals
  • speculative checks
  • beta tracing state

所以 compaction 在这里不仅是“消息摘要”,也是 prompt 相关状态的边界点。

13. 当前 checkout 里还留着哪些“cache 相关但没完整开放”的接口

13.1 break-cache 命令在当前 checkout 是 stub

src/commands/break-cache/index.js 当前只有:

export default { isEnabled: () => false, isHidden: true, name: 'stub' };

但 src/context.ts 里仍然保留了:

  • systemPromptInjection
  • setSystemPromptInjection()

并且切换它会立即清掉 getUserContext() / getSystemContext() 的 memo cache。

所以“手动 cache break”的接线仍在,但当前公开 checkout 没有对应的真实命令实现。

13.2 cached microcompact 的内部实现文件缺失

前面提到的 cachedMicrocompact.js / cachedMCConfig.js 也属于这类情况:

  • 对外接口在当前代码里可见
  • 但具体内部策略不在当前仓库

14. 总结

如果把这套代码压缩成一句话,可以这么理解:

这个仓库把 prompt cache 当成一等约束,所以它不是“开了 cache_control 就结束”,而是从 system prompt 分段、tool schema 稳定、消息级 marker、fork 共享、microcompact/cache editing、TTL/header latch、到失效检测与清理,全链路都在围绕“尽量让可复用前缀的字节稳定”来设计。

反过来说,阅读这个仓库时,凡是看到下面这些词,基本都可以把它们理解成 prompt cache 设计的一部分:

  • memoize
  • stable / latched / sticky
  • byte-identical
  • rendered bytes
  • do not change tools/model/thinking
  • move to attachment
  • hash-based path
  • preserve prefix
  • cache-safe params

Claude Code Harness Engineering Notes

这是一份基于 mdBook 组织的静态书稿,用于承载当前仓库里的 Claude Code 相关研究、架构分析与工具调用说明。

阅读方式

  • 侧边栏按章节阅读
  • 顶部搜索可全文检索
  • URL 会稳定映射到章节路径,例如 part1/ch01.html

仓库结构

  • src/: 原始 TypeScript 源码快照
  • docs/: mdBook 书稿源文件
  • book.toml: mdBook 配置
  • .github/workflows/pages.yml: GitHub Pages 自动部署

本地预览

mdbook serve

本地构建

mdbook build

如果你把仓库改名,记得同步修改根目录 book.toml 里的 site-url、git-repository-url 和 edit-url-template。

阅读本项目需要的 Node.js 与 TypeScript 基础知识

1. 文档目标

这份文档不是完整的 Node.js 或 TypeScript 教程,而是面向当前仓库的“最小必备知识清单”。

目标是回答两个问题:

  • 读这个项目之前,哪些知识必须先补
  • 哪些知识可以边读边学,不需要一次性学完

结合当前仓库的代码特点,可以先下结论:

  • TypeScript 语法本身不是主要门槛
  • 真正的门槛在 Node.js 运行时、终端程序模型、异步控制和工程组织方式

2. 先理解:Node.js、TypeScript、Bun 在这个项目里的关系

这个仓库虽然是 TypeScript 项目,但运行时明显偏向 Bun,同时大量沿用了 Node.js 生态和心智模型。

可以这样理解:

  • TypeScript:提供类型系统、模块化写法、接口约束
  • Node.js:提供 CLI、文件系统、子进程、环境变量、事件循环这些基础模型
  • Bun:作为实际运行时和构建时特性来源,补充了 bun:bundle、Bun.hash、Bun.spawn 等能力

所以阅读这个项目时,不需要把 Node.js 和 Bun 完全分开学。更实际的顺序是:

  1. 先掌握 Node.js 的基本运行时模型
  2. 再理解这个项目在哪些地方使用了 Bun 特性
  3. 最后把 TypeScript 作为辅助阅读工具

3. 必须掌握的 Node.js 基础

下面这些是阅读本仓库时最需要的部分。

3.1 process 对象

你需要熟悉:

  • process.argv
  • process.env
  • process.cwd()
  • process.platform
  • process.exit()
  • process.stdin / process.stdout / process.stderr

因为这个仓库是 CLI/REPL 应用,很多行为都依赖:

  • 启动参数分流
  • 环境变量控制 feature 和模式
  • 标准输入输出驱动交互

典型场景:

  • src/entrypoints/cli.tsx 根据 process.argv 做 fast-path 分流
  • src/main.tsx、src/entrypoints/init.ts 大量读取 process.env

3.2 文件系统与路径处理

必须掌握:

  • fs / fs/promises
  • path
  • 绝对路径与相对路径
  • Windows 与 POSIX 路径差异

项目中大量代码围绕文件系统工作:

  • 读取配置
  • 管理会话日志
  • 读写插件、技能、工作区文件
  • 校验权限作用域

重点 API:

  • readFile
  • writeFile
  • mkdir
  • readdir
  • stat
  • rename
  • rm
  • join
  • resolve
  • relative
  • dirname
  • basename

3.3 子进程模型

这个项目不是只在内存里跑逻辑,它会频繁调用外部命令。

必须理解:

  • spawn
  • spawnSync
  • exec
  • execFile
  • 子进程的 stdin/stdout/stderr
  • 退出码与信号

因为以下功能都依赖子进程:

  • shell/bash/powershell 工具
  • git 工作流
  • tmux/worktree
  • LSP、Bridge、远程会话

如果不懂子进程,很难读懂工具执行链和权限模型。

3.4 事件循环与异步模型

这是 Node.js 的核心知识点,也是本项目最重要的基础之一。

必须掌握:

  • Promise
  • async/await
  • 并发与串行的区别
  • setTimeout / setInterval
  • 事件监听与回调
  • abort/cancel 模型

项目中大量流程都是异步的:

  • API 流式响应
  • 工具执行
  • MCP 连接
  • 后台任务
  • 远程 session heartbeat
  • 自动 compact 与恢复

如果对异步模型不熟,很容易看不懂为什么代码里有这么多:

  • await Promise.all(...)
  • fire-and-forget 的异步调用
  • cleanup callback
  • 中断与取消逻辑

3.5 AbortController

这是本项目里非常实用、必须补的知识点。

你至少要知道:

  • AbortController 用来取消一个异步流程
  • signal 会被下游任务监听
  • 流式请求、工具执行、子任务都可能接入统一取消机制

项目里常见使用场景:

  • QueryEngine 中断当前轮请求
  • 任务停止
  • 工具执行取消
  • 会话切换时收尾

3.6 Stream 与流式输出

这是阅读 agent 主循环时必须补的知识。

至少要理解:

  • 什么是“流式返回”
  • 为什么响应不是一次性拿完整,而是逐段处理
  • 为什么工具调用和消息渲染会交错发生

这个项目的模型交互、消息更新、工具执行都带有明显的 streaming 特征。

读 src/query.ts 时,建议先建立这个心智模型:

  • 模型回复不是一个字符串
  • 它是一串事件、一串块、一段持续变化的状态流

3.7 事件驱动终端程序

这个项目不是普通后端服务,而是终端交互应用,所以要补:

  • TTY 是什么
  • stdin/stdout 与交互式输入的关系
  • 键盘事件和终端刷新
  • 为什么 UI 不是浏览器 DOM,而是终端渲染树

如果没有这个背景,Ink、输入监听、状态栏、消息流更新这些都很难看懂。

4. 必须掌握的 TypeScript 基础

你已经比较熟悉 TypeScript,这一节更偏“对这个仓库特别重要的部分”。

4.1 类型别名、接口、联合类型

本项目大量使用:

  • type
  • interface
  • 联合类型
  • 字面量类型

例如状态、消息、任务、权限结果,往往会写成:

type Status = 'idle' | 'running' | 'failed'

你必须能很自然地读这类代码,因为项目中很多状态流转就是靠联合类型表达的。

4.2 泛型

项目里会频繁看到:

  • 泛型函数
  • 泛型工具类型
  • 带约束的泛型

如果对泛型阅读不熟,读以下模块会吃力:

  • store/state
  • Tool 类型
  • MCP 结果包装
  • 各类 utility

4.3 判别联合与类型收窄

非常重要。

例如:

if (message.type === 'assistant') {
  // 这里 message 会被收窄
}

这在本项目中无处不在:

  • 消息类型判断
  • 任务类型判断
  • 权限结果判断
  • MCP 结果分类

4.4 可选属性、空值处理

你需要对这些非常敏感:

  • ?
  • undefined
  • null
  • 可选链 ?.
  • 空值合并 ??

因为项目中大量状态是渐进装配的,很多字段都不是一开始就存在。

4.5 模块系统与动态导入

本仓库大量使用:

  • 静态 import
  • 动态 import(...)
  • require(...)

原因通常有三类:

  • 懒加载
  • 避免循环依赖
  • 配合 bun:bundle 做 dead code elimination

阅读时必须理解:

  • 为什么有些模块不在文件顶部静态导入
  • 为什么同一项目里会同时出现 import 和 require

4.6 类型与运行时是两回事

这是大型 TypeScript 项目最容易误判的点。

要记住:

  • TypeScript 类型只在编译期生效
  • 真正运行的是 JavaScript
  • type、interface 不会进入运行时

所以读代码时要分清:

  • 哪些是运行时分支
  • 哪些只是类型描述

5. 这个仓库特别需要补的 React 知识

虽然它不是 Web 页面,但 REPL UI 仍然是 React 风格组织的。

必须掌握:

  • 组件
  • props
  • state
  • useEffect
  • useMemo
  • useRef
  • useState
  • Context
  • 自定义 Hook

因为以下目录都强依赖 React 思维:

  • src/screens/
  • src/components/
  • src/hooks/
  • src/context/

关键理解:

  • 这里不是浏览器 React
  • 而是 React + Ink,把组件树渲染到终端

6. 必须理解的工程概念

6.1 配置驱动

这个项目很多行为不是写死的,而是由:

  • 环境变量
  • settings
  • policy
  • feature gate
  • plugin/skill/frontmatter

共同决定。

如果没有“配置驱动”的意识,很容易误以为某个功能一定会执行,但实际上它可能被:

  • feature flag 裁掉
  • 权限禁用
  • 策略拦截
  • 运行模式切换

6.2 Feature flag

这个仓库里 feature('...') 非常多。

你需要理解:

  • 某些代码路径只在特定构建中存在
  • 有些模块是为了让 Bun 在构建时直接裁剪
  • 这意味着“你看到的代码”不一定在当前二进制里全部运行

这是阅读这个项目时必须建立的认知。

6.3 运行模式

至少要区分:

  • 交互式 REPL
  • headless / SDK
  • remote / bridge
  • background task

很多模块之所以复杂,就是因为它们要兼容多模式运行。

6.4 平台型代码思维

这个项目不是单一业务逻辑,而是平台。

因此你会经常看到:

  • registry
  • manager
  • loader
  • adapter
  • provider
  • context

这些命名通常表示:

  • 不是只处理一件具体业务
  • 而是在组织一组能力或运行时资源

7. 读这个仓库前最值得先补的模块

如果只想用最小成本进入代码,建议先按下面顺序补知识。

第 1 组:最优先

  • Node.js 的 process
  • fs / path
  • Promise / async / await
  • AbortController
  • React Hooks 基础

第 2 组:读主循环前必须补

  • 子进程模型
  • 流式处理
  • 事件驱动终端程序
  • 动态导入与模块加载

第 3 组:深入扩展系统前再补

  • WebSocket / SSE 基础
  • MCP 协议思维
  • 插件系统与 manifest
  • 权限规则与策略系统

8. 按仓库目录反推需要哪些基础

8.1 src/entrypoints/、src/main.tsx

需要:

  • process.argv
  • 环境变量
  • 动态导入
  • 启动流程与初始化顺序

8.2 src/screens/REPL.tsx

需要:

  • React Hooks
  • Context
  • 终端交互模型
  • 状态驱动渲染

8.3 src/query.ts、src/QueryEngine.ts

需要:

  • async generator
  • streaming
  • AbortController
  • 状态机思维

8.4 src/tools/

需要:

  • TypeScript 类型建模
  • 子进程
  • 文件系统
  • 权限校验

8.5 src/services/mcp/

需要:

  • 网络连接基础
  • WebSocket / SSE
  • transport / client 抽象

8.6 src/utils/plugins/、src/skills/

需要:

  • 文件扫描
  • 配置解析
  • manifest/frontmatter
  • 动态装配

9. 最小学习路线

如果你已经有 TypeScript 基础,建议按下面顺序学习。

第一步:先补 Node.js CLI 基础

至少学会:

  • process.argv
  • process.env
  • fs/promises
  • path
  • child_process
  • Promise / async / await

达到的目标:

  • 能看懂入口文件和工具执行文件

第二步:补 React + Ink 所需基础

至少学会:

  • useState
  • useEffect
  • useRef
  • Context
  • 自定义 Hook

达到的目标:

  • 能读 REPL.tsx、components/、hooks/

第三步:补流式与取消控制

至少学会:

  • async iterator
  • 流式响应
  • AbortController
  • 事件驱动状态更新

达到的目标:

  • 能读 query.ts、QueryEngine.ts

第四步:再补 Bun 特性

重点看:

  • bun:bundle
  • Bun.hash
  • Bun.spawn
  • Bun 与 Node 兼容层差异

达到的目标:

  • 能理解这个项目为什么大量使用 feature-gated 分支和 Bun 专属 API

10. 不需要一开始就精通的内容

下面这些不需要先学深,读到对应模块再补就够:

  • OpenTelemetry
  • MCP 细节协议
  • GrowthBook
  • LSP 协议细节
  • tmux / worktree 高级用法
  • 复杂遥测体系
  • 企业策略与远程设置系统

这些会影响深入理解,但不是进入代码的门槛。

11. 最终建议

如果你已经比较熟悉 TypeScript,那么读这个项目最缺的不是 TS,而是下面这套组合能力:

  • Node.js CLI 运行时
  • 异步与流式控制
  • React/Ink 终端 UI
  • 大型工程的配置驱动与平台注册思维

可以把目标定得很实际:

不需要先“学完 Node.js”,只需要先学到“能看懂一个终端交互式 TypeScript 应用是怎么启动、读写文件、拉起子进程、处理异步和渲染 UI”的程度,就足够进入这个仓库。

12. 推荐你优先掌握的关键词

如果想自己继续查资料,优先搜这些关键词:

  • nodejs process argv env
  • nodejs fs promises path
  • nodejs child_process spawn
  • javascript async await promise
  • AbortController javascript
  • React hooks basics
  • Ink React terminal UI
  • async generator javascript
  • bun bundle feature

Claude Code 项目架构分析

1. 项目定位

从代码结构看,这个项目本质上不是“若干命令拼起来的 CLI”,而是一个基于 Bun + TypeScript + React/Ink 的终端交互式应用框架。它的核心目标是:

  • 以终端 REPL 形式承载与模型的多轮会话
  • 在会话中统一调度命令、工具、权限、任务、子代理、插件、MCP 服务
  • 同时支持交互式 UI、SDK/无头模式、远程控制/Bridge 模式

换句话说,它的架构中心不是某个单独命令,而是“一次会话的生命周期管理”。

2. 代码规模与仓库边界

基于当前仓库快照统计:

  • src/ 下约 1902 个源码文件
  • src/ 顶层约 35 个一级子目录
  • 文件分布最重的目录是:utils/、components/、commands/、tools/、services/、hooks/

这说明项目已经进入“大型单体应用”阶段,但仍保持按能力域拆分的结构。

同时,这个仓库更像源码快照而不是完整工程根目录:

  • 仓库根目录未包含 package.json、tsconfig*.json、lockfile 等常见构建清单
  • 因此本分析聚焦于 src/ 中的运行时架构,而不是完整构建链路

3. 总体架构判断

这个项目可以概括为一个 事件驱动的终端 Agent 平台,采用“中心主循环 + 多能力注册 + 横切基础设施”的架构。

可抽象为下面这条主链路:

CLI 入口
  -> 初始化配置/环境/遥测/安全上下文
  -> 装配命令、工具、技能、插件、MCP、LSP
  -> 创建全局 AppState Store
  -> 启动 REPL 或 Headless QueryEngine
  -> 进入 query 主循环
  -> 模型输出 tool_use / 命令 / 子任务
  -> 权限判定 + 工具执行 + 状态更新 + UI 渲染
  -> 会话持久化 / 压缩 / 恢复 / 远程同步

这不是经典三层 Web 架构,也不是纯命令式 CLI,更接近:

  • 上层:终端 UI 与交互编排
  • 中层:会话与 Agent 主循环
  • 下层:工具、插件、MCP、LSP、Bridge 等执行能力
  • 横切:权限、配置、遥测、记忆、压缩、恢复

4. 启动与入口层

4.1 src/entrypoints/cli.tsx

这是最外层引导入口,特点是“按需动态加载”:

  • 对 --version、Bridge、daemon、background session 等路径做 fast-path
  • 通过 feature('...') 做大量编译期裁剪
  • 在真正进入完整 CLI 前,尽量避免大规模模块加载

这说明项目非常重视:

  • 启动性能
  • 多运行模式共存
  • 内部/外部构建差异的裁剪

4.2 src/main.tsx

main.tsx 是真实意义上的应用装配中心,职责非常重:

  • 提前触发 profiler、MDM 设置读取、keychain 预取
  • 初始化配置、GrowthBook、策略限制、远程托管设置
  • 组装命令、工具、技能、插件、MCP、LSP
  • 创建状态仓库并进入 REPL / headless 流程

可以把它视为项目的 composition root。

4.3 src/entrypoints/init.ts

init.ts 处理的是更底层的启动基础设施:

  • 配置系统启用
  • 安全环境变量注入
  • CA 证书 / mTLS / 代理 / upstream proxy
  • 清理回调注册
  • 远程设置与策略限制预加载
  • scratchpad、LSP cleanup 等基础资源初始化

因此启动层被明显分成了两段:

  • cli.tsx:入口分流与轻量 fast-path
  • main.tsx:应用级装配
  • init.ts:环境级初始化

这个分层是合理的。

5. 运行时主循环

5.1 REPL 是交互核心

src/screens/REPL.tsx 是交互式会话的核心屏幕。它不是简单视图组件,而是大型编排器,负责:

  • 输入提交
  • 消息流渲染
  • 工具权限交互
  • 后台任务展示
  • IDE / Remote / MCP / Sandbox / 通知集成
  • 队列、恢复、prompt suggestion、session backgrounding 等复杂行为

因此 REPL 在架构上承担了“终端 UI Shell + 会话控制器”的双重角色。

5.2 src/query.ts 是模型回合循环核心

query.ts 是最关键的运行时引擎之一。它实现的是典型的 agentic loop:

  1. 构建当前轮 query 配置
  2. 发起模型请求并流式接收输出
  3. 解析 tool_use
  4. 执行工具并注入 tool_result
  5. 必要时继续下一轮
  6. 处理 auto compact、stop hooks、预算、恢复等边界

这里还接入了:

  • StreamingToolExecutor
  • runTools(...)
  • auto compact / reactive compact
  • token budget / task budget

说明该项目的“Agent 能力”并不是外围补丁,而是主循环内建能力。

如果想继续往下读 src/query.ts 的真实执行细节,建议直接配合《Agent主循环》一起看。那篇文档会把这个文件里的状态字段、请求前预处理、流式采样、工具执行、恢复重试、stop hooks、预算续轮和终止条件按实际运行顺序拆开,而不是继续堆在总览文档里。

5.3 src/QueryEngine.ts 是 headless/SDK 抽象

QueryEngine 将相同的 query 逻辑抽象成可复用类,用于:

  • SDK 场景
  • headless 会话
  • 非 REPL 环境下的多轮消息提交

这说明作者已经意识到“UI 主循环”和“会话主循环”需要分离,但当前架构仍保留了较强耦合:REPL 和 QueryEngine 并存,主逻辑尚未完全统一。

6. 能力执行层:Tools、Commands、Tasks

6.1 Tools 是模型可调用能力

src/tools.ts 是工具注册中心,负责:

  • 汇总所有基础工具
  • 按 feature flag 裁剪工具
  • 按权限 deny rule 在暴露给模型前做过滤
  • 在需要时加入 MCP 工具、ToolSearchTool 等动态能力

这层的设计重点是“模型看到什么能力”。

从实现上看,Tool 是整个系统最核心的能力抽象之一,承载:

  • Shell / PowerShell
  • 文件读写与编辑
  • 搜索
  • Web fetch / Web search
  • MCP
  • Agent / Team / Task
  • Plan mode / Worktree 等会话控制能力

6.2 Commands 是用户显式入口

src/commands.ts 是 slash command 注册中心,负责整合:

  • 内建命令
  • 动态技能命令
  • 插件命令
  • bundled skills
  • builtin plugin skill commands

因此命令层的角色不是业务核心,而是“用户入口的统一索引层”。

一个重要结论是:

  • command 面向用户显式输入
  • tool 面向模型自主调用

两者共用同一会话上下文,但职责边界清晰。

6.3 Tasks 是后台执行与子代理承载

src/tasks/ 与 src/tasks/LocalAgentTask/LocalAgentTask.tsx 表明系统把后台 agent、子任务、远程任务统一建模为 Task。

Task 机制负责:

  • 注册/更新任务状态
  • 记录任务进度与消息
  • 在主会话与子代理之间传递通知
  • 支撑 foreground/background 切换

这使得“子代理”不是临时线程,而是系统内的一等运行单元。

7. 权限与安全控制

权限系统是该项目最突出的横切模块之一。

7.1 src/utils/permissions/permissionSetup.ts

这里负责:

  • 从磁盘加载权限规则
  • 构造 ToolPermissionContext
  • auto mode / plan mode 切换时修正权限
  • 剥离危险规则

尤其值得注意的是,它显式识别以下危险自动授权:

  • Bash 对解释器/任意脚本执行的宽泛放行
  • PowerShell 对嵌套 shell、表达式执行、进程启动的宽泛放行
  • AgentTool 的自动放行

这说明安全策略不是只在工具执行时判断,而是会提前改写能力边界。

7.2 src/services/tools/toolExecution.ts

工具执行层统一处理:

  • 权限校验
  • hook 调用
  • telemetry
  • MCP 异常归类
  • 工具结果消息化

也就是说,真正的工具调用不是工具对象自行直连执行,而是经过统一执行管线。

这是一个比较成熟的平台化设计。

8. 扩展体系:MCP、插件、技能、Bridge

8.1 MCP:外部能力总线

src/services/mcp/client.ts 负责把 MCP 服务接入为系统内能力,支持:

  • stdio / SSE / streamable HTTP / WebSocket 多种 transport
  • MCP tool 调用
  • resource 读取
  • auth / reauth
  • URL elicitation retry
  • 将 MCP tool/resource 包装为内部 Tool

这意味着 MCP 在本项目中不是“附加插件”,而是标准扩展协议。

8.2 插件:本地能力分发机制

src/utils/plugins/pluginLoader.ts 显示插件系统已经比较完整,具备:

  • marketplace / session-only / seed cache 等多来源加载
  • versioned cache
  • manifest 校验
  • hook / command / agent / settings 整合
  • 启停与错误收集

插件层更像“本地分发与装配机制”,而 MCP 更像“运行时协议接入机制”。

8.3 技能:Markdown 驱动的轻量能力封装

src/skills/loadSkillsDir.ts 说明技能系统本质上是:

  • 从 markdown/frontmatter 中加载描述、约束、参数、hooks、模型偏好
  • 可作为 slash command 使用
  • 可由 MCP skill builder 复用解析逻辑

这是一种很轻量的“提示工程资产化”方式,降低了扩展成本。

8.4 Bridge / Remote:远程控制与多环境调度

src/bridge/bridgeMain.ts 体现的是另一条非常重的架构支线:

  • 环境注册
  • session spawn
  • heartbeat
  • reconnectSession
  • worktree 管理
  • 远程工作分发

这说明项目不是单机 CLI,而是已经朝“本地终端 + 远程环境 + 会话调度”的平台方向演化。

9. 状态模型

src/state/AppStateStore.ts 里的 AppState 是系统统一状态核心,覆盖:

  • 设置与模型选择
  • 工具权限上下文
  • tasks
  • MCP clients/tools/resources
  • plugins
  • bridge / remote 状态
  • 通知、elicitation、todos、file history、session hooks

这说明项目采用的是集中式应用状态,而不是各功能局部自治。

优点:

  • 全局交互一致
  • 易于跨模块共享会话状态
  • 适合复杂终端 UI

代价:

  • 主状态过大
  • 模块耦合偏高
  • REPL 与状态变更逻辑容易继续膨胀

10. 横切能力

除了主链路,项目还有几类重要横切系统:

  • services/compact/:上下文压缩、自动 compact、恢复关键附件
  • memdir/、services/SessionMemory/、extractMemories/:长期/会话记忆
  • services/analytics/:遥测、feature gate、实验开关
  • services/lsp/:语言服务能力
  • migrations/:设置迁移
  • utils/sessionStorage.ts、conversationRecovery.ts:会话落盘与恢复

这些模块共同支撑了“长生命周期会话”这一产品特性。

11. 目录分层解读

可以把 src/ 粗分为 5 层:

11.1 入口与编排层

  • entrypoints/
  • main.tsx
  • screens/
  • commands/

11.2 会话运行层

  • query.ts
  • QueryEngine.ts
  • Task.ts
  • tasks/
  • state/

11.3 能力层

  • tools/
  • services/mcp/
  • services/lsp/
  • bridge/
  • remote/

11.4 扩展层

  • plugins/
  • skills/
  • commands/plugin/

11.5 横切基础设施层

  • utils/
  • services/analytics/
  • services/compact/
  • memdir/
  • migrations/
  • constants/

其中 utils/ 数量明显最多,说明大量通用能力被沉淀在基础设施层,但也意味着“工具化沉积”较重,后期需要警惕边界继续变模糊。

12. 架构优点

  • 入口清晰,fast-path 与完整装配链路区分明确
  • Tool、Command、Task 三种抽象边界相对清楚
  • 权限系统是平台级设计,不是零散校验
  • MCP、插件、技能三种扩展机制定位不同,层次合理
  • Query 主循环支持流式输出、工具调用、压缩、预算控制,能力完整
  • 支持 REPL、SDK、Remote/Bridge 多种运行模式,复用度高

13. 主要问题与风险

13.1 超大编排文件

以下文件承担了过多职责:

  • src/main.tsx
  • src/screens/REPL.tsx
  • src/query.ts
  • src/QueryEngine.ts

这类文件已经接近“巨型 orchestrator”,后续维护成本会持续上升。

13.2 UI 与业务编排耦合较深

尤其在 REPL.tsx 中,UI、权限交互、任务控制、消息处理、外部集成混在一起,未来如果要进一步拆出桌面端、Web 端或更纯净的 headless runtime,会有较高改造成本。

13.3 Feature flag 分支复杂

feature('...') 的广泛使用有利于构建裁剪,但也会带来:

  • 阅读路径分叉
  • 测试覆盖困难
  • 外部构建与内部构建行为差异增大

13.4 集中式状态继续膨胀

AppState 已经承载过多系统状态。若没有继续做领域切分,后续容易出现:

  • 修改影响面扩大
  • 非预期刷新/联动
  • 状态恢复与调试复杂化

14. 结论

这是一个大型单体、平台化、会话驱动的终端 Agent 系统。

它的核心架构不是传统 CLI 的“命令分发”,而是:

  • 以 main.tsx 完成装配
  • 以 REPL.tsx / QueryEngine.ts 承载会话入口
  • 以 query.ts 驱动模型-工具闭环
  • 以 tools/、tasks/、services/mcp/、plugins/ 构成能力平台
  • 以权限、压缩、记忆、恢复、遥测做横切支撑

如果只用一句话概括:

这是一个把终端 UI、Agent 运行时、工具平台、远程控制和扩展协议整合在同一进程模型中的大型 TypeScript 单体应用。

从工程角度看,它已经具备成熟平台的骨架;从维护角度看,下一阶段最值得投入的是“拆分超大编排器、收缩状态边界、降低 feature flag 带来的认知复杂度”。

上下文预处理

1. 文档目标

本文聚焦 Claude Code 在正式向模型发起一轮请求前,如何对会话上下文做“压缩前预处理”和“压缩执行”。这里的“上下文压缩”不是单一功能,而是几层机制叠加:

  • 查询前的轻量裁剪
  • 请求前的微压缩(microcompact)
  • 超阈值后的自动压缩(autocompact)
  • 基于 session memory 的替代压缩
  • 手动 /compact 与局部 partial compact
  • 压缩后关键上下文的重新注入

核心入口主要在:

  • src/query.ts
  • src/services/compact/autoCompact.ts
  • src/services/compact/microCompact.ts
  • src/services/compact/compact.ts
  • src/services/compact/sessionMemoryCompact.ts
  • src/utils/messages.ts

2. 总体链路

主查询循环在 src/query.ts 中,每轮请求大致按下面顺序处理上下文:

messages
  -> getMessagesAfterCompactBoundary()
  -> applyToolResultBudget()
  -> HISTORY_SNIP(若开启)
  -> microcompactMessages()
  -> CONTEXT_COLLAPSE(若开启)
  -> autoCompactIfNeeded()
  -> 真正调用模型

这意味着“压缩”并不是在上下文爆掉时才一次性做,而是在每轮请求前就开始逐层减负。

3. 第一层:只保留最后一个 compact 边界之后的会话

getMessagesAfterCompactBoundary() 位于 src/utils/messages.ts。

它的作用是:

  • 找到最后一个 compact_boundary
  • 只保留这个边界之后的消息
  • 默认还会过滤掉已经被 snip 标记裁掉的消息

这一步非常关键,因为它保证后续压缩、摘要、恢复都只围绕“当前活跃上下文段”工作,而不是反复处理历史上已经被摘要过的旧段。

4. 第二层:请求前轻量预处理

4.1 tool result 预算裁剪

在 query.ts 中,applyToolResultBudget() 先对聚合后的工具结果做预算控制。这一层不是摘要,而是把超大的工具输出替换为更短的内容表示,避免单条工具结果过度吞掉上下文窗口。

4.2 HISTORY_SNIP

如果开启 HISTORY_SNIP,snipCompactIfNeeded() 会先把低价值历史消息从“模型可见视图”里投影掉,但 UI 仍可保留完整滚动历史。

特点:

  • 主要解决“模型要看的上下文”过长
  • 不直接改写全部会话存储
  • 会把节省出来的 token 数回传给 autocompact 阈值判断

5. 第三层:microcompact

src/services/compact/microCompact.ts 提供两种微压缩路径,本质都不做“语义摘要”,只清理高成本工具结果。

5.1 Cached microcompact

这是默认主路径之一,目标是:

  • 找出可压缩工具的 tool_use_id
  • 不直接改写本地消息
  • 而是在 API 层插入 cache_edits
  • 让旧工具结果从服务端 prompt cache 里被删除

可压缩工具包括:

  • FileRead
  • shell 类工具
  • Grep
  • Glob
  • WebSearch
  • WebFetch
  • FileEdit
  • FileWrite

关键点:

  • 只对主线程查询源生效,避免污染子 agent 的状态
  • 通过 pendingCacheEdits 延后到 API 调用层插入
  • API 返回后再根据真实的 cache_deleted_input_tokens 生成 microcompact_boundary

所以 cached microcompact 的特点不是“改消息”,而是“保缓存、删缓存里的旧工具结果”。

5.2 Time-based microcompact

evaluateTimeBasedTrigger() / maybeTimeBasedMicrocompact() 会在“距离上一次 assistant 消息时间过长”时触发。

默认思路是:

  • 如果服务端 prompt cache 大概率已经过期
  • 那就没必要继续保留很老的工具结果正文
  • 直接把旧 tool_result.content 替换成 [Old tool result content cleared]
  • 仅保留最近 keepRecent 个工具结果

这是一次真正的本地消息改写,但只改工具结果正文,不做语义摘要。

6. 第四层:何时触发完整压缩

src/services/compact/autoCompact.ts 负责自动压缩判定。

6.1 阈值计算

自动压缩阈值大致是:

effectiveContextWindow = 模型上下文窗口 - 预留摘要输出 token
autoCompactThreshold = effectiveContextWindow - 13000

其中:

  • 预留摘要输出 token 默认 20000
  • warning buffer 默认 20000
  • manual blocking buffer 默认 3000

因此压缩并不是“正好撞满窗口才触发”,而是提前留出安全余量。

6.2 自动压缩优先级

当 shouldAutoCompact() 为真时,autoCompactIfNeeded() 的顺序是:

  1. 先尝试 sessionMemoryCompact
  2. 不满足再走传统 compactConversation

这说明 session memory 压缩是优先分支,不是附属功能。

7. 第五层:传统完整压缩的预处理

传统完整压缩实现位于 src/services/compact/compact.ts 的 compactConversation()。

它在真正发出摘要请求前,会做几类重要预处理。

7.1 执行 PreCompact hooks

先跑 executePreCompactHooks(),允许外部 hook:

  • 注入额外的 compact 指令
  • 给用户显示附加提示

之后通过 mergeHookInstructions() 把用户传入的 compact 指令和 hook 生成的指令合并。

7.2 只摘要 compact 边界后的消息

streamCompactSummary() 内部会对输入执行:

  • getMessagesAfterCompactBoundary(messages)
  • 再把本轮 summaryRequest 追加进去

所以完整压缩也不会把更早的历史段再次送给摘要模型。

7.3 去除图片和文档

stripImagesFromMessages() 会把:

  • 用户消息中的 image
  • 用户消息中的 document
  • tool_result 里的嵌套图片/文档

统一替换成 [image] 或 [document]。

目的有两个:

  • 减少压缩请求自身的 token
  • 保留“这里曾经出现过图片/文档”这一语义痕迹

7.4 去除会在压缩后重新注入的附件

stripReinjectedAttachments() 会移除部分附件,例如技能发现类附件,因为这些内容会在压缩后重新挂回上下文,提前送去做摘要只会浪费 token,还会把过时建议污染到摘要里。

7.5 消息归一化,修正 API 合法性

发送给压缩模型前会经过 normalizeMessagesForAPI(),而这一步本身也是很重要的“上下文预处理”。

它会做的事包括:

  • 重排 attachment,尽量贴近相关 assistant/tool_result
  • 过滤 display-only 的消息
  • 合并连续 user 消息
  • 清理失效的 tool_reference
  • 处理过大的 image/pdf 错误后的残留 meta 消息
  • 统一 assistant tool input 结构
  • 通过 ensureToolResultPairing() 修复缺失、重复、孤儿化的 tool_use/tool_result

这里的意义是:压缩请求要先保证自己是一个 API 可接受的消息流,否则连“做摘要”都可能失败。

7.6 禁止压缩 agent 再去调用工具

压缩 prompt 在 src/services/compact/prompt.ts 里,明确要求:

  • 只能输出纯文本
  • 必须返回 <analysis> + <summary>
  • 不允许调用任何工具

在 forked-agent 路径里,还会通过 createCompactCanUseTool() 从权限层直接拒绝工具调用。

因此压缩模型的职责是“总结上下文”,不是继续执行任务。

8. 压缩请求失败时的兜底预处理

如果压缩请求本身也触发 prompt too long,系统不会直接失败,而是执行 truncateHeadForPTLRetry()。

它会:

  • 按 groupMessagesByApiRound() 对消息按 API 往返分组
  • 优先丢弃最老的若干组
  • 必要时插入一个 synthetic user marker
  • 保证切掉前缀后仍是合法的 API 消息序列

也就是说,传统 compact 还有一层“为让摘要请求本身先活下来”的二次裁剪。

9. 第六层:session memory 压缩

src/services/compact/sessionMemoryCompact.ts 提供另一条路径:不用现场重新总结整段会话,而是直接使用已经提炼好的 session memory。

9.1 触发前提

需要同时满足:

  • session memory 功能开启
  • compact 相关开关开启
  • session memory 文件存在且不为空

9.2 保留尾部消息的策略

它不会简单地“只留下最后几条消息”,而是通过 calculateMessagesToKeepIndex() 计算保留起点,约束包括:

  • 至少保留一定 token 数
  • 至少保留一定数量的 text-block 消息
  • 不能超过 maxTokens
  • 不能拆断 tool_use/tool_result
  • 不能丢失与同一 message.id 相关的 thinking 块

这个保留策略由 adjustIndexToPreserveAPIInvariants() 保证 API 结构完整。

9.3 session memory 也会做长度截断

truncateSessionMemoryForCompact() 会控制 session memory 的 section 大小,避免“压缩摘要本身”过长,反而又把上下文塞满。

10. 第七层:压缩后的上下文恢复

压缩不是“生成一段摘要后就完了”。compactConversation() 和 partial compact 在摘要完成后,会主动恢复若干关键上下文。

10.1 恢复最近读取过的文件

createPostCompactFileAttachments() 会:

  • 从 readFileState 里找最近读过的文件
  • 重新读取真实文件内容
  • 按最近访问时间排序
  • 最多恢复 5 个文件
  • 单文件最多约 5000 token
  • 总预算最多约 50000 token

并且会跳过:

  • plan 文件
  • memory 文件
  • 已经在 preserved tail 中可见的 Read 结果

10.2 恢复计划、计划模式、技能、异步 agent 状态

压缩后可能附带恢复:

  • plan_file_reference
  • plan_mode
  • invoked_skills
  • 后台 agent 的 task_status

其中技能内容还会做截断,优先保留文件头部,避免每次 compact 都重新灌入完整技能文本。

10.3 恢复工具、agent 列表和 MCP 指令差量

因为 compact 会吃掉之前的 delta attachment,所以系统会重新生成:

  • deferred tools delta
  • agent listing delta
  • MCP instructions delta

这样下一轮对话仍然知道当前可用的工具、agent 和 MCP 说明。

10.4 再跑 SessionStart / PostCompact hooks

压缩成功后还会执行:

  • processSessionStartHooks('compact')
  • executePostCompactHooks()

这保证 CLAUDE.md、hook 指令和会话状态在 compact 后重新建立。 CLAUDE.md 的发现、注入与重载细节,可进一步参见《CLAUDE.md 工作机制》。

11. partial compact 的特殊点

partialCompactConversation() 支持围绕某个 pivot 做局部摘要,有两种方向:

  • from:摘要 pivot 之后的消息,保留更早的上下文
  • up_to:摘要 pivot 之前的消息,保留更新的上下文

它的本质不是单独一套实现,而是复用完整 compact 逻辑,只是:

  • messagesToSummarize 与 messagesToKeep 的选取不同
  • up_to 会主动去掉保留段里的旧 compact boundary / compact summary
  • 后续恢复 attachment 时会只补“被摘要段失去的那部分信息”

12. 结论

这个项目里的“上下文压缩机制”可以概括为一句话:

先用轻量规则尽量减少无价值 token,再在必要时把旧会话折叠成摘要,并把继续工作所需的文件、计划、技能和运行态重新挂回上下文。

从实现上看,它不是一个单点函数,而是一条完整流水线:

  • query.ts 负责压缩编排
  • microCompact.ts 负责低成本削减工具结果
  • autoCompact.ts 负责阈值与触发
  • sessionMemoryCompact.ts 负责优先走 session memory 摘要
  • compact.ts 负责传统摘要压缩与压缩后恢复
  • messages.ts 负责消息合法化和压缩边界切片

所以如果后续要继续分析,可以优先把问题拆成三类:

  1. 请求前到底裁掉了什么
  2. 真正送去摘要模型的输入长什么样
  3. 压缩后哪些信息被重新注入,哪些信息被永久丢弃

跨 Memory Context 机制

1. 文档目标

这份文档聚焦 src/ 里“memory 如何跨不同 context 流动”这一条主线。

这里的 context 不是单指模型上下文窗口,而是几种边界同时存在:

  • 主对话与 forked subagent 之间
  • 当前会话与后续会话之间
  • 个人记忆与 team 共享记忆之间
  • 主线程 agent 与具名 agent 类型之间
  • 原始长对话与 compact 之后的新上下文之间

从源码看,Claude Code 并没有做一个统一的“大 memory 总线”,而是把 memory 拆成几类职责不同、边界不同的存储,再通过 prompt 注入、fork 继承、文件系统、同步服务把它们串起来。

核心入口主要在:

  • src/services/SessionMemory/sessionMemory.ts
  • src/services/compact/sessionMemoryCompact.ts
  • src/services/extractMemories/extractMemories.ts
  • src/memdir/memdir.ts
  • src/memdir/paths.ts
  • src/memdir/teamMemPaths.ts
  • src/services/teamMemorySync/
  • src/tools/AgentTool/agentMemory.ts
  • src/utils/forkedAgent.ts
  • src/utils/systemPrompt.ts

2. 先区分仓库里的几类 memory

这套实现里至少有四类 memory:

类型主要作用典型路径作用范围
session memory给当前长会话做浓缩摘要,优先服务 compact{projectDir}/{sessionId}/session-memory/summary.md只对当前 session 生效
auto memory沉淀跨会话可复用信息~/.claude/projects/<project>/memory/ 或 override 路径同一用户、同一项目
team memory在项目维度共享长期记忆<autoMemPath>/team/同仓库、同组织成员
agent memory给某个 agent 类型持久化经验agent-memory/ 或 agent-memory-local/同 agent 类型、按 scope 控制

它们不是同一个生命周期:

  • session memory 面向“当前对话压缩续航”
  • auto memory 面向“下次还记得”
  • team memory 面向“别人也能继承”
  • agent memory 面向“这个 agent 类型持续自我积累”

3. 跨 Context 的共性设计

虽然几类 memory 分工不同,但源码里有三条共性原则。

3.1 用 prompt 注入把 memory 变成当前上下文

memory 真正进入模型可见上下文,主要靠 prompt 注入,而不是额外挂载一个数据库查询层。

  • src/constants/prompts.ts 在系统 prompt 组装阶段调用 loadMemoryPrompt()
  • src/memdir/memdir.ts 根据是否启用 auto/team memory,构造单目录或双目录 memory prompt
  • src/utils/systemPrompt.ts 在 agent 有 memory 配置时,用 loadAgentMemoryPrompt() 把 agent memory 追加进 agent 的 system prompt

因此“跨 context”的第一步其实是:先把持久化文件重新翻译成 prompt 里的行为说明和索引内容。

3.2 用 forked agent 共享上下文,但隔离可变状态

memory 更新通常不是主线程直接做,而是 fork 一个子执行链去做。

关键点在 src/utils/forkedAgent.ts:

  • createCacheSafeParams() 把 system prompt、user context、system context、tools、历史消息打包出来
  • runForkedAgent() 复用这些 cache-safe 参数,尽量命中父会话的 prompt cache
  • createSubagentContext() 默认克隆 readFileState、contentReplacementState,并给子链路新的 abort/controller 与 no-op 状态回调

也就是说:

  • 可见上下文尽量继承,保证 fork 和主线程看到的是同一份对话语义
  • 可变运行态尽量隔离,避免 session memory / extract memories 之类的后台动作污染主线程状态

这就是“跨 context 传语义,不传脏状态”。

3.3 用文件路径和权限规则做 memory 边界

memory 最终都落到文件系统,所以边界控制也主要靠路径。

  • src/utils/permissions/filesystem.ts 为 session memory、auto memory、agent memory 提供专门的读写许可分支
  • src/memdir/teamMemPaths.ts 对 team memory 的写路径做两段校验:先 resolve(),再 realpath(),防止 .. 和 symlink 逃逸
  • src/utils/memoryFileDetection.ts / src/utils/sessionFileAccessHooks.ts 统一识别 memory 文件,补充 telemetry 和 watcher 触发

因此这里不是“逻辑上说它是 memory”,而是“路径进入某个受控目录后,权限、同步、统计都按 memory 规则处理”。

4. 链路一:主会话 -> Session Memory -> Compact

这是最典型的“当前上下文跨边界压缩续航”路径。

4.1 Session memory 只在主 REPL 线程更新

src/services/SessionMemory/sessionMemory.ts 的 extractSessionMemory() 明确限制:

  • querySource 必须是 repl_main_thread
  • remote mode 不启用
  • auto compact 关闭时也不注册这个 hook

初始化入口是 initSessionMemory(),它通过 registerPostSamplingHook() 在每轮主查询结束后判断要不要更新 session memory。

4.2 触发条件是“上下文已足够大”,而不是每轮都写

shouldExtractMemory() 组合了几类阈值:

  • 会话 token 总量达到初始化门槛
  • 距离上次提取又增长了足够 token
  • 工具调用数达到阈值,或者至少已经来到一个“末尾没有 tool call”的自然停顿点

对应状态保存在 src/services/SessionMemory/sessionMemoryUtils.ts:

  • tokensAtLastExtraction
  • lastSummarizedMessageId
  • extractionStartedAt

这说明 session memory 不是实时镜像,而是“阶段性浓缩快照”。

4.3 真正写文件的是一个受限 forked agent

提取流程是:

主线程消息
  -> setupSessionMemoryFile()
  -> buildSessionMemoryUpdatePrompt()
  -> runForkedAgent(querySource='session_memory')
  -> 只允许 Edit summary.md

这里有两个关键点:

  • setupSessionMemoryFile() 会创建 summary.md,并在首次创建时写入模板
  • createMemoryFileCanUseTool() 只允许对子文件执行 FileEdit

也就是说,session memory fork 并不是一个通用 agent,它本质上是一个“只准改当前 session 摘要文件”的单用途上下文转换器。

4.4 Compact 优先消费 session memory,而不是重新总结整段对话

src/services/compact/sessionMemoryCompact.ts 里的 trySessionMemoryCompaction() 是这条链路的下游消费者。

它的流程是:

compact 前
  -> waitForSessionMemoryExtraction()
  -> getLastSummarizedMessageId()
  -> getSessionMemoryContent()
  -> calculateMessagesToKeepIndex()
  -> 用 session memory 作为 compact summary
  -> buildPostCompactMessages()

这条路径的意义是:

  • 已经被 session memory 覆盖的历史,不必再次调用 compact summary 模型
  • 只保留摘要之后的一小段 tail,维持 API 合法性和最近交互细节
  • 仍然会通过 processSessionStartHooks('compact') 恢复 CLAUDE.md 等开场上下文

所以 session memory 的本质不是“给用户看的笔记”,而是“预先计算好的 compact 中间态”。

4.5 它如何跨过 compact 边界继续生效

createCompactionResultFromSessionMemory() 会:

  • 构造新的 compact_boundary
  • 把 session memory 变成一条 isCompactSummary 的 user message
  • 记录 messagesToKeep
  • 在 boundary 上标注 preservedSegment

因此 compact 后的新上下文,不是凭空重建,而是:

旧长对话
  -> session memory 摘要
  + 保留的 tail
  + compact 后恢复的附件/钩子
  = 新对话上下文

这就是第一种跨 context:同一 session 内,长历史被提前折叠成 session memory,再跨过 compact 边界续接。

5. 链路二:主会话 -> Auto / Team Memory -> 后续会话

这条链路解决的是“这次聊过的东西,下次怎么还在”。

5.1 Auto memory 先通过 system prompt 暴露给当前会话

src/memdir/memdir.ts 的 loadMemoryPrompt() 会把 memory 系统装进 system prompt:

  • 只开 auto memory 时,注入 private memory 目录说明
  • 同时开 team memory 时,注入 private + team 双目录说明
  • assistant/kairos 模式下,改成 daily log 追加式记忆

这一步只注入行为规范和 MEMORY.md 索引,不会自动把所有 topic 文件全文塞进 prompt。

5.2 会话结束点由后台提取器把新信息沉淀出来

src/services/extractMemories/extractMemories.ts 负责在 query loop 完成后做 durable memory 提取。

它的特点:

  • 也是 runForkedAgent(),共享父对话 cache-safe 上下文
  • createAutoMemCanUseTool() 只允许 Read/Grep/Glob、只读 Bash,以及对 memory 目录内的 Write/Edit
  • 如果主线程已经直接写过 memory 文件,hasMemoryWritesSince() 会跳过本轮提取,避免重复劳动

这意味着主线程和后台 extractor 的关系不是竞争,而是互补:

  • 主线程自己记了,就不再 fork
  • 主线程没记,后台 extractor 补记

5.3 Team memory 不是另一套 prompt,而是 auto memory 的共享分支

当 TEAMMEM 启用时,buildCombinedMemoryPrompt() 会告诉模型:

  • private memory 写到 auto memory 根目录
  • team memory 写到 <autoMemPath>/team/
  • 两边各有自己的 MEMORY.md 索引

所以 team memory 不是独立系统,而是 auto memory 目录树里的共享子空间。

5.4 跨成员传播靠 Team Memory Sync

src/services/teamMemorySync/ 把 team memory 从本地目录同步到服务端。

核心机制:

  • watcher.ts 启动时先 pullTeamMemory()
  • 然后对 team 目录开 fs.watch,本地有变更时 debounce 后 pushTeamMemory()
  • index.ts 里 pull 是“服务端覆盖本地”,push 是“只上传 checksum 有变化的 key”

同步 scope 不是靠本地目录名,而是靠 repo slug:

  • 通过 getGithubRepo() 识别仓库
  • 服务端按 repo 维度存 team memory

于是第二种跨 context 出现了:

当前会话
  -> extract memories 写本地 memory 文件
  -> 下次 session 再由 loadMemoryPrompt 注入
  -> 若写入 team 子目录,再由 watcher 推到服务端
  -> 其他成员 session 启动时 pull 下来

这条链把“会话上下文”扩展成了“项目长期上下文”和“团队共享上下文”。

6. 链路三:Agent 定义 -> Agent Memory -> 同类型 Agent

这条链路解决的是“不同 agent 类型如何拥有自己的长期记忆”。

6.1 Agent memory 是 agent definition 的一个 frontmatter 能力

src/tools/AgentTool/loadAgentsDir.ts 里,agent 定义支持:

  • memory: user
  • memory: project
  • memory: local

一旦配置了 memory:

  • getSystemPrompt() 会自动把 loadAgentMemoryPrompt() 追加进 agent 的 system prompt
  • 如果 agent 自己声明了 tools,还会自动补上 FileRead / FileWrite / FileEdit,保证它能操作 memory 文件

所以 agent memory 不是外部服务,而是 agent prompt 的内建能力。

6.2 三种 scope 对应三种持久化边界

src/tools/AgentTool/agentMemory.ts 中:

  • user:~/.claude/agent-memory/<agentType>/
  • project:<cwd>/.claude/agent-memory/<agentType>/
  • local:<cwd>/.claude/agent-memory-local/<agentType>/,或 remote mount 下的 project namespaced 路径

这说明 agent memory 的“跨 context”不是跨成员,而是跨 agent 实例:

  • 只要 agentType 相同,后续实例都能读到这份 memory
  • 但 scope 决定它是用户级共享、项目级共享,还是仅本机局部共享

6.3 项目还可以给 agent memory 提供 snapshot 种子

src/tools/AgentTool/agentMemorySnapshot.ts 额外提供一层“项目预置 agent 记忆”:

  • snapshot 放在 <cwd>/.claude/agent-memory-snapshots/<agentType>/
  • 首次本地 memory 为空时,可 initializeFromSnapshot()
  • snapshot 更新后,还能检测到“需要提示更新”

这让 agent memory 既能自我积累,也能由项目维护者预灌初始知识。

7. 这套“跨 Memory Context”机制到底跨了什么

如果把几条链路合在一起,可以把它概括成下面这张图:

flowchart TD
  A["主对话 messages"] --> B["Session Memory Hook"]
  B --> C["session-memory/summary.md"]
  C --> D["sessionMemoryCompact"]
  D --> E["compact 后的新上下文"]

  A --> F["extractMemories fork"]
  F --> G["auto memory"]
  F --> H["team memory"]
  G --> I["下次 session 的 system prompt"]
  H --> J["TeamMemorySync"]
  J --> K["其他成员 session"]

  L["agent definition(memory scope)"] --> M["agent memory prompt"]
  M --> N["同类型 agent 的后续实例"]
  O["project snapshot"] --> N

从源码层面,所谓“跨 memory context”主要体现在四件事:

  • 同一会话内跨上下文窗口:长对话先折叠成 session memory,再穿过 compact 边界续接
  • 跨会话:本轮对话经 extractMemories 落盘为 auto memory,下次再注入 prompt
  • 跨成员:team memory 通过 repo 维度同步,把本地共享子目录扩展成组织内共享上下文
  • 跨 agent 类型实例:agent memory 让某个 agentType 的经验可以在后续实例中复用

8. 设计上的几个关键判断

8.1 它不是“一个 memory 系统”,而是“多层记忆分层”

源码里没有试图用一种存储覆盖所有需求,而是按时间跨度拆层:

  • 短期:session memory
  • 中长期:auto memory
  • 团队长期:team memory
  • agent 专属长期:agent memory

这种分层让 compact、协作、agent 自我改进不会互相打架。

8.2 它不是直接共享运行态,而是共享可重建的语义

真正跨 context 传播的核心对象不是 React state,也不是 query loop 的内部变量,而是:

  • fork 时共享的 cache-safe prompt 语义
  • 文件系统里的 memory 内容
  • compact 后的 boundary + summary + preserved tail

运行态本身大多通过 createSubagentContext() 被隔离掉了。

8.3 文件系统是这套机制的真实“记忆总线”

无论是哪种 memory,最后都落在文件上:

  • session memory:summary.md
  • auto/team memory:MEMORY.md + topic files
  • agent memory:按 agentType 分目录

所以权限、同步、搜索、统计都围绕路径展开。这也是为什么 filesystem.ts、memoryFileDetection.ts、teamMemPaths.ts 在这套机制里和业务逻辑同等关键。

9. 一句话总结

Claude Code 的“跨 memory context”机制,本质上是:

用 forked agent 在不污染主线程状态的前提下,把当前对话提炼成不同层级的文件化记忆,再通过 prompt 注入、compact 边界恢复和 team sync,把这些记忆重新投射回后续的 session、agent 和协作成员上下文中。

10. 相关源码

  • src/services/SessionMemory/sessionMemory.ts
  • src/services/SessionMemory/sessionMemoryUtils.ts
  • src/services/SessionMemory/prompts.ts
  • src/services/compact/sessionMemoryCompact.ts
  • src/services/extractMemories/extractMemories.ts
  • src/memdir/memdir.ts
  • src/memdir/paths.ts
  • src/memdir/teamMemPaths.ts
  • src/services/teamMemorySync/index.ts
  • src/services/teamMemorySync/watcher.ts
  • src/tools/AgentTool/agentMemory.ts
  • src/tools/AgentTool/agentMemorySnapshot.ts
  • src/tools/AgentTool/loadAgentsDir.ts
  • src/utils/forkedAgent.ts
  • src/utils/systemPrompt.ts

工具调用与工具系统构建

这份文档只回答一个问题:

这个仓库里,模型可调用的“工具系统”是怎么被构建出来的?

结论先说:

它不是简单地把若干函数暴露给模型,而是把工具做成了一套完整的平台层,包含:

  • 统一工具抽象
  • 静态注册与按环境裁剪
  • 内建工具和 MCP 工具的统一合并
  • 工具权限与 Hook 管线
  • 并发/串行执行编排
  • tool_use -> tool_result 的消息回写
  • 运行中动态刷新工具集合

可以把它理解为:

Tool 定义层 -> Tool Pool 装配层 -> Query 主循环 -> Tool Execution 管线 -> Transcript/UI 回写


1. 统一抽象:先把“工具”定义成一种平台对象

核心类型在 src/Tool.ts。

这里没有把工具定义成“只有一个 call() 的函数”,而是定义成一个完整的 Tool 对象。一个工具至少包含这些部分:

  • name
  • inputSchema / inputJSONSchema
  • call()
  • prompt()
  • description()
  • checkPermissions()
  • validateInput()
  • mapToolResultToToolResultBlockParam()
  • 各种 UI 渲染方法
  • 并发、安全、只读、破坏性等元信息

这说明系统把“工具”看成一个跨层对象,而不是单纯执行逻辑。它同时服务于:

  1. 给模型暴露能力
  2. 在本地做输入校验
  3. 做权限判定
  4. 做执行
  5. 做 transcript / UI 渲染
  6. 做 telemetry

1.1 buildTool() 的作用

src/Tool.ts 里的 buildTool() 很关键。

它给工具提供一组默认行为,例如:

  • 默认 isEnabled() 为 true
  • 默认 isConcurrencySafe() 为 false
  • 默认 isReadOnly() 为 false
  • 默认 isDestructive() 为 false
  • 默认 checkPermissions() 为放行

这带来的效果是:

  • 每个工具只需要声明自己的特殊部分
  • 平台层总能拿到一个结构完整的 Tool 对象
  • 调用方不需要到处写 tool.xxx?.() ?? default

也就是说,Tool 在这里已经是一个内部 DSL。

1.2 ToolUseContext 是工具运行时上下文

ToolUseContext 也是 src/Tool.ts 的核心。

它把工具执行需要的运行时能力集中到一起,包括:

  • 当前可用工具列表
  • commands / mcpClients / mcpResources
  • getAppState() / setAppState()
  • abortController
  • 消息列表 messages
  • UI 相关接口
  • 文件状态缓存
  • 当前 agent / query 链路信息

所以工具并不是在一个“纯函数环境”里运行,而是在一个完整的 agent runtime 里运行。


2. 工具如何注册:src/tools.ts 是注册中心

工具系统的第一层装配在 src/tools.ts。

2.1 getAllBaseTools():静态工具总表

getAllBaseTools() 返回系统内建工具全集,例如:

  • BashTool
  • FileReadTool
  • FileEditTool
  • FileWriteTool
  • NotebookEditTool
  • WebFetchTool
  • WebSearchTool
  • AgentTool
  • Task*
  • ListMcpResourcesTool
  • ReadMcpResourceTool
  • ToolSearchTool

但这个“全集”仍然会受到环境与 feature flag 影响,例如:

  • 某些工具只在 USER_TYPE === 'ant' 下启用
  • 某些工具依赖特定 feature
  • 某些工具依赖运行环境,例如 PowerShell、Worktree、LSP

所以这里做的不是死注册,而是“按构建与运行环境生成基础工具集”。

2.2 getTools():基于权限上下文过滤

getTools(permissionContext) 会在基础工具集上继续做过滤:

  • 简化模式只保留少量工具
  • REPL 模式下隐藏被 REPL 包装掉的原始工具
  • 删除被 deny rule 明确禁止的工具
  • 删除当前 isEnabled() 为 false 的工具

重点是:

这里不是等模型调用后再拒绝,而是先把不该看到的工具从工具池里拿掉。

这意味着安全边界的一部分是在“能力暴露阶段”完成的。

2.3 filterToolsByDenyRules():权限规则会提前改写能力边界

filterToolsByDenyRules() 用权限上下文过滤工具。

它不仅能过滤内建工具,也能过滤 MCP 工具;而且支持按 MCP server 前缀整体屏蔽。

这点非常重要:

权限系统不是只在执行时生效,而是会直接改变“模型当前看见什么工具”。


3. 工具池如何装配:内建工具和 MCP 工具被统一合并

注册完内建工具后,还要装配出“当前这一轮真正可用的工具池”。

3.1 assembleToolPool()

src/tools.ts 的 assembleToolPool(permissionContext, mcpTools) 是核心入口。

它做三件事:

  1. 取出当前允许的内建工具
  2. 对 MCP 工具应用同样的 deny 规则
  3. 把两者合并并按名字排序、去重

这里的排序不是为了美观,而是为了 prompt cache 稳定。

代码里专门保持:

  • 内建工具作为连续前缀
  • MCP 工具作为后缀

这样新增或删除某个 MCP 工具时,不会无谓地打碎整个系统 prompt 的缓存键。

3.2 mergeAndFilterTools()

src/utils/toolPool.ts 的 mergeAndFilterTools() 会继续做一层合并:

  • 合并初始工具、组装后的工具、动态工具
  • 再次去重
  • 必要时应用 coordinator mode 的工具白名单

所以最终“给模型看的工具列表”不是单点来源,而是多路合流后的结果。

3.3 cli/print.ts 的 buildAllTools()

在 CLI 主入口里,buildAllTools() 把这些来源真正汇总到一起:

  • 启动时已有工具
  • SDK MCP 工具
  • 动态接入的 MCP 工具
  • synthetic output tool

这说明工具池是运行时可变的,而不是进程启动后固定不变。


4. MCP 如何接入:外部工具被包装成内部 Tool

MCP 是这套工具系统最重要的扩展入口,核心在 src/services/mcp/client.ts。

4.1 fetchToolsForClient()

这个函数会向 MCP server 发 tools/list 请求,然后把远端返回的工具描述转换成内部 Tool 对象。

转换时复用了 src/tools/MCPTool/MCPTool.ts 这个模板工具,再覆盖关键字段:

  • name
  • mcpInfo
  • description()
  • prompt()
  • inputJSONSchema
  • checkPermissions()
  • call()
  • 只读/破坏性/openWorld 等提示信息

所以 MCP 工具虽然来自外部协议,但进入系统后会被“编译”为内部统一工具对象。

4.2 为什么这样设计很重要

这样做之后,MCP 工具和内建工具就能共享同一套基础设施:

  • 同一个工具池
  • 同一套权限机制
  • 同一条执行管线
  • 同一种 transcript 表达
  • 同一种 UI 渲染模型

从平台视角看,MCP 不是外挂,而是一级公民。

4.3 延迟加载工具:shouldDefer / ToolSearch

Tool 抽象里支持:

  • shouldDefer
  • alwaysLoad

这说明系统支持“不是一开始把全部 schema 都发给模型”,而是:

  • 先暴露一部分
  • 其余工具通过 ToolSearchTool 延迟发现

toolExecution.ts 里甚至有专门逻辑,当某个 deferred tool 的 schema 没被发送导致参数校验失败时,会提示模型先用 ToolSearch 再重试。

这套机制本质上是在解决:

  • 工具多
  • schema 大
  • prompt 成本高

这三个问题。


5. 工具如何进入主循环:query.ts 负责模型-工具闭环

工具系统真正运转起来是在 src/query.ts。

主循环大致是:

  1. 把当前工具列表传给模型
  2. 流式接收模型输出
  3. 收集其中的 tool_use
  4. 执行工具
  5. 把结果变成 tool_result
  6. 把新消息拼回上下文继续下一轮

5.1 工具是跟模型流式输出耦合的

query.ts 在流式接收 assistant 响应时,会从消息块里提取 tool_use。

也就是说,工具调用不是“模型输出完一整段文本后再解析命令”,而是协议层就存在正式的 tool_use block。

5.2 主循环把当前工具集直接传给模型

在 deps.callModel(...) 时,会显式传入:

  • tools: toolUseContext.options.tools

这说明工具系统不是另起一套 side channel,而是作为当前回合采样参数的一部分送入模型。

5.3 工具执行后再递归进入下一轮

当这一轮 assistant 产出 tool_use 后,query.ts 会执行工具,然后把:

  • assistant 消息
  • tool_result 消息
  • 额外 attachment

一起拼成新的 messages

再递归进入下一轮 query。

所以 agent 的“会调用工具”能力,本质上就是这个递归闭环。


6. 工具如何被编排执行:串行与并发是平台层统一控制的

具体的批量执行调度在 src/services/tools/toolOrchestration.ts。

6.1 runTools() 不是简单 for 循环

它先用 partitionToolCalls() 把当前这批工具分成两类:

  • 并发安全工具批次
  • 非并发安全工具批次

判断依据不是写死的,而是每个工具自己的 isConcurrencySafe(input)。

6.2 并发安全工具可以批量并发

对于 read-only / concurrency-safe 的工具,会走 runToolsConcurrently()。

这意味着像搜索、读取类工具可以并行跑,提高吞吐。

6.3 非并发安全工具必须串行

写文件、修改状态、可能互相影响的工具,会走 runToolsSerially()。

这保证了上下文修改不会乱序。

6.4 上下文修改不是立刻生效,而是按批次回放

工具返回值允许带 contextModifier。

对于并发批次,这些 modifier 不会在结果返回瞬间就直接改上下文,而是先排队,等这一批跑完后再按顺序应用。

这个细节说明作者很清楚:

“并发执行”和“上下文一致性”是两个不同问题。


7. 真正执行一条工具调用:toolExecution.ts 是总管线

最核心的执行文件是 src/services/tools/toolExecution.ts。

runToolUse() 和 checkPermissionsAndCallTool() 组成了完整执行链。

7.1 执行链路

一条工具调用大致会经过这些阶段:

  1. 按名字找到 Tool 对象
  2. 用 inputSchema.safeParse() 做结构校验
  3. 用 validateInput() 做语义校验
  4. 跑 PreToolUse hooks
  5. 解析权限决策
  6. 真正执行 tool.call()
  7. 跑 PostToolUse hooks
  8. 生成 tool_result
  9. 产出消息并回写上下文

这说明工具对象本身只负责“自己的业务逻辑”,真正的平台行为全在统一执行层里。

7.2 输入校验分成两层

这里有两道关:

  • inputSchema.safeParse():结构是否正确
  • validateInput():值是否合法

这样能把“模型参数格式错了”和“参数含义不合法”分开处理。

7.3 权限不直接散落在工具实现里

权限决策通过:

  • tool.checkPermissions()
  • resolveHookPermissionDecision(...)
  • canUseTool(...)

共同完成。

也就是说,权限系统是“平台统一裁决 + 工具自定义补充”的组合,不是每个工具自己乱做一套。

7.4 Hook 是执行管线的一级结构

工具执行前后都有 hook:

  • runPreToolUseHooks()
  • runPostToolUseHooks()
  • runPostToolUseFailureHooks()

Hook 可以做很多事:

  • 改输入
  • 阻止继续执行
  • 注入额外上下文
  • 修改 MCP 输出
  • 失败时追加附加消息

这说明工具系统已经不是“调用函数”,而是“可拦截、可扩展的执行管线”。

7.5 tool.call() 的返回值不只是结果数据

ToolResult<T> 不止有 data,还可以带:

  • newMessages
  • contextModifier
  • mcpMeta

这意味着工具不仅能返回结果,还能:

  • 主动往 transcript 注入消息
  • 修改后续回合的上下文
  • 给 MCP / SDK 保留结构化元数据

所以工具被设计成“会影响对话状态的执行单元”。


8. 工具结果如何回写:统一转成 tool_result 消息

8.1 每个工具负责把业务结果映射成协议块

通过 mapToolResultToToolResultBlockParam(),工具把自己的输出转成标准的 tool_result 块。

这一步很关键,因为它把“内部业务结果”映射回“模型协议结果”。

8.2 平台层再统一处理大结果与持久化

执行层不会直接把结果原样塞回 transcript,而是会经过:

  • processPreMappedToolResultBlock()
  • processToolResultBlock()

这些逻辑会处理:

  • 大结果落盘
  • 截断
  • 结果替换
  • 与 tool_use_id 的绑定

所以 transcript 里的 tool_result 并不一定等于工具原始输出。

8.3 tool_use_id 是主键

整套系统围绕 tool_use_id 串联:

  • progress
  • permission
  • hook
  • tool_result
  • transcript 恢复

这使得系统可以稳定处理:

  • 并发工具
  • 中断
  • fallback
  • resume
  • orphan result 修复

9. 流式工具执行:工具甚至可以在模型还没说完时启动

query.ts 会根据 gate 决定是否创建 StreamingToolExecutor。

如果开启,它就不再等 assistant 整轮输出结束后才统一执行工具,而是在 streaming 过程中一边接收 tool_use,一边立刻尝试调度执行。

这使工具执行从“批处理”变成了“增量调度”:

  • 模型还在继续生成后续内容
  • 一部分工具已经开始执行
  • 一部分 progress / tool_result 已经提前进入 transcript

9.1 入口:它是在 query.ts 的 streaming 主循环旁边启动的

StreamingToolExecutor 的创建发生在 query.ts 的 query setup 阶段。

随后主循环一边消费 deps.callModel(...) 的 streaming 输出,一边检查 assistant message 里有没有新的 tool_use block。

一旦发现:

  • 先把这些 block 推入 toolUseBlocks
  • 再调用 streamingToolExecutor.addTool(toolBlock, message)

也就是说,它不是在模型输出完成后“回头扫一遍工具”,而是和模型 streaming 并行工作。

9.2 它本质上是一个按顺序回放结果的工具队列

src/services/tools/StreamingToolExecutor.ts 里维护了一组 TrackedTool,每个工具都有一套状态机:

  • queued
  • executing
  • completed
  • yielded

这几个状态的含义可以理解为:

  • queued:已经在 transcript 中出现,但还没开始执行
  • executing:已经启动,并正在消费 runToolUse() 的输出
  • completed:工具执行结束,最终结果已经缓存
  • yielded:结果已经正式回灌给 query 主循环,不应再重复输出

因此它的关键职责并不只是“尽快启动工具”,还包括:

  • 记录工具出现顺序
  • 缓存已经完成但尚未回放的结果
  • 保证最终输出顺序稳定

9.3 并发规则:并不是所有工具都可以一起跑

addTool(...) 不会盲目启动工具。

它会先:

  • 根据工具定义找到对应 Tool
  • 用 inputSchema.safeParse(...) 校验输入
  • 调用工具自己的 isConcurrencySafe(...)

只有在工具被判定为 concurrency-safe 时,它才允许和其他 concurrency-safe 工具并发执行。

对应规则可以概括成:

  • 当前没有正在执行的工具时,任何工具都可以启动
  • 当前正在执行的工具如果全部是 concurrency-safe,那么新的 concurrency-safe 工具也能加入并发执行
  • 只要遇到非 concurrency-safe 工具,它就必须独占执行

这和传统 runTools(...) 的“先按批次切组,再串行/并行执行”是同一个设计意图,只是 StreamingToolExecutor 把这套调度提前到了 streaming 阶段。

9.4 它没有重写单个工具执行,而是复用了 runToolUse()

StreamingToolExecutor 负责的是调度、顺序和取消传播。

真正执行单个工具时,它仍然调用 src/services/tools/toolExecution.ts 里的 runToolUse(...)。

所以单个工具内部仍然走同一条执行管线:

  • 工具查找
  • 参数校验
  • 权限检查
  • hook
  • 真正调用
  • 结果封装为 message / tool_result

这点很重要,因为它说明“流式工具执行”不是另一套独立工具框架,而是在原有工具执行管线上加了一层更激进的运行时编排。

9.5 结果如何提前进入 transcript

runToolUse() 自身就是 async generator,因此工具在执行过程中可以不断产出 update。

StreamingToolExecutor 对这些 update 做了两层处理:

  • progress message 不放进最终结果数组,而是先进入 pendingProgress
  • 非 progress 的 message 作为最终结果缓存到 tool.results

随后 query.ts 在 streaming 主循环内部会不断调用 getCompletedResults()。

这个方法会:

  • 优先吐出所有待发送的 progress
  • 再按工具原始接收顺序吐出已经完成的结果
  • 如果遇到一个仍在执行中的非 concurrency-safe 工具,就停止继续向后扫描

这样就实现了一个非常关键的性质:

  • 工具可以提早开始执行
  • progress 可以尽早显示给 UI
  • 最终 tool_result 又不会因为并发完成时间不同而打乱 transcript 顺序

换句话说,系统追求的是“执行尽量早,结果尽量稳”。

9.6 getRemainingResults() 负责在流结束后把尾巴收干净

模型 streaming 结束时,工具不一定已经全部跑完。

因此在工具执行分支里,query.ts 会把后续消费入口切到:

  • streamingToolExecutor.getRemainingResults()

这个 async generator 会反复:

  • 尝试继续推进队列
  • 先吐出已完成结果
  • 如果还有执行中的工具但当前没有新结果,就等待“某个工具完成”或“新的 progress 到来”

所以它承担的是 drain 作用:把 streaming 阶段没来得及回放完的工具输出全部补齐。

这一步非常关键,因为 transcript 对 API 来说必须满足一个基本约束:

  • 已经出现的 tool_use
  • 最终必须有对应的 tool_result

9.7 它对中断、错误和 fallback 有专门处理

流式工具执行最复杂的地方,不是“怎么更快”,而是“怎么在异常情况下不把 transcript 搞坏”。

StreamingToolExecutor 专门处理了几类取消语义。

第一类是用户中断。

如果 query 级别的 abortController 已经中断,它会根据工具的 interruptBehavior() 决定:

  • 这个工具是否应该取消
  • 取消后要不要生成 synthetic error / reject message

第二类是 sibling error。

当前实现只让 Bash 工具的错误级联取消兄弟工具。

原因也很现实:

  • Bash 工具经常存在隐含依赖链
  • 一个前置命令失败后,后面的并发命令继续执行通常没有意义
  • 但 Read、WebFetch、Grep 这类工具彼此往往是独立的,不应该因为一个失败就全部中止

第三类是 streaming fallback / model fallback。

一旦 streaming attempt 被判定为失败并切换到 fallback 路径,旧 attempt 中已经启动但尚未完成的工具结果就可能变成“孤儿结果”。

为了解决这个问题,query.ts 会:

  • tombstone 已经产出的 orphaned assistant messages
  • 调用 streamingToolExecutor.discard()
  • 重建一个全新的 StreamingToolExecutor

这样旧 attempt 的 tool_result 就不会带着旧 tool_use_id 混进新 attempt 的 transcript。

这套处理还和 Claude API 的 streaming fallback 风险绑定在一起:如果不显式丢弃旧执行器,中途 fallback 很容易导致“同一个工具被执行两次”。

9.8 它和传统 runTools(...) 的差别到底在哪里

如果把两条路径对比起来,可以更清楚地看出 StreamingToolExecutor 的定位。

runTools(...) 的特点是:

  • 先收集完整批次的 tool_use
  • 再按批次划分并发安全工具和非并发安全工具
  • 最后统一执行并产出结果

StreamingToolExecutor 的特点是:

  • tool_use 一出现就立刻入队
  • 能启动的工具立刻启动
  • 已完成的结果尽早回灌
  • 剩余结果在流结束后再 drain 完成

所以它改变的不是单个工具的执行语义,而是:

  • 启动时机
  • 调度时机
  • 取消传播方式
  • transcript 回灌方式

这是一个 runtime orchestration 层的优化,而不是 Tool API 层的重写。

9.9 当前实现的一个限制:并发工具的 context modifier 还没有真正支持

代码里有一条很重要的注释:

  • 当前并不真正支持 concurrency-safe 工具的 contextModifier

实际行为是:

  • 非 concurrency-safe 工具执行完后,contextModifier 会顺序应用回 toolUseContext
  • concurrency-safe 工具即使产生了 modifier,目前也不会像串行工具那样被安全地合并回上下文

这说明当前系统对“并发工具修改运行时上下文”仍然保持保守态度。

因此,StreamingToolExecutor 今天已经完整解决了:

  • 提前执行
  • 顺序回放
  • 中断与 fallback

但还没有把“并发上下文变更”这个问题彻底做完。

9.10 为什么说它是 agent runtime 级别的能力

从设计上看,StreamingToolExecutor 不是一个简单的“并发优化器”。

它实际上把以下几件事绑在了一起:

  • assistant streaming
  • tool queueing
  • progress UI
  • transcript 一致性
  • 中断传播
  • fallback 安全

所以它体现出来的并不是“工具调用加快了”,而是整个 agent runtime 已经从同步回合式执行,演化成了一个能处理流式事件、并发状态和异常恢复的系统。


10. 这套工具系统的真实分层

如果抽象成分层,可以写成:

10.1 定义层

  • src/Tool.ts
  • src/tools/*

职责:

  • 定义 Tool 接口
  • 定义具体工具
  • 规定 schema、权限、渲染、结果映射

10.2 注册与装配层

  • src/tools.ts
  • src/utils/toolPool.ts
  • src/cli/print.ts

职责:

  • 注册内建工具
  • 合并 MCP 工具
  • 按模式、权限、feature 过滤
  • 生成当前回合最终工具池

10.3 扩展接入层

  • src/services/mcp/client.ts
  • src/tools/MCPTool/MCPTool.ts

职责:

  • 从 MCP server 拉取工具描述
  • 包装成内部 Tool
  • 接入同一套平台能力

10.4 执行编排层

  • src/services/tools/toolOrchestration.ts
  • src/services/tools/StreamingToolExecutor.ts

职责:

  • 分批
  • 并发/串行调度
  • 顺序回放结果

10.5 执行管线层

  • src/services/tools/toolExecution.ts
  • src/services/tools/toolHooks.ts

职责:

  • 校验
  • 权限
  • Hook
  • 真实调用
  • 结果格式化
  • 错误处理

10.6 主循环层

  • src/query.ts

职责:

  • 把工具集交给模型
  • 接收 tool_use
  • 执行工具
  • 递归推进 agent 回合

11. 为什么说这不是“工具调用”,而是“工具平台”

从代码结构看,这套系统已经具备平台特征:

  • 工具有统一协议
  • 工具有统一注册中心
  • 工具有统一执行管线
  • 工具有统一权限与 hook 扩展点
  • 外部 MCP 工具可以无缝接入
  • 工具结果能影响上下文与后续回合
  • 支持延迟加载、并发、流式执行、恢复与重放

所以更准确地说:

这个仓库不是“实现了一批工具”,而是“实现了一个 agent tool runtime”。


12. 一句话总结

这个项目构建工具系统的方式是:

先用 Tool 抽象统一工具定义,再由 src/tools.ts 和 MCP 客户端把工具组装成当前可见的 tool pool,随后在 query.ts 的主循环里把工具交给模型、解析 tool_use,最后通过 toolOrchestration.ts 和 toolExecution.ts 走完整的校验、权限、hook、执行、结果回写链路,形成一个持续递归的模型-工具闭环。

状态管理机制:Claude Code 如何组织运行时状态

这篇文档只回答一个问题:

src/ 里的运行时状态,到底是怎样被定义、初始化、订阅、更新并扩散副作用的?

先给结论:

  • 这套实现不是 Redux/Zustand 一类第三方状态库方案
  • 也不是纯 React useState / useReducer 驱动的组件内状态方案
  • 它的核心模式是:自研外部 store + Context 注入 + useSyncExternalStore 切片订阅
  • 在整体上,项目采用的是:一个主 AppState + 多个领域专用 store / 轻量 Context 的组合式架构

可以先把主链路记成下面这条:

初始化默认状态
  -> 创建 store
  -> Provider 注入 / Headless 直连
  -> 组件按 selector 订阅
  -> setState 更新
  -> onChangeAppState 扩散副作用

1. 先判断:这不是“单一状态容器包打天下”

如果只看目录,很容易以为这个项目会使用 Redux、Zustand、MobX 之类的现成方案,但实际源码不是这样。

围绕状态管理的核心文件主要有:

  • src/state/store.ts
  • src/state/AppState.tsx
  • src/state/AppStateStore.ts
  • src/state/onChangeAppState.ts
  • src/components/App.tsx
  • src/main.tsx

它们共同构成了一个很明确的分层:

  1. store.ts 提供最小外部 store 原语
  2. AppStateStore.ts 定义全局状态结构和默认值
  3. AppState.tsx 把 store 接到 React 树里,并提供读写 hook
  4. onChangeAppState.ts 负责状态变化后的副作用扩散
  5. 其他领域再按需要选择:
    • 进入主 AppState
    • 自己维护专用 store
    • 或只用一个轻量 Context 做局部依赖注入

这意味着它并不追求“所有状态都进一个 reducer”,而是追求:

  • 全局会话态统一
  • 局部领域态按需隔离
  • React 组件和非 React 代码都能共享同一份状态心智模型

2. 第 1 层:最底层的 store 原语

src/state/store.ts 是整个状态体系最底层的基石。它没有引入任何复杂框架,只定义了一个很小的 store 接口:

  • getState()
  • setState(updater)
  • subscribe(listener)

这份实现有几个特点:

2.1 setState 是同步的

调用 setState 时,会立刻:

  1. 读取旧状态 prev
  2. 用 updater 计算新状态 next
  3. 如果 Object.is(next, prev) 为真就直接跳过
  4. 否则写入新状态并通知订阅者

这让它既能服务 React,也能服务命令式逻辑。
很多异步流程会在更新后立刻调用 getState() 读取最新值,而不用等待下一轮渲染。

2.2 它是“外部 store”,不是组件 state

这里的状态不挂在某个组件实例上,而是一个独立对象。
这正是后面可以接 useSyncExternalStore 的原因。

2.3 它自带 onChange

createStore(initialState, onChange?) 支持在每次状态变化后执行回调。
这个能力后面会被 AppState 用来接 onChangeAppState,形成“状态更新”和“副作用扩散”分离的结构。

3. 第 2 层:全局应用状态 AppState

如果说 store.ts 提供的是“状态引擎”,那么 AppState 就是这个项目的主状态平面。

3.1 AppStateStore.ts 是全局 schema 中心

src/state/AppStateStore.ts 负责两件事:

  • 定义 AppState 的完整类型
  • 提供 getDefaultAppState() 作为默认状态装配点

这里可以把它理解成“全局运行时状态表”。

状态域非常多,但典型的核心区域包括:

  • tasks:后台任务、子代理、远程任务等统一任务状态
  • mcp:MCP clients / tools / commands / resources
  • plugins:插件启用状态、命令、错误、安装状态
  • notifications:当前通知与通知队列
  • toolPermissionContext:工具权限模式和权限上下文
  • promptSuggestion:Prompt 建议的展示与接受状态
  • teamContext:swarm / teammate 的团队上下文

除此之外,还有文件历史、bridge 状态、远程会话状态、overlay 集合、plan mode 相关状态等。
这说明 AppState 不是 UI 小状态集合,而是整个会话 runtime 的控制平面。

3.2 默认状态从 getDefaultAppState() 开始

getDefaultAppState() 负责初始化整个会话的默认值,例如:

  • 空的 tasks
  • 空的 mcp.clients/tools/commands/resources
  • 初始 toolPermissionContext
  • 初始 notifications
  • 各种 bridge / remote / prompt suggestion / overlay 的默认状态

这里不是简单返回一个“空对象”,而是把运行时真正需要的初始结构一次性装配好。
后面无论交互态还是无头态,都是从这份默认结构继续扩展。

4. 第 3 层:React 如何消费这份全局状态

src/state/AppState.tsx 负责把外部 store 接进 React。

4.1 AppStateProvider 做了什么

AppStateProvider 的核心逻辑很直接:

  1. 用 createStore(initialState ?? getDefaultAppState(), onChangeAppState) 创建 store
  2. 用 AppStoreContext 把 store 注入到 React 树
  3. 暴露给子组件统一消费

它还做了两件补充工作:

  • 挂接 settings 变化监听,把外部 settings 文件变化同步回 AppState
  • 在 provider 内层再包 MailboxProvider 和可选 VoiceProvider

一个重要细节是:store 在 provider 生命周期里只创建一次。
这让 context value 保持稳定,provider 本身不会因为每次状态更新而触发整棵树重渲染。

4.2 useAppState 是“按切片订阅”

useAppState(selector) 内部用的是:

  • store.getState() 读取快照
  • useSyncExternalStore(store.subscribe, get, get) 建立订阅

它的语义不是“拿整个 state”,而是“订阅某个切片”。

例如:

const verbose = useAppState(s => s.verbose)
const permissionMode = useAppState(s => s.toolPermissionContext.mode)

这样组件只会在选中的值变化时重渲染,而不会因为其他字段变化被动刷新。

4.3 useSetAppState 是“只写不订阅”

useSetAppState() 直接返回 store.setState。
这意味着:

  • 调用方可以更新全局状态
  • 但不会因为状态变化而重渲染

这种模式特别适合:

  • 命令按钮
  • 事件处理器
  • 只负责发起状态变更的 hooks

4.4 useAppStateStore 是给非 React / 异步逻辑的命令式入口

useAppStateStore() 直接返回整个 store。
这样调用方就能使用:

  • store.getState()
  • store.setState()

这在很多异步流程里很关键,因为它可以避免 stale closure。

典型场景是:

  • useCancelRequest.ts
  • useInboxPoller.ts
  • useTypeahead.tsx
  • useReplBridge.tsx

这些逻辑经常需要在定时器、异步回调或事件处理中读取“此刻最新”的状态,而不是依赖某次渲染时捕获的旧快照。

4.5 交互态和无头态共享同一套 store 心智模型

交互态入口在 src/components/App.tsx:

  • 外层挂 FpsMetricsProvider
  • 再挂 StatsProvider
  • 再挂 AppStateProvider initialState={initialState} onChangeAppState={onChangeAppState}

无头态则在 src/main.tsx 里直接创建:

const headlessStore = createStore(headlessInitialState, onChangeAppState)

这说明两种模式虽然 UI 不同,但底层状态组织方式是一致的:

  • 都从 AppState 出发
  • 都使用同一个 store 原语
  • 都复用同一个 onChangeAppState

也就是说,项目并没有把“交互式 UI 状态”和“headless/SDK 状态”拆成两套完全不同的机制。

5. 第 4 层:状态更新之后,副作用往哪里走

src/state/onChangeAppState.ts 是理解这套系统的关键文件。

它不是 reducer,而是“状态变化后的副作用汇聚点”。

换句话说:

  • setState 只负责把状态从 A 变到 B
  • onChangeAppState 负责观察 oldState -> newState 的变化,并把需要的副作用同步出去

5.1 它负责哪些同步

从当前实现看,主要包括这几类:

  • 权限模式变化:向 CCR / SDK 状态通道同步 permission mode
  • 模型配置变化:把 mainLoopModel 写回 settings / bootstrap override
  • 全局配置持久化:例如 expandedView、verbose、tungstenPanelVisible
  • settings 变化后的刷新:清理 auth 相关 cache,并重新应用环境变量

这带来一个很重要的好处:

  • 状态更新逻辑可以尽量保持纯粹
  • 副作用不必散落在每个 setState 调用点

对于大型项目来说,这种“单一副作用出口”比到处手写同步逻辑更稳。

6. 为什么要避免 selector 返回新对象

useAppState 订阅切片时,比较依据是 Object.is。
因此它天然偏向“引用稳定”的子对象或标量值。

这也是 AppState.tsx 里明确强调的约束:

  • 不要让 selector 直接返回新对象
  • 要尽量选择已有引用

例如推荐:

const promptSuggestion = useAppState(s => s.promptSuggestion)

而不是:

const value = useAppState(s => ({ text: s.promptSuggestion.text }))

原因很简单:后一种写法每次都会生成新对象,Object.is 一定判定为变化,订阅优化就失效了。

7. 不可变更新约定:为什么 Set / Map 要新建引用

这套状态体系默认遵循不可变更新约定。
也就是说,更新时通常会:

  • 返回新的顶层对象
  • 对被修改的子对象创建新引用

这一点在普通对象上很好理解,在 Set / Map 上尤其重要。

例如:

  • AppState 里有 agentNameRegistry: Map<string, AgentId>
  • activeOverlays: ReadonlySet<string>

如果直接原地修改这些结构,外层引用不变,订阅方就可能感知不到变化。
所以像 src/context/overlayContext.tsx 这类代码会显式:

  1. 用旧的 Set 构造一个新的 Set
  2. 在新引用上 add / delete
  3. 再把新引用写回 state

这和 React 社区常见的不可变更新原则是一致的,只是这里不依赖 Immer 之类工具,而是手工维持引用边界。

8. 局部专用 store:不是所有状态都进 AppState

这个仓库没有把所有状态都塞进全局 AppState,而是明确保留了一些领域专用状态容器。

这不是分裂,而是分层。

8.1 src/context/voice.tsx:局部专用 store

voice.tsx 的实现几乎就是 AppState 模式的缩小版:

  • 仍然用 createStore(DEFAULT_STATE)
  • 仍然用 Context 注入 store
  • 仍然用 useSyncExternalStore 做切片订阅

它提供了:

  • useVoiceState(selector):响应式读取
  • useSetVoiceState():只写不订阅
  • useGetVoiceState():命令式读取最新语音状态

之所以不把这些字段直接并入 AppState,是因为它只服务语音域,独立维护更清晰,也避免让全局状态继续膨胀。

8.2 src/context/stats.tsx:指标收集 store

stats.tsx 维护的是另一种完全不同的状态:

  • counter
  • gauge
  • histogram
  • set

它的目标不是驱动 UI 响应式刷新,而是收集指标,并在进程退出时 flush 到配置中。
因此它虽然也是一个 store,但职责更像 telemetry accumulator,而不是应用会话状态中心。

8.3 src/hooks/useTasksV2.ts:模块级单例 store

useTasksV2.ts 更有代表性。
它既不走 AppState,也不是简单 Context,而是一个模块级单例 store:

  • 内部维护 TasksV2Store
  • 用 fs.watch、fallback poll、debounce timer 管理任务列表
  • 用 useSyncExternalStore 暴露快照

这么做的直接收益是:

  • 多个组件共享同一份 watcher
  • 避免 spinner / footer / REPL 分别监听同一目录
  • 让“文件系统驱动的状态”独立于主 AppState

这类状态更适合“模块自管理 + React 订阅”,而不是硬塞进全局 store。

9. 轻量 Context:只做局部依赖注入,不做全局状态总线

还有一类状态更轻,只需要局部作用域,不值得进 AppState,也不需要专门 store。

9.1 src/context/modalContext.tsx

ModalContext 提供的是 modal 内可用的:

  • rows
  • columns
  • scrollRef

这是纯布局上下文。
它解决的是“组件当前是否在 modal slot 内渲染、可用空间多大”的问题,不属于会话全局状态。

9.2 src/context/mailbox.tsx

MailboxProvider 只是创建一个稳定的 Mailbox 实例并通过 Context 分发。
这里的重点是共享一个对象实例,而不是建立一个可订阅的全局状态树。

9.3 src/context/promptOverlayContext.tsx

这个 Context 更像 prompt 上层浮层的 portal 协调器。

它把状态拆成:

  • 数据 Context
  • setter Context
  • dialog Context
  • dialog setter Context

这样写的目的不是做复杂状态管理,而是:

  • 让读取者拿到当前 overlay 数据
  • 让写入者拿到稳定 setter
  • 避免写入者因为自己的写入再次重渲染

这是一种非常典型的“局部 UI 协调 Context”用法。

10. selectors.ts 说明:项目允许“派生读取”,但保持简单

src/state/selectors.ts 里已经开始出现一些显式 selector,例如:

  • getViewedTeammateTask(...)
  • getActiveAgentForInput(...)

这说明项目并不反对派生状态,但它对 selector 的要求很明确:

  • 保持纯函数
  • 只做数据提取和轻量判断
  • 不承担副作用

因此这套体系更接近“轻量 selector + 外部 store + hook 订阅”,而不是完整的 Redux selector 生态。

11. 回答三个最核心的问题

11.1 全局状态存在哪里

全局状态定义在 src/state/AppStateStore.ts 的 AppState 中,运行时 store 由 src/state/AppState.tsx 或 src/main.tsx 基于 createStore(...) 创建。

11.2 组件和非组件代码分别怎么读写状态

组件里主要用:

  • useAppState(selector):读切片
  • useSetAppState():写状态

非组件或异步逻辑里主要用:

  • useAppStateStore() 拿到 store
  • 再通过 store.getState() / store.setState() 命令式读写

11.3 哪些状态为什么没有放进 AppState

主要有三类:

  • 领域专用 store:如 voice.tsx,只服务单个能力域
  • 模块级单例 store:如 useTasksV2.ts,更适合自己管理 watcher / timer / snapshot
  • 轻量 Context:如 modalContext.tsx、mailbox.tsx、promptOverlayContext.tsx,只做局部依赖注入,不需要全局总线

12. 最后的架构判断

把这套实现压缩成一句话,就是:

Claude Code 的状态管理不是“一个框架接管全部状态”,而是“用最小外部 store 承担全局会话状态,再用专用 store 和轻量 Context 承担局部领域状态”。

这种设计的实际收益是:

  • 组件订阅粒度细
  • 非 React 逻辑也能稳定读写状态
  • 副作用出口集中
  • 大量局部状态不必挤进全局容器

对应的代价也很明显:

  • 需要团队自觉维护不可变更新约定
  • 需要手动控制 selector 粒度与引用稳定性
  • 需要清楚区分“该进 AppState”和“该局部隔离”的边界

但从当前仓库实现看,这种取舍是明确而一致的。

可观测性基础设施:Claude Code 如何观测自身运行

这篇文档只回答一个问题:

src/ 里的可观测性基础设施,到底是怎样分层、初始化、采集、脱敏并导出的?

先给结论:

  • 这套实现不是一个单点 telemetry 模块,而是四层协作:
    • src/services/analytics/:业务事件采集与分发
    • src/utils/telemetry/:OpenTelemetry 指标、日志、追踪与导出器
    • src/bootstrap/state.ts:全局 meter / logger / tracer / counter 状态中枢
    • src/context/stats.tsx:会话内轻量统计与本地落盘
  • 系统同时维护多种信号类型,而不是把所有信息都塞进同一条管线:
    • analytics event:面向产品行为、权限决策、功能使用
    • metrics:面向会话级计数、成本、token、活跃时长
    • logs / events:面向 OTel 事件记录和内部事件批量上报
    • traces:面向 interaction / tool / LLM request 的时序链路
  • 这套实现非常强调隐私边界:
    • 默认不允许任意字符串直接进入 analytics metadata
    • MCP/tool 名称、prompt 内容、workspace 路径都要经过显式门控
    • _PROTO_* 字段只允许流向 1P 特权后端,不会进入 Datadog 这类通用后端

可以先把整体链路记成下面这条:

调用点
  -> analytics / OTel API
  -> sink / provider / state
  -> exporter
  -> Datadog / 1P event logging / OTLP / BigQuery / Perfetto / 本地 session metrics

1. 边界:可观测性不是一个目录,而是一组协作层

如果只看目录名,很容易把可观测性理解成 services/analytics/。

但真实实现更接近“四层拼接”:

1.1 第一层:services/analytics/ 负责业务事件

这一层关注的是:

  • 用户做了什么
  • 权限如何被批准或拒绝
  • API 请求是否成功
  • 插件、Skill、MCP 等能力是否被启用

它的统一入口是 src/services/analytics/index.ts,核心 API 只有:

  • logEvent()
  • logEventAsync()
  • attachAnalyticsSink()

也就是说,这一层的抽象不是“日志行”,而是“业务事件”。

1.2 第二层:utils/telemetry/ 负责 OTel 信号

这一层处理的是 OpenTelemetry 语义下的三种核心信号:

  • metrics
  • logs / event
  • traces

对应的核心文件包括:

  • src/utils/telemetry/instrumentation.ts
  • src/utils/telemetry/events.ts
  • src/utils/telemetry/sessionTracing.ts
  • src/utils/telemetry/bigqueryExporter.ts
  • src/utils/telemetry/perfettoTracing.ts
  • src/utils/telemetry/betaSessionTracing.ts

它不是“业务埋点系统”的附庸,而是一套独立的运行时遥测基础设施。

1.3 第三层:bootstrap/state.ts 负责全局观测状态注入

src/bootstrap/state.ts 并不直接导出 exporter,但它保存了:

  • meter
  • sessionCounter
  • locCounter
  • prCounter
  • commitCounter
  • costCounter
  • tokenCounter
  • codeEditToolDecisionCounter
  • activeTimeCounter
  • loggerProvider
  • eventLogger
  • meterProvider
  • tracerProvider
  • statsStore

所以它是“观测运行时句柄”的集中注册表。

1.4 第四层:context/stats.tsx 负责会话内轻量统计

src/context/stats.tsx 这层不是标准 OTel,也不是远端 analytics。

它更像一个:

  • 进程内统计 accumulator
  • UI 渲染性能采样器
  • session 结束时落盘的本地 metrics store

这层解释了为什么仓库里既有 OTel counter,又有 StatsStore.observe()。

两者不是重复设计,而是分工不同。

2. 业务事件链路:logEvent 如何走到后端

2.1 src/services/analytics/index.ts 是统一入口

这一层最关键的设计是“先记事件,再挂 sink”。

logEvent() 和 logEventAsync() 不直接依赖 Datadog 或 1P logger,而是:

  1. 如果 sink 还没挂上,就把事件放进内存队列
  2. 等 attachAnalyticsSink() 被调用后,再异步 drain 这些事件

这意味着 analytics API 本身被做成了一个无依赖、低耦合的门面层:

  • 避免 import cycle
  • 允许启动早期先记录事件
  • 把真正的路由逻辑延迟到应用初始化完成之后

2.2 src/utils/sinks.ts 负责真正挂接 sink

initSinks() 会按顺序做两件事:

  1. initializeErrorLogSink()
  2. initializeAnalyticsSink()

这里的顺序不是随意的。errorLogSink 注释里明确要求它在 analytics sink 之前初始化,这样 analytics 路由过程中产生的问题也有地方可记。

2.3 src/services/analytics/sink.ts 负责 fanout

这个模块把业务事件从统一入口分发到两个后端:

  • Datadog
  • 1P event logging

它承担的不是单纯“转发”,而是整个 analytics dispatch policy:

  • shouldSampleEvent():按 event name 做采样
  • initializeAnalyticsGates():初始化 Datadog gate
  • isSinkKilled():按 sink 维度做 killswitch
  • stripProtoFields():在通用后端前剥离 _PROTO_* 字段

也就是说,真正的 analytics 规则集中在这里,而不是散落在调用点。

2.4 Datadog 与 1P 后端不是对称关系

src/services/analytics/datadog.ts 和 src/services/analytics/firstPartyEventLogger.ts 看起来都在“发事件”,但角色不同。

datadog.ts 的特点:

  • 只接受 allowlist 中的事件名
  • 只在 production 且 provider 为 firstParty 时发送
  • 事件会做 cardinality reduction
  • _PROTO_* 字段会在进入 Datadog 前被剥离
  • 以批量 HTTP POST 的方式发到 Datadog logs intake

firstPartyEventLogger.ts 的特点:

  • 使用独立的 LoggerProvider
  • 通过 FirstPartyEventLoggingExporter 批量发到 /api/event_logging/batch
  • 支持采样配置、批处理配置、GrowthBook refresh 后重建 provider
  • 失败事件可以落盘并重试

它们的关系更像:

  • Datadog:通用事件分析后端
  • 1P event logging:内部特权事件管线

2.5 初始化时序上,1P event logging 甚至早于完整 telemetry

src/entrypoints/init.ts 在 init() 里就会异步加载:

  • firstPartyEventLogger.js
  • growthbook.js

然后调用 initialize1PEventLogging()。

这样做的意义是:

  • 即使完整 OTel telemetry 还没初始化
  • 甚至 trust 后才初始化的那部分 telemetry 还未就绪
  • 1P internal event logging 也可以更早开始工作

这能减少启动早期业务事件丢失。

3. OTel 基础设施:metrics、logs、traces 如何装起来

3.1 src/utils/telemetry/instrumentation.ts 是 OTel 装配核心

这个文件负责:

  • 读取 OTEL_* 环境变量
  • 解析 metrics / logs / traces exporter 类型
  • 动态导入不同 OTLP exporter
  • 合并 resource 信息
  • 初始化 MeterProvider、LoggerProvider、BasicTracerProvider
  • 注册 shutdown / forceFlush 逻辑
  • 初始化 Perfetto tracing

这里有两个重要特征。

第一,它大量使用 lazy import。

目的很明确:

  • 避免 OpenTelemetry 和 gRPC 依赖在每次启动时都被提前加载
  • 只有真正启用某类 exporter 时,才把对应实现拉进来

第二,它把 customer OTLP telemetry 与 1P event logging 分开装配。

firstPartyEventLogger.ts 使用的是自己的 LoggerProvider,并不会注册为全局 provider;而 instrumentation.ts 初始化的是 customer-facing 的 OTel provider。

这是一条明确的数据隔离边界。

3.2 src/entrypoints/init.ts 负责“信号装配”和“状态映射”

initializeTelemetryAfterTrust() 是完整 telemetry 初始化的入口。

它的时序不是“程序一启动就立刻做”,而是:

  • 对 remote managed settings 用户,先等设置加载,再重新应用 env vars,再做 telemetry init
  • 对普通路径,则直接初始化
  • 在 headless + beta tracing 的场景下,还会走 eager init,避免首个 query 开始时 tracer 还没准备好

真正的初始化发生在 setMeterState():

  1. 动态导入 initializeTelemetry()
  2. 拿到 meter
  3. 基于 meter.createCounter() 构造 attributed counter 工厂
  4. 调用 setMeter() 把这些 counter 注册到 bootstrap/state.ts
  5. 补记一次 sessionCounter

所以 init.ts 的角色不是 exporter 本身,而是把 exporter 产出的运行时对象接入全局状态。

3.3 src/bootstrap/state.ts 是 OTel 运行时句柄的集中存储

setMeter() 会一次性构造多个标准计数器:

  • claude_code.session.count
  • claude_code.lines_of_code.count
  • claude_code.pull_request.count
  • claude_code.commit.count
  • claude_code.cost.usage
  • claude_code.token.usage
  • claude_code.code_edit_tool.decision
  • claude_code.active_time.total

除此之外,bootstrap/state.ts 还提供:

  • setLoggerProvider() / getLoggerProvider()
  • setEventLogger() / getEventLogger()
  • setMeterProvider() / getMeterProvider()
  • setTracerProvider() / getTracerProvider()

这使得业务代码不需要自己持有 provider,只需要从 state 中取句柄即可。

3.4 BigQuery metrics 是 metrics 管线的一部分,不是 analytics fanout 的一部分

src/utils/telemetry/bigqueryExporter.ts 实现了 PushMetricExporter,会把 OTel metrics 转成内部 API 可接受的 payload,然后发到 /api/claude_code/metrics。

它的特点是:

  • 要求 trust 已建立或处于 non-interactive session
  • 会检查组织级 metrics opt-out
  • 导出的是 metrics,不是业务事件
  • 使用 delta temporality,明确不走 cumulative

所以这条链路属于 metrics exporter,而不是 logEvent -> sink 那条业务事件链路。

4. 会话内统计:为什么还需要 StatsStore

4.1 src/context/stats.tsx 是一套轻量本地统计系统

这里的 StatsStore 支持四种操作:

  • increment()
  • set()
  • observe()
  • add()

其中 observe() 不是简单相加,而是维护一个 histogram:

  • count
  • sum
  • min
  • max
  • reservoir sample

最终还能导出:

  • p50
  • p95
  • p99

4.2 它的核心用途是会话内统计和本地持久化

StatsProvider 会在进程退出时把 store.getAll() 的结果写入配置中的 lastSessionMetrics。

所以它更像:

  • 本地 session summary
  • UI / TUI 运行时指标
  • 调试和回看辅助数据

而不是标准 OTel exporter 的替代品。

4.3 StatsStore 与 OTel counter 的职责不同

StatsStore 擅长:

  • 轻量、局部、进程内聚合
  • percentile 近似统计
  • 会话结束后本地落盘

OTel counter 擅长:

  • 远端导出
  • 标准语义指标
  • 带 attributes 的统一计量

所以可以把它们理解为:

  • StatsStore:本地运行态观测
  • OTel metrics:标准化外部观测

4.4 交互态会显式把 StatsStore 注入运行时

src/interactiveHelpers.tsx 的 getRenderContext() 会:

  1. createStatsStore()
  2. setStatsStore(stats)
  3. 把 stats 交给 App 组件树

src/components/App.tsx 再通过 StatsProvider 注入 React 树。

这说明 StatsStore 是交互态 runtime 的一等公民,而不是某个页面私有的小工具。

5. Tracing 与 OTel 事件:运行链路如何被展开

5.1 src/utils/telemetry/sessionTracing.ts 负责 span 生命周期

这里定义的核心 span 类型包括:

  • interaction
  • llm_request
  • tool
  • tool.blocked_on_user
  • tool.execution
  • hook

这说明 tracing 不是只关心 API,而是把一次完整交互拆成多个运行阶段。

这个模块还做了几件非常关键的事:

  • 用 AsyncLocalStorage 保存当前 interaction / tool 上下文
  • 给 orphan span 做 TTL 清理,避免内存泄漏
  • 同时兼容 OTel tracing 与 Perfetto tracing
  • 在 enhanced telemetry 和 beta tracing 两种模式下复用同一套 span 入口

5.2 src/utils/telemetry/events.ts 负责 OTel 事件日志

logOTelEvent() 不是业务 analytics,而是 OTel event record。

它会为每条事件补充:

  • 通用 telemetry attributes
  • event.name
  • event.timestamp
  • event.sequence
  • prompt.id

此外还有一个非常重要的限制:

  • workspace.host_paths 只写入 event attributes,不进入 metric dimensions

这体现了仓库对高基数路径字段的谨慎处理。

5.3 代表性调用点 1:用户输入

src/utils/processUserInput/processTextPrompt.ts 在处理用户输入时同时做了三件事:

  1. startInteractionSpan(userPromptText)
  2. logOTelEvent('user_prompt', ...)
  3. logEvent('tengu_input_prompt', ...)

这非常能说明该仓库的设计风格:

  • 同一个用户动作,可能同时产出 trace、OTel event、analytics event
  • 但它们分别进入不同管线,承担不同用途

5.4 代表性调用点 2:API 请求

src/services/api/logging.ts 在 API 成功路径里会:

  • 记录 analytics 事件,例如 tengu_api_query、成功/失败事件
  • 记录 OTel 事件 api_request
  • 结束 LLM request span,并把 token、TTFT、输出信息挂到 span 上

这里可以清楚看到:

  • analytics 负责产品/行为语义
  • OTel event 负责结构化事件
  • tracing 负责时序与上下文

5.5 代表性调用点 3:工具权限与执行

src/hooks/toolPermission/permissionLogging.ts 和 src/services/tools/toolExecution.ts 共同覆盖了工具权限决策链路。

这里会同时写入:

  • analytics event,例如批准/拒绝事件
  • OTel event tool_decision
  • code edit tool counter

而 toolExecution.ts 还会在 headless 等非交互路径下补发这些信号,避免因为没有走 UI permission path 而丢观测数据。

这说明观测逻辑并不是只写在 UI 交互层,而是会在执行层补齐。

6. 隐私与数据治理:哪些内容能上报,哪些不能

6.1 metadata.ts 是 analytics 元数据的中心治理层

src/services/analytics/metadata.ts 做的不只是“补字段”,更像一个集中式 policy 层。

它负责:

  • 构造公共 metadata
  • 识别运行环境、平台、subscription、agent 身份
  • 处理 MCP/tool 名称脱敏
  • 处理文件扩展名提取
  • 处理 bash 命令里可安全提取的扩展名
  • 控制 tool input 在 telemetry 中的截断与深度

这比“调用点自己拼 metadata”要成熟得多。

6.2 类型标记本身就是一层制度约束

src/services/analytics/index.ts 和 metadata.ts 都定义了 marker type,例如:

  • AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  • AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED

这些类型不会在运行时做校验,但在代码层表达了一个重要制度:

  • 普通 analytics metadata 默认不应携带代码、路径或敏感原文
  • 只有被显式标记为 PII-tagged 的字段,才允许进入特定后端

6.3 _PROTO_* 是一条双通道设计

这套实现里最关键的隐私机制之一,就是 _PROTO_* 字段。

它的工作方式是:

  1. 调用点可以把原始值放进 _PROTO_*
  2. stripProtoFields() 会在发往 Datadog 等通用后端前剥离这些字段
  3. firstPartyEventLoggingExporter 会识别并提升这些字段,送进 1P 特权 proto 列

所以同一条事件可以同时拥有:

  • 通用后端可见的脱敏字段
  • 仅 1P 后端可见的原始字段

这就是典型的 twin-column / dual-path 设计。

6.4 MCP/tool 名称默认不是随便上报的

默认情况下:

  • MCP tool 名称会被 sanitizeToolNameForAnalytics() 归一成 mcp_tool
  • 只有满足特定条件时,才会通过 mcpToolDetailsForAnalytics() 记录更细颗粒度的 MCP server / tool 名称

这些条件包括:

  • local-agent 模式
  • claude.ai proxy connector
  • official registry 中的 MCP URL

也就是说,用户自定义 MCP 配置默认不会被当成普通低敏字段直接上报。

6.5 prompt、tool input、workspace path 都有显式门控

几个典型例子:

  • src/utils/telemetry/events.ts
    • 只有 OTEL_LOG_USER_PROMPTS=1 时才会记录真实 prompt,否则写 <REDACTED>
  • src/services/analytics/metadata.ts
    • 只有 OTEL_LOG_TOOL_DETAILS=1 时才会记录序列化后的 tool input
    • 而且会做长度截断、深度限制、键过滤
  • src/utils/telemetry/events.ts
    • workspace.host_paths 只进入 event,不进入 metric dimensions

这说明仓库对“可观测性”和“可泄漏性”是明确区分的。

7. 初始化顺序:这套基础设施何时变得可用

把关键顺序串起来,大致是这样:

程序启动
  -> init()
  -> 异步启动 1P event logging 与 GrowthBook
  -> preAction / setup 挂 error log sink 和 analytics sink
  -> trust 建立后执行 initializeTelemetryAfterTrust()
  -> initializeTelemetry()
  -> setMeterState()
  -> bootstrap/state 注册 meter / logger / tracer / counters
  -> 调用点开始持续写入 analytics / OTel / traces

这里有三个时序点值得特别记住。

第一,analytics API 可以早于 sink 调用。

因为 logEvent() 会先排队,等 sink 挂载后再 drain。

第二,1P event logging 可以早于完整 OTel telemetry。

因为 init() 里就已经启动了它。

第三,完整 telemetry 初始化要等 trust 和 remote managed settings 条件满足。

这也是为什么 initializeTelemetryAfterTrust() 不只是一个简单的“开关函数”,而是一段带条件和时序语义的编排逻辑。

8. 总结:这是一套分层而非单点的可观测性体系

最后可以把整个体系压缩成一句话:

这个仓库把“可观测性”拆成了三条主链路,再用全局 state 把它们接在一起:

  • services/analytics/:面向业务事件与产品行为
  • utils/telemetry/:面向 OTel 指标、事件、追踪与导出
  • context/stats.tsx:面向交互态本地统计与会话总结

它们共享一些调用点,但不会被粗暴混成一个系统。

所以如果要理解仓库里的 observability,最重要的不是记住某个 exporter,而是先分清三个问题:

  1. 这是 analytics event、metric、OTel event,还是 trace?
  2. 它进入的是通用后端、1P 特权后端,还是只留在本地 session?
  3. 它在上报前经过了哪些脱敏、采样、gate 和 killswitch?

把这三个问题想清楚,src/ 里的可观测性实现就基本能读通了。

Agent Loop 主循环:src/query.ts 如何驱动一次完整 Agent 回合

这篇文档只回答一个问题:

src/query.ts 是怎样把“上下文预处理、模型流式采样、工具执行、恢复重试、stop hooks、预算控制、下一轮继续”串成一个完整 agent loop 的?

先给结论:

它不是一个“模型返回了 tool_use 就递归再调一次”的轻量封装,而是一个显式的状态循环。每一轮都会经历:

准备本轮状态
  -> 请求前预处理上下文
  -> 调用模型并流式接收 assistant 输出
  -> 判断是否需要恢复 / 是否需要工具跟进
  -> 执行工具并回写 tool_result / attachment
  -> 注入额外上下文与预算控制
  -> 决定继续下一轮还是终止

因此,把 query.ts 理解成“模型调用器”是不够的。更准确的说法是:

  • query.ts 是会话级 agent runtime 的主循环
  • 它维护的是一个跨多轮迭代的显式状态机
  • 工具调用只是其中一个分支,不是全部

1. query() 与 queryLoop() 的职责分工

对外暴露的是 query(params),但真正的主循环在 queryLoop(params, consumedCommandUuids)。

两者职责分得很清楚:

  • query() 是外层包装器
  • queryLoop() 是实际执行每一轮 agent 回合的主体

1.1 query() 做什么

query() 本身非常薄,核心逻辑只有两件事:

  1. 创建 consumedCommandUuids
  2. yield* queryLoop(...)

只有当 queryLoop() 正常返回时,query() 才会补发这批命令的 notifyCommandLifecycle(uuid, 'completed')。
这意味着:

  • 如果 queryLoop() 抛错,completed 不会被补发
  • 如果 generator 被外部 .return() 提前关闭,也不会走到这个补发逻辑

所以 query() 更像“生命周期收尾包装层”,不是实际的 agent loop。

1.2 queryLoop() 做什么

queryLoop() 才是完整闭环:

  1. 初始化跨轮状态
  2. 在 while (true) 中一轮轮推进
  3. 在不同条件下 continue 到下一轮
  4. 在终止条件满足时 return 一个 terminal reason

这里最关键的一点是:它没有把“继续下一轮”编码成函数递归,而是编码成显式状态迁移。
这使得恢复路径、预算续轮、stop hook 重试、compact 后重试都能共享同一个循环框架。

2. 循环状态模型:这是显式状态机,不是递归套娃

queryLoop() 在入口定义了一个 State,并把它保存在局部变量 state 里。每一轮循环开始时先解构状态,再根据本轮结果构造下一个 state。

核心字段如下:

字段作用
messages当前这轮看到的会话消息基线。每次继续下一轮时,都会把 assistant 输出、tool result、attachment 等并回这里。
toolUseContext工具运行时上下文,包含工具列表、app state、abortController、agent 信息、MCP 信息等。
autoCompactTracking记录最近一次 compact 后的追踪信息,避免 compact 行为失控,也为统计和后续轮次提供上下文。
maxOutputTokensRecoveryCount记录本轮因 max_output_tokens 已经恢复过几次,用于限制自动续写次数。
hasAttemptedReactiveCompact标记这一轮是否已经试过 reactive compact,防止 prompt-too-long / media error 场景反复压缩重试。
maxOutputTokensOverride在 max_output_tokens 触发时,临时把输出上限从默认值提升到更高额度。
pendingToolUseSummary上一轮工具批次的摘要 Promise,本轮开始时再异步消费,避免阻塞下一次采样。
stopHookActive标记 stop hook 阻塞错误是否已经触发过,用于 stop hook 重试路径。
turnCount当前已经推进到第几轮内部回合,用来做 maxTurns 判断。
transition上一轮为什么继续。它不参与主逻辑计算,但对恢复路径非常关键。

2.1 为什么 transition 很重要

transition 是这份实现里很容易被忽略、但非常关键的一个字段。它显式记录“上一轮是因为什么继续的”,例如:

  • collapse_drain_retry
  • reactive_compact_retry
  • max_output_tokens_escalate
  • max_output_tokens_recovery
  • stop_hook_blocking
  • token_budget_continuation
  • next_turn

这让主循环不仅知道“要继续”,还知道“为什么继续”。
例如 prompt too long 恢复时,会先尝试 context collapse drain;如果上一轮已经因为 collapse_drain_retry 继续过,这一轮就不会重复 drain,而是直接落到下一层恢复逻辑。

2.2 这套状态模型解决了什么问题

它本质上解决的是:一次用户 turn 并不总是“请求一次模型 -> 得到一次回答 -> 结束”。

在这个项目里,一次 turn 可能会被延长成很多个内部子轮次,因为系统可能需要:

  • 继续执行工具
  • 在 compact 后重试
  • 在 output token 截断后续写
  • 在 stop hook 阻塞后追加 meta message 再重试
  • 在 token budget 认为还有必要时继续推进

所以 State 不是普通缓存,而是整个 agent loop 的控制平面。

3. 每轮请求前的预处理链

每轮真正调用模型之前,query.ts 都会先重建一份 messagesForQuery,然后沿着固定顺序对上下文做预处理。

顺序大致如下:

messages
  -> getMessagesAfterCompactBoundary()
  -> applyToolResultBudget()
  -> HISTORY_SNIP(可选)
  -> microcompact
  -> CONTEXT_COLLAPSE(可选)
  -> autocompact
  -> 调用模型

这条链路非常重要,因为它说明“agent loop 的第一步不是问模型”,而是先整理模型即将看到的上下文。

3.1 getMessagesAfterCompactBoundary()

每一轮先从 state.messages 中只截取最后一个 compact 边界之后的活跃消息段。
这样后续所有处理都围绕当前上下文段展开,不会反复把已经被摘要过的旧历史再送进来。

3.2 applyToolResultBudget()

在真正 compact 之前,先控制单条工具结果的体积。
这一层做的是“工具结果预算裁剪”,不是语义摘要。目标是避免极端长的 tool_result 把当前轮上下文直接撑爆。

这里还有一个细节:某些 query source 会把 content replacement 记录持久化下来,方便 agent resume 或主线程恢复时复用。

3.3 HISTORY_SNIP

若功能开启,会先做 snip,把低价值历史从模型可见视图里移除,但不等于把 UI 上的完整历史全部删掉。
snip 还会返回 snipTokensFreed,后面的 autocompact 阈值判断会把这部分收益算进去。

3.4 microcompact

这是完整 autocompact 之前的轻量减负层。
这里的核心思想不是“总结上下文”,而是优先处理高成本工具结果,例如:

  • 用 cache edits 删除旧工具结果缓存
  • 或在必要时清空老工具结果正文

因此 microcompact 更像是“削峰”,而不是“摘要”。

3.5 CONTEXT_COLLAPSE

如果启用 context collapse,会先把部分历史折叠为投影视图。
它在 autocompact 之前执行,是因为如果 collapse 已经把上下文压到安全区间,就没必要再做一次更激进的 summary compact。

这一层的重点不是直接向 transcript 里插入新消息,而是调整“本轮 query 实际看到的上下文视图”。

3.6 autocompact

最后才轮到完整自动压缩。
如果 autocompact 成功,query.ts 会:

  • 记录 compact telemetry
  • 必要时更新 taskBudgetRemaining
  • 重置 compact tracking
  • 生成 postCompactMessages
  • 立刻 yield 这些 compact 结果消息
  • 然后用 compact 之后的新消息作为本轮真正送入模型的输入

这意味着 compact 不一定发生在某一轮结束之后,它也可能直接嵌在这一轮请求的前半段。

4. 模型采样与流式阶段

完成预处理后,主循环才会真正调用模型。
这里不是简单 await 一个完整 response,而是进入流式消费阶段。

4.1 deps.callModel() 会携带哪些信息

调用模型时,query.ts 会显式带上当前轮所需的采样参数和运行上下文,包括:

  • messages: prependUserContext(messagesForQuery, userContext)
  • systemPrompt: fullSystemPrompt
  • thinkingConfig
  • tools: toolUseContext.options.tools
  • model: currentModel
  • fallbackModel
  • querySource
  • agents / allowedAgentTypes
  • maxOutputTokensOverride
  • mcpTools
  • effortValue
  • advisorModel
  • taskBudget

这说明 query loop 不是只负责消息收发,它同时也是“本轮模型采样参数的最终装配层”。

4.2 流式处理中维护哪些本轮局部变量

每轮在真正 streaming 前会初始化:

  • assistantMessages
  • toolResults
  • toolUseBlocks
  • needsFollowUp

它们分别对应:

  • 本轮 assistant 实际产出的消息
  • 本轮工具执行后要回写的 user / attachment 结果
  • 本轮发现的所有 tool_use
  • 本轮是否需要在 assistant 输出后继续跟进

其中 needsFollowUp 非常关键。
它是“这一轮是否进入工具分支”的唯一信号。如果 streaming 结束后它还是 false,流程会走“无工具分支”的收尾与恢复逻辑。

4.3 streaming fallback 与 tombstone

模型 streaming 期间如果触发 fallback,当前已经收集到的 assistant partial message 不能直接继续使用。
query.ts 会:

  • 为已产生的 assistant partial message 发出 tombstone
  • 清空 assistantMessages / toolResults / toolUseBlocks
  • 重建 StreamingToolExecutor
  • 切换到 fallback model 重试整个请求

这里 tombstone 的作用是把前一次 streaming 尝试的半成品从 UI 和 transcript 中清掉,避免后续 thinking block 签名不合法。

4.4 withheld error:先不立刻暴露错误

在 streaming 过程中,如果出现某些可恢复错误,query.ts 不会马上把它们作为最终结果暴露出去,而是先“压住”:

  • prompt too long
  • media size error
  • max_output_tokens

原因是这些错误后面仍然可能被恢复逻辑吃掉。如果太早把错误抛给上层,外部调用方可能会以为 turn 已经失败并提前结束监听。

所以这里形成了一个很重要的设计:

  • streaming 阶段负责记录错误
  • streaming 结束后的恢复分支负责决定“继续救”还是“正式对外暴露”

5. 无工具分支的收尾与恢复

如果本轮没有发现 tool_use,并不意味着立刻结束。
此时主循环会进入“无工具分支”的后半段逻辑,这里同样可能继续下一轮。

5.1 prompt too long 的恢复链

当最后一条 assistant 消息是被 withheld 的 413 错误时,恢复路径是:

prompt too long
  -> 先尝试 context collapse drain
  -> 仍不行则尝试 reactive compact
  -> 还不行才正式暴露错误并返回

这里有两个关键点:

  1. collapse drain 比 reactive compact 更轻,优先级更高
  2. hasAttemptedReactiveCompact 和 transition.reason 会一起防止重复重试

因此这不是“报错后简单重试一次”,而是多层恢复策略。

5.2 max_output_tokens 的恢复链

如果最后一条 assistant 消息是 max_output_tokens,恢复分两层:

max_output_tokens
  -> 若当前还没 override,先把输出上限提升到更高额度再重试
  -> 若还会截断,则插入一条 meta recovery message 继续下一轮
  -> 超过恢复上限后才真正把错误暴露出来

那条 recovery message 的作用很明确:要求模型直接续写,不要道歉,不要 recap,而是从被截断处继续往下做。

5.3 API error 会提前终止 stop hooks

如果最终 assistant 消息本质上仍是 API error,那么 query.ts 会直接返回,不再执行正常 stop hooks 判定。
原因也很明确:模型并没有产出一条“有效回答”,这时再让 stop hooks 去评估它,只会制造新的死循环。

5.4 handleStopHooks() 也可能让循环继续

当没有工具调用且没有提前终止时,主循环会执行 handleStopHooks(...)。
它可能产生三类结果:

  • preventContinuation
  • blockingErrors
  • 正常通过

其中最容易忽略的是 blockingErrors。
一旦存在,query.ts 会把这些 blocking error 包装成新的 user/meta message 并回写进 state.messages,然后 continue 到下一轮。
换句话说:

  • 没有 tool_use
  • 没有用户新输入
  • 仍然可能因为 stop hook 阻塞而继续下一轮

5.5 TOKEN_BUDGET 也可能主动续轮

stop hooks 之后,如果 TOKEN_BUDGET 开启,query.ts 会调用 checkTokenBudget(...)。
预算模块可能返回 continue,这时循环会:

  • 记录 continuation count
  • 生成一条 meta nudge message
  • 把它拼回 messages
  • 再继续下一轮

所以“没有 tool_use 不代表 turn 结束”这个判断,在这里再次成立。

6. 有工具分支的执行与续轮

如果 streaming 阶段捕获到了 tool_use,就会进入工具执行分支。

6.1 两条工具执行路径

当前实现支持两种路径:

  • StreamingToolExecutor
  • runTools(...)

前者用于边 streaming 边完成部分工具执行;后者是传统的批处理执行入口。

如果你想看这条路径的细节,推荐直接对照 docs/工具调用.md 里的“9. 流式工具执行”一节阅读。
那一节会专门展开它的队列模型、并发规则、结果回灌方式,以及用户中断 / Bash sibling error / fallback 时的处理逻辑。

所以工具不是一定要在 assistant 全部输出完后才统一跑完。
如果 streaming tool execution 打开,一部分工具结果可以更早完成并提前进入 transcript。

6.2 工具执行如何回写

不管是哪条路径,主循环都会消费 toolUpdates。每个 update 可能带:

  • message
  • newContext

如果有 message:

  • 立刻 yield
  • 再通过 normalizeMessagesForAPI() 转成 API 可接受的 user 消息
  • 推入 toolResults

如果有 newContext:

  • 用它更新 updatedToolUseContext

这说明工具执行不仅会产出 tool_result,也可能直接修改后续轮次看到的 runtime context。

6.3 tool use summary 是异步挂到下一轮的

工具批次执行完后,主循环可能异步触发 generateToolUseSummary(...)。
这个摘要不会阻塞当前轮,而是存进 nextPendingToolUseSummary,等下一轮开始时再消费并 yield。

这是一种很典型的“把非关键路径工作藏在下一轮空档里”的设计。

6.4 attachment 注入是工具分支的重要后处理

工具执行后,query.ts 还会补一整层 attachment 注入:

  • queued command snapshot 转 attachment
  • memory prefetch consume
  • skill discovery prefetch consume

这些内容都会被追加到 toolResults。
因此下一轮模型看到的并不只有 assistant + tool_result,还可能看到系统额外注入的 attachment。

6.5 为什么 Sleep 会影响 queued command drain

代码里会检查本轮工具中是否跑过 SleepTool。
如果跑过 sleep,队列里的命令会以更保守的优先级被 drain。这个细节反映的是:某些工具会改变“后台通知什么时候适合送进下一轮上下文”的时机策略。

6.6 进入下一轮前还会刷新工具集合

如果 refreshTools() 存在,主循环会在续轮前重新刷新一次工具列表。
这样新连接上的 MCP server 或动态变化的工具池,就能在下一轮立即对模型可见。

6.7 最后的续轮写回

当工具执行和附件注入完成后,主循环会检查:

  • 是否 abort
  • 是否被 hook attachment 阻止继续
  • 是否超过 maxTurns

如果都没有触发终止,它会构造新的 State:

  • messages = [...messagesForQuery, ...assistantMessages, ...toolResults]
  • toolUseContext = toolUseContextWithQueryTracking
  • pendingToolUseSummary = nextPendingToolUseSummary
  • turnCount = turnCount + 1
  • transition = { reason: 'next_turn' }

然后回到 while (true) 顶部,开始下一轮。

6.8 三类“继续下一轮”的原因

从整个实现看,继续下一轮大致有三类原因:

类别触发条件典型例子
模型要求继续模型显式产生 tool_useassistant 请求调用 Bash、FileEdit、MCP Tool
系统要求继续系统恢复或控制逻辑主动触发 continuecompact 重试、max output recovery、stop hook blocking、token budget continuation
正常续轮工具已经执行完,需要带着 tool_result 和 attachment 回到模型next_turn

这张表很重要,因为它说明“下一轮”并不只由模型决定,系统本身也会主动制造下一轮。

7. 终止条件与阅读心智模型

最终,queryLoop() 会在不同路径下返回不同 terminal reason。常见终止原因包括:

  • blocking_limit
  • image_error
  • model_error
  • aborted_streaming
  • prompt_too_long
  • stop_hook_prevented
  • hook_stopped
  • aborted_tools
  • max_turns
  • completed

这些返回值共同回答的是:“这次 agent loop 为什么停止在这里?”

7.1 一个简化心智模型

可以把 src/query.ts 想成下面这个状态机:

while (true):
  1. 从 state 取出当前 messages / context / counters
  2. 在请求前先做上下文预处理与 compact
  3. 流式调用模型,累计 assistantMessages / toolUseBlocks
  4. 如果没有 tool_use:
       4.1 先跑错误恢复
       4.2 再跑 stop hooks / token budget
       4.3 决定 return 或 continue
  5. 如果有 tool_use:
       5.1 执行工具
       5.2 回写 tool_result / attachment / newContext
       5.3 检查 abort / hook stop / maxTurns
       5.4 写回 state 并继续下一轮

如果再压缩成一句话,这个 agent loop 的本质就是:

request -> stream -> decide -> recover/execute -> enrich -> continue or return

这也是阅读 query.ts 时最稳的心智模型。

8. 与其他文档的边界

这篇文档只讲“编排”,不重复展开其他子系统的内部细节。

  • 上下文压缩、microcompact、autocompact 的更细实现,继续看《上下文预处理》
  • 工具注册、工具池组装、tool execution 管线,继续看《工具调用》
  • 更宏观的系统分层和 QueryEngine/REPL 位置,继续看《架构分析》

可以把三篇文档的关系理解为:

《上下文预处理》:query 之前,上下文怎样被整理
《工具调用》:tool_use 之后,工具怎样被执行
《Agent主循环》:这些步骤在 query.ts 里怎样被串成一个完整状态循环

因此,这篇文档关注的不是“单个模块怎么实现”,而是“这些模块在 query.ts 中如何协同工作”。

安全设计

本文总结仓库中“安全层”的实际实现方式。这里的“安全层”不是单独一个模块,而是一套横切系统,覆盖:

  • 工具是否对模型可见
  • 工具调用前是否允许执行
  • 文件/命令/远端桥接是否触达敏感边界
  • 自动模式是否会绕过人工确认
  • 共享记忆是否会把秘密同步出去

核心代码主要分布在:

  • src/utils/permissions/*
  • src/services/tools/*
  • src/tools.ts
  • src/utils/permissions/filesystem.ts
  • src/utils/permissions/yoloClassifier.ts
  • src/bridge/*
  • src/services/teamMemorySync/*

1. 总体设计思路

这套安全设计不是“工具内部各写一套校验”,而是平台统一裁决,分成几层:

  1. 能力暴露层 在模型看到工具列表之前,先按 deny rule 过滤工具,直接缩小能力边界。
  2. 规则判定层 用统一的 ToolPermissionContext、allow/deny/ask 规则和模式状态做首轮裁决。
  3. 工具专属安全层 每个工具在 checkPermissions() 里补充自己的内容级安全检查,例如文件路径、shell 子命令、sandbox 相关判断。
  4. 自动化安全层 auto mode 下再接一层分类器,避免“规则允许但语义危险”的动作直接落地。
  5. 交互审批层 无法自动放行时,进入交互式确认、远端桥接确认,或 headless 下拒绝。
  6. 执行后治理层 Pre/Post hook、拒绝 hook、审计日志、拒绝计数、提示建议,形成闭环。

换句话说,它不是“只有执行前问一次用户”,而是“先缩工具池,再做规则判定,再做语义安全,再决定是否要问用户”。

2. 核心数据结构

2.1 ToolPermissionContext

定义见:

  • src/Tool.ts
  • src/types/permissions.ts

它是整套权限系统的运行时上下文,核心字段包括:

  • mode 当前权限模式,如 default、acceptEdits、plan、bypassPermissions、auto。
  • alwaysAllowRules / alwaysDenyRules / alwaysAskRules 按来源拆分的规则集合。
  • additionalWorkingDirectories 额外加入权限范围的工作目录。
  • isBypassPermissionsModeAvailable 是否允许进入 bypass 模式。
  • isAutoModeAvailable auto mode 是否可用。
  • shouldAvoidPermissionPrompts 当前上下文是否不能弹权限框,例如后台 agent。
  • awaitAutomatedChecksBeforeDialog 是否在显示权限框前先等自动检查完成。
  • strippedDangerousRules 进入 auto mode 时临时剥离的危险 allow 规则。

2.2 规则模型

定义见:

  • src/types/permissions.ts
  • src/utils/permissions/permissionRuleParser.ts

规则由三部分组成:

  • source 来源,例如 userSettings、projectSettings、localSettings、cliArg、session、policySettings。
  • ruleBehavior allow / deny / ask
  • ruleValue 形如 toolName + ruleContent

规则字符串支持:

  • 整个工具级别规则,如 Bash
  • 带内容的规则,如 Bash(npm publish:*)
  • 转义括号内容
  • legacy tool 名映射,避免工具改名后旧规则失效

3. 第一层:先过滤“模型看得到什么”

实现见:

  • src/tools.ts
  • src/utils/permissions/permissions.ts

关键点:

  • filterToolsByDenyRules() 会在工具暴露给模型前,直接删除被 deny rule 命中的工具。
  • 这不仅作用于内建工具,也作用于 MCP 工具。
  • 对 MCP 还支持 server 级别屏蔽,例如 mcp__server 或 mcp__server__*。

这意味着安全边界并不只在“调用时”判断,而是更早在“能力暴露阶段”就生效。模型从一开始就看不到明确被禁用的能力。

4. 第二层:统一权限决策链

主流程在:

  • src/services/tools/toolExecution.ts
  • src/utils/permissions/permissions.ts
  • src/hooks/useCanUseTool.tsx

4.1 调用顺序

一次工具调用的大致顺序是:

  1. 解析输入并做 backfillObservableInput
  2. 执行 PreToolUse hooks
  3. 合并 hook 给出的 updatedInput / permissionResult
  4. 调 hasPermissionsToUseTool()
  5. 若需要,进入 auto classifier 或交互审批
  6. 允许后再真正执行工具
  7. 执行 PostToolUse / PostToolUseFailure hooks

4.2 hasPermissionsToUseToolInner() 的裁决顺序

关键顺序很清晰:

  1. 整个工具 deny rule
  2. 整个工具 ask rule
  3. 工具自己的 checkPermissions()
  4. 工具级 deny 结果
  5. 必须用户交互的工具保持 ask
  6. 内容级 ask rule 保持 ask
  7. safetyCheck 保持 ask
  8. 若是 bypassPermissions,且未命中前面的强约束,则 allow
  9. 若整个工具有 allow rule,则 allow
  10. 剩余的 passthrough 统一转成 ask

这套顺序说明两件事:

  • deny、ask、safetyCheck 的优先级高于 bypass 模式。
  • allow rule 不是无条件生效,它排在工具专属安全检查之后。

5. 第三层:文件系统安全

实现主要在:

  • src/utils/permissions/filesystem.ts
  • src/utils/permissions/pathValidation.ts
  • src/tools/FileReadTool/FileReadTool.ts
  • src/tools/FileEditTool/FileEditTool.ts
  • src/tools/FileWriteTool/FileWriteTool.ts

5.1 设计原则

文件权限不是简单看“是不是在 cwd 下”,而是同时考虑:

  • 工作目录与附加目录
  • symlink 解析后的真实路径
  • read/edit 独立规则
  • acceptEdits 模式
  • sandbox 写白名单
  • Claude 自身配置目录与敏感目录
  • Windows/UNC 特殊路径绕过
  • 内部运行目录的受控豁免

5.2 路径检查顺序

写权限 checkWritePermissionForTool() 的顺序大致是:

  1. edit deny rule
  2. 内部可写路径豁免
  3. .claude/** 的 session 级特殊 allow 规则
  4. checkPathSafetyForAutoEdit()
  5. edit ask rule
  6. acceptEdits + 工作目录内 自动允许
  7. edit allow rule
  8. 默认 ask

读权限 checkReadPermissionForTool() 则是:

  1. UNC 与可疑 Windows 路径拦截
  2. read deny rule
  3. read ask rule
  4. “有 edit 权限则可读”
  5. 工作目录内可读
  6. 内部可读路径豁免
  7. read allow rule
  8. 默认 ask

5.3 敏感路径与绕过防护

checkPathSafetyForAutoEdit() 会拒绝自动编辑这些对象:

  • .git、.vscode、.idea、.claude
  • .gitconfig、.gitmodules、shell rc 文件等
  • Claude 自己的 settings / commands / agents / skills 目录
  • 可疑 Windows 路径模式 例如 ADS、8.3 短文件名、长路径前缀、尾随点/空格、设备名、UNC

而且它不是只检查原路径,还会检查 getPathsForPermissionCheck() 得到的解析路径,避免通过 symlink 绕过。

5.4 工作目录模型

工作目录范围由:

  • 原始 cwd
  • additionalWorkingDirectories

共同决定。

pathInAllowedWorkingPath() 会对输入路径和工作目录都做对称解析,再判断是否位于允许范围内。这解决了:

  • symlink cwd
  • macOS /tmp / /private/tmp
  • 大小写不敏感文件系统

带来的假阴性或绕过问题。

5.5 内部路径豁免

为了让系统自身可运行,代码显式放行一批“受控内部路径”,例如:

  • 当前 session 的 plan 文件
  • scratchpad
  • session memory
  • tool results
  • project temp 目录
  • agent memory / auto memory
  • tasks / teams / bundled skills 提取目录

这些豁免都是明确列举的,不是“凡是 .claude 下都放行”。

6. 第四层:Shell 与命令安全

实现见:

  • src/utils/permissions/shellRuleMatching.ts
  • src/utils/permissions/dangerousPatterns.ts
  • src/utils/permissions/permissionSetup.ts
  • src/utils/permissions/permissions.ts

6.1 规则匹配能力

shell 规则支持三类匹配:

  • exact
  • legacy prefix,如 npm:*
  • wildcard,如 git *

这让“命令级 allow/ask/deny”可以细到具体子命令,而不是只能对整个 Bash/PowerShell 开大口子。

6.2 auto mode 对危险 allow 规则做“去武器化”

进入 auto mode 时,stripDangerousPermissionsForAutoMode() 会主动剥离危险 allow 规则,防止它们绕过分类器。

典型危险规则包括:

  • Bash(*)
  • Bash(python:*)
  • Bash(node:*)
  • PowerShell(*)
  • PowerShell(iex:*)
  • PowerShell(Start-Process:*)
  • 任意 Agent(...) allow

离开 auto mode 时,再通过 restoreDangerousPermissions() 恢复。

这说明 auto mode 不是简单“把默认模式改个名字”,而是重新收紧了一次权限上下文。

6.3 auto classifier 的位置

实现见:

  • src/utils/permissions/yoloClassifier.ts
  • src/utils/permissions/classifierDecision.ts
  • src/utils/permissions/denialTracking.ts

auto mode 的判定大致分三层:

  1. 快速放行路径
    • 如果在 acceptEdits 语义下本来就会允许,则不走分类器
    • 某些安全工具在 allowlist 中,直接放行
  2. 分类器判定
    • classifyYoloAction() 对动作和上下文做语义安全判断
    • XML classifier 支持两阶段模式:先 fast,再在需要时进入 thinking
  3. 失败保护
    • transcript 太长时回退到手动审批
    • 分类器不可用时可 fail closed 或 fail open,受 gate 控制
    • 连续拒绝和总拒绝次数过高时,退回人工审阅

因此 auto mode 的本质是“规则层 + 语义分类层”的叠加,不是彻底自动放行。

6.4 sandbox 也是权限链的一部分

Bash ask rule 在特定条件下可被 sandbox 自动放行逻辑覆盖:

  • sandbox 已启用
  • 配置允许 “sandboxed bash auto allow”
  • 当前命令确实会在 sandbox 中执行

也就是说,sandbox 不是独立附加功能,而是会直接参与权限决策。

7. 第五层:交互审批、Hook 与 headless 约束

实现见:

  • src/hooks/useCanUseTool.tsx
  • src/hooks/toolPermission/handlers/interactiveHandler.ts
  • src/services/tools/toolHooks.ts
  • src/utils/permissions/permissions.ts

7.1 交互审批

当结果是 ask 时:

  • 主线程会进入交互式权限框
  • 可以被 classifier/hook 在后台抢先放行
  • 也可以转发给 bridge/channel 做远端确认

7.2 headless / 后台 agent

如果 shouldAvoidPermissionPrompts = true,系统不会假装“等用户确认”,而是:

  1. 先给 PermissionRequest hook 一次机会
  2. 如果 hook 没有明确 allow/deny
  3. 直接 auto deny

这避免了后台 agent 卡死在无 UI 的审批点。

7.3 Hook 位置

Hook 不是可有可无的扩展,而是嵌在安全链里:

  • PreToolUse 可以修改输入、阻断执行、返回权限结果
  • PermissionRequest 可以替代用户审批
  • PermissionDenied 可以在 auto deny 后决定是否允许重试
  • PostToolUse / PostToolUseFailure 做后处理与治理

这使得权限系统是“平台统一规则 + 可插拔策略”的组合。

8. 第六层:远端桥接与鉴权

实现见:

  • src/bridge/jwtUtils.ts
  • src/bridge/trustedDevice.ts
  • src/bridge/workSecret.ts
  • src/bridge/bridgePermissionCallbacks.ts
  • src/remote/remotePermissionBridge.ts

8.1 远端权限请求不会绕过本地审批模型

远端会话需要审批时,bridge 会把请求转换成统一的权限请求结构,再由本地同一套 ToolUseConfirm 流程处理。对于本地不认识的远端工具,还会创建最小 stub,走 fallback permission request。

因此远端工具不是另开一条“弱化版审批通道”,而是尽量复用本地审批体系。

8.2 会话令牌与刷新

createTokenRefreshScheduler() 会根据 JWT exp 提前刷新 session token,避免长会话过期失效。它带有:

  • refresh buffer
  • 失败重试
  • generation 防抖,避免旧刷新任务污染新会话

8.3 trusted device

bridge 的 elevated 会话还引入 trusted device token:

  • token 存在 secure storage
  • 受 gate 控制是否启用
  • /login 后尽早 enrollment
  • 支持清缓存和账号切换场景

这相当于对 OAuth 之外再补一层“设备信任”。

8.4 work secret 与 session 绑定

decodeWorkSecret() 会校验版本和必要字段; sameSessionId() 会在不同 tagged id 之间比较底层 session body,防止桥接过程把别的 session 误当成当前 session。

9. 第七层:共享记忆的秘密防泄漏

实现见:

  • src/services/teamMemorySync/secretScanner.ts
  • src/services/teamMemorySync/teamMemSecretGuard.ts
  • src/tools/FileWriteTool/FileWriteTool.ts
  • src/tools/FileEditTool/FileEditTool.ts

设计要点:

  • 在写入 team memory 前,先在本地扫描内容
  • 使用高置信度 secret 规则子集
  • 命中后直接拒绝写入
  • 扫描发生在上传前,避免秘密离开本机

team memory sync 本身还要求:

  • first-party OAuth
  • 指定 scope
  • repo 级作用域
  • delta upload / checksum 同步

所以这里既有“共享数据面”的权限约束,也有“内容面”的秘密扫描。

10. 这套安全层的几个关键特点

10.1 不是单点校验,而是分层收敛

同一个危险动作通常会经过多层约束:

  • 工具暴露过滤
  • 规则 deny/ask
  • 工具专属安全检查
  • auto classifier
  • 交互审批或 headless 拒绝

10.2 默认保守,但允许局部放宽

系统允许:

  • 按工具授权
  • 按命令前缀授权
  • 按目录授权
  • 按 session 临时授权

但又尽量避免“一把梭”授权在 auto mode 下失控,所以会剥离危险 allow 规则、限制 .claude、限制敏感路径、限制 headless prompt。

10.3 明确区分“平台内部路径”和“用户项目路径”

很多内部文件被显式豁免,是为了保证 agent runtime 本身能工作;但这些豁免是精确列举,不会泛化成对整个配置目录的长期开放。

10.4 兼顾安全与可用性

典型例子有:

  • sandbox 白名单参与写权限自动放行
  • read 权限可从 edit 权限推导
  • 给权限对话框生成 suggestions
  • auto mode 中对安全工具走 allowlist,减少分类器成本

这说明它并不是“安全优先到不可用”,而是在严格边界内做效率优化。

11. 结论

这个仓库的安全层本质上是一个“平台级权限与安全编排系统”,而不是零散的工具内校验。它的核心设计可以概括为:

  • 用统一的 ToolPermissionContext 和规则体系收拢权限状态
  • 在工具暴露前先裁剪能力边界
  • 在执行前按规则、路径、命令、模式做多阶段检查
  • 在 auto mode 下额外引入语义分类器,并主动剥离会绕过分类器的危险 allow 规则
  • 对远端桥接、共享记忆、敏感路径、Windows/UNC 特殊路径做专项防护
  • 用 Hook、建议、审计、拒绝计数把安全机制接入完整执行链路

因此,这套安全设计的重点不是“绝对禁止”,而是把“可授权的范围、不可绕过的边界、自动化的上限”清楚分层,并且把这些层真正落实到了工具池、权限判定、文件系统、自动模式、远端桥接和数据同步的代码里。

Sandbox 机制分析

本文只分析当前仓库源码里能直接确认的 sandbox 机制,不追踪外部依赖 @anthropic-ai/sandbox-runtime 的内部实现。结论先说:

  • 仓库内确实存在完整的 sandbox 接入链。
  • sandbox 不是单个布尔开关,而是“配置 + 权限判定 + Shell 包装 + 网络授权回调 + UI/SDK/swarm 协同”的组合机制。
  • 真正执行 OS 级隔离的底层能力来自外部依赖 @anthropic-ai/sandbox-runtime;本仓库负责的是配置生成、启停条件、调用时机、权限回调和结果治理。

1. 仓库里是否有 sandbox

有,而且是一级能力,不是实验性残留代码。

直接证据包括:

  • package.json 依赖声明了 @anthropic-ai/sandbox-runtime。
  • src/entrypoints/sandboxTypes.ts 定义了完整的 sandbox 配置 schema。
  • src/utils/sandbox/sandbox-adapter.ts 封装了仓库自己的 SandboxManager,作为 Claude Code 和外部 runtime 之间的适配层。
  • src/tools/BashTool/shouldUseSandbox.ts 决定某条 Bash 命令是否应该进入 sandbox。
  • src/utils/Shell.ts 真正执行命令前会调用 SandboxManager.wrapWithSandbox(...)。
  • src/commands/sandbox-toggle/* 暴露了 /sandbox 命令和 /sandbox exclude ... 用户入口。
  • src/screens/REPL.tsx、src/cli/structuredIO.ts 存在 sandbox 网络授权回调,说明运行时会把网络访问决策回传到 UI/SDK。

2. 总体架构

可以把仓库内的 sandbox 机制拆成 7 层:

  1. 配置层 src/entrypoints/sandboxTypes.ts 定义设置结构。
  2. 启用判定层 src/utils/sandbox/sandbox-adapter.ts 判断平台、依赖、策略是否允许启用。
  3. Bash 入沙盒判定层 src/tools/BashTool/shouldUseSandbox.ts 决定单条命令要不要走 sandbox。
  4. 权限交互层 src/tools/BashTool/bashPermissions.ts 决定 auto-allow / ask / deny / override 行为。
  5. 运行时包装层 src/utils/Shell.ts 在 spawn 前把命令包进 sandbox 运行字符串。
  6. 网络授权层 src/screens/REPL.tsx、src/cli/structuredIO.ts、src/utils/swarm/permissionSync.ts 处理网络访问授权。
  7. UI/命令入口层 /sandbox、/sandbox exclude、/add-dir、bridge --sandbox 暴露给用户或远程会话。
flowchart TD
    A[settings / flags / commands] --> B[SandboxManager.isSandboxingEnabled]
    B --> C[shouldUseSandbox(input)]
    C --> D[exec in src/utils/Shell.ts]
    D --> E[SandboxManager.wrapWithSandbox]
    E --> F[@anthropic-ai/sandbox-runtime]
    F --> G[violation / network ask callback]
    G --> H[REPL local dialog]
    G --> I[SDK structuredIO can_use_tool]
    G --> J[swarm mailbox request/response]

3. 配置层:SandboxSettingsSchema

源码位置:src/entrypoints/sandboxTypes.ts

这里是仓库内 sandbox 配置的单一来源。主要字段如下。

3.1 顶层开关

  • enabled 是否开启 sandbox。
  • failIfUnavailable 当用户显式启用 sandbox 但平台或依赖不满足时,是否直接拒绝启动。
  • autoAllowBashIfSandboxed 开启后,Bash 命令在“会进入 sandbox”的前提下可走自动放行逻辑。
  • allowUnsandboxedCommands 是否允许通过 dangerouslyDisableSandbox 退回到非 sandbox 执行。
  • excludedCommands 用户指定哪些命令模式不要进 sandbox。

3.2 网络配置:sandbox.network

  • allowedDomains 允许访问的域名列表。
  • allowManagedDomainsOnly 如果为真,只接受 managed/policy settings 里的允许域名和 WebFetch 域规则。
  • allowUnixSockets 允许的 Unix socket 路径。
  • allowAllUnixSockets 允许所有 Unix socket。
  • allowLocalBinding 是否允许本地绑定。
  • httpProxyPort HTTP 代理端口。
  • socksProxyPort SOCKS 代理端口。

3.3 文件系统配置:sandbox.filesystem

  • allowWrite 额外允许写入的路径。
  • denyWrite 额外拒绝写入的路径。
  • denyRead 额外拒绝读取的路径。
  • allowRead 在 denyRead 区域内重新放行的路径。
  • allowManagedReadPathsOnly 只接受 policy settings 提供的读路径白名单。

3.4 其他控制项

  • ignoreViolations 忽略特定 violation。
  • enableWeakerNestedSandbox 允许更弱的嵌套 sandbox。
  • enableWeakerNetworkIsolation 放宽部分网络隔离,注释里明确写了“降低安全性”。
  • ripgrep 为 sandbox 内的 ripgrep 提供 command/args。

4. 实现主链:sandbox-adapter.ts

源码位置:src/utils/sandbox/sandbox-adapter.ts

这个文件是核心适配层。它没有自己做 OS 级隔离,但它决定“外部 runtime 会拿到什么配置、何时初始化、何时刷新、如何做仓库特有的安全补丁”。

4.1 适配器职责

SandboxManager 对外暴露了一组仓库级接口:

  • initialize
  • isSandboxingEnabled
  • getSandboxUnavailableReason
  • wrapWithSandbox
  • refreshConfig
  • cleanupAfterCommand
  • checkDependencies
  • setSandboxSettings
  • getExcludedCommands
  • getFsReadConfig
  • getFsWriteConfig
  • getNetworkRestrictionConfig

其中很多方法是“仓库自实现 + 底层 runtime 转发”的混合体。

4.2 配置转换:convertToSandboxRuntimeConfig()

这个函数把 Claude Code 自己的 settings/permission 体系转换为 sandbox runtime 可消费的配置。

转换内容包括:

  • 从 permissions.allow 和 permissions.deny 中提取 WebFetch(domain:...) 规则,合并成 allowedDomains / deniedDomains。
  • 当 allowManagedDomainsOnly 开启时,只读 policySettings 里的域名允许列表。
  • 初始化默认可写路径: . 和 Claude 临时目录 getClaudeTempDir()。
  • 把所有 settings 文件路径加入 denyWrite。
  • 把 managed settings drop-in 目录加入 denyWrite。
  • 当当前 cwd 与原始 cwd 不同时,补充当前目录下 .claude/settings.json 和 .claude/settings.local.json 的写保护。
  • 无条件保护 .claude/skills,避免通过技能目录写入绕过安全边界。
  • 为 git bare-repo 逃逸场景做防御: 存在的 HEAD、objects、refs、hooks、config 直接加入 denyWrite; 不存在的路径记到 bareGitRepoScrubPaths,命令后做同步清理。
  • 如果检测到 git worktree,允许写主仓库路径,避免 worktree 下 git 锁文件失败。
  • 把 permissions.additionalDirectories 和运行期 --add-dir / /add-dir 注入的额外目录加入 allowWrite。
  • 把 Edit(...) / Read(...) 权限规则转换为 allowWrite / denyWrite / denyRead。
  • 把 sandbox.filesystem.* 的路径规则转成 runtime 的文件系统限制。
  • 为 sandbox 生成 ripgrep 配置。

4.3 路径解析细节

这里刻意区分了两套路径语义:

  • 权限规则路径 通过 resolvePathPatternForSandbox() 解析,//path 表示绝对路径,/path 表示相对 settings 根目录。
  • sandbox.filesystem.* 路径 通过 resolveSandboxFilesystemPath() 解析,/path 直接按绝对路径处理,./path 或裸路径相对 settings 根目录。

也就是说,Edit(/foo) 和 sandbox.filesystem.allowWrite: ["/foo"] 在仓库里不是同一种语义。

4.4 启用条件

isSandboxingEnabled() 需要同时满足:

  • 当前平台受支持。
  • checkDependencies() 没有错误。
  • 平台在 enabledPlatforms 白名单内。
  • sandbox.enabled === true。

只要任何一项不满足,就不会真正启用 sandbox。

4.5 不可用原因

getSandboxUnavailableReason() 用来回答“用户明明开了 sandbox,为什么没生效”。

它会区分:

  • WSL1 不支持。
  • 当前平台不支持。
  • enabledPlatforms 排除了当前平台。
  • 依赖缺失。

print.ts 和 REPL.tsx 会在启动时调用它:

  • 如果 failIfUnavailable=true,直接拒绝启动。
  • 否则给出 warning,并明确说明“命令将不受 sandbox 保护”。

4.6 初始化与动态刷新

initialize() 做的事:

  • 先检查 sandbox 是否启用。
  • 只初始化一次,用 initializationPromise 防止竞态。
  • 解析 git worktree 主仓路径。
  • 生成 runtime config。
  • 调用 BaseSandboxManager.initialize(runtimeConfig, wrappedCallback)。
  • 订阅 settings 变化,发生变化时调用 BaseSandboxManager.updateConfig(newConfig)。

refreshConfig() 是同步刷新入口,供权限或工作目录变化后立即更新 runtime 配置,避免下一条命令命中旧配置。

/add-dir 的实现就会在更新目录后立刻调用它。

4.7 命令后清理

cleanupAfterCommand() 不是简单转发。它会:

  • 先调用底层 BaseSandboxManager.cleanupAfterCommand()。
  • 再执行仓库自己的 scrubBareGitRepoFiles()。

后者专门清理 sandbox 命令期间可能被种下的 bare git repo 痕迹,避免后续非 sandbox git 调用被利用。

5. Bash 何时进入 sandbox

源码位置:src/tools/BashTool/shouldUseSandbox.ts

判定逻辑非常直接:

  1. SandboxManager.isSandboxingEnabled() 为假,直接不进 sandbox。
  2. 如果调用参数里有 dangerouslyDisableSandbox=true,且策略允许非 sandbox 命令,则不进 sandbox。
  3. 没有命令文本,不进 sandbox。
  4. 命中 sandbox.excludedCommands,不进 sandbox。
  5. 否则进入 sandbox。

excludedCommands 只是用户体验层便利功能,不是安全边界。源码注释明确写了:真正的安全控制仍然是 permission system。

5.1 excludedCommands 的匹配方式

containsExcludedCommand() 会做两件事:

  • 拆分 compound command,逐个子命令匹配。
  • 去掉前导环境变量和安全 wrapper,再匹配 exact/prefix/wildcard 规则。

这意味着下面这些都可能绕开 sandbox:

  • 用户显式配置的命令模式。
  • /sandbox exclude "npm run test:*" 加进去的模式。

但仓库作者把它定义为“选择不进 sandbox 的功能”,不是漏洞。

6. 权限交互:auto-allow / ask / deny / override

源码位置:src/tools/BashTool/bashPermissions.ts

sandbox 和 Bash 权限系统是耦合的,不是两条平行链路。

6.1 auto-allow 模式

当前提同时成立时:

  • SandboxManager.isSandboxingEnabled()
  • SandboxManager.isAutoAllowBashIfSandboxedEnabled()
  • shouldUseSandbox(input)

会进入 checkSandboxAutoAllow(...)。

它的规则是:

  • 如果 full command 命中显式 deny,返回 deny。
  • 如果子命令命中显式 deny,返回 deny。
  • 如果子命令命中 ask,返回 ask。
  • 如果 full command 命中 ask,返回 ask。
  • 上面都没有命中,则直接 allow,原因写成 Auto-allowed with sandbox (autoAllowBashIfSandboxed enabled)。

也就是说,auto-allow 不是“所有命令都默默放行”,而是“没有显式 ask/deny 规则时,允许它在 sandbox 内执行”。

6.2 dangerouslyDisableSandbox

BashTool 的输入 schema 明确暴露了:

  • dangerouslyDisableSandbox?: boolean

但是它是否生效,取决于 allowUnsandboxedCommands:

  • 如果 allowUnsandboxedCommands=true,shouldUseSandbox() 会因为 override 返回 false。
  • 如果 allowUnsandboxedCommands=false,这个参数在策略上被禁用,命令仍必须进入 sandbox。

src/tools/BashTool/prompt.ts 也把这层约束写进了给模型的工具提示:

  • 默认所有命令都应该先在 sandbox 中执行。
  • 只有用户明确要求绕过,或出现明显的 sandbox 失败证据时,才应使用 dangerouslyDisableSandbox: true。
  • 如果策略禁止,则“所有命令必须运行在 sandbox 内”。

6.3 UI 上如何展示

src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx 会计算:

  • sandbox 是否启用。
  • 该命令是否会进入 sandbox。

如果启用了 sandbox 但当前命令没有进入,会在权限框标题上显示成 unsandboxed bash。

7. 运行时包装:Shell.exec()

源码位置:src/utils/Shell.ts

这里是“是否进入 sandbox”真正落地到进程执行的地方。

7.1 包装时机

BashTool.call() 最终会进入 runShellCommand(),然后调用:

  • exec(command, ..., { shouldUseSandbox: shouldUseSandbox(input) })

exec() 里如果 shouldUseSandbox 为真,就会:

  • 计算 sandbox 专用临时目录。
  • 调用 provider.buildExecCommand(...) 生成命令字符串。
  • 调用 SandboxManager.wrapWithSandbox(commandString, sandboxBinShell, ...)。
  • 再用包装后的命令字符串执行 spawn(...)。

7.2 $TMPDIR

Shell.ts 会给 sandbox 命令准备专用临时目录,并把这个目录传进 shell provider。

BashTool/prompt.ts 也明确要求模型在 sandbox 模式下只使用 $TMPDIR,不要直接写 /tmp。

7.3 PowerShell 特殊处理

源码专门处理了 sandbox 下的 PowerShell:

  • 不是直接把 pwsh 塞进 sandbox。
  • 会先由 provider 构造 pwsh -NoProfile -NonInteractive -EncodedCommand ...。
  • sandbox 的内层 shell 改成 /bin/sh。

这样做是为了避免 PowerShell profile 在 sandbox 中加载,导致延迟、杂音输出或卡住。

7.4 命令结束后的清理

shellCommand.result.then(...) 里有一个关键分支:

  • 如果本次命令用了 sandbox,先执行 SandboxManager.cleanupAfterCommand()。

源码注释写得很清楚:Linux 下 bwrap 可能会在宿主机留下 0 字节挂载点文件,所以必须在命令结束后同步清理。

8. 网络授权链

sandbox 不只限制文件系统,也会限制网络访问。仓库内的设计是“底层 runtime 发现某个 host 不在允许范围内时,向上层要一次决策”。

8.1 REPL 本地交互

源码位置:src/screens/REPL.tsx

REPL 初始化 sandbox 时会传入 sandboxAskCallback。

普通本地场景下:

  • 把请求加入 sandboxPermissionRequestQueue。
  • 本地 UI 弹出授权对话框。
  • 用户允许或拒绝后,resolve 对应 promise。

如果启用了 bridge,还会额外把请求发到 remote control 侧,走 can_use_tool 风格的控制请求。

8.2 SDK / print 模式

源码位置:src/cli/structuredIO.ts、src/cli/print.ts

structuredIO.createSandboxAskCallback() 会把网络授权请求转成一个 synthetic tool:

  • tool name:SANDBOX_NETWORK_ACCESS_TOOL_NAME
  • protocol subtype:can_use_tool
  • description:Allow network connection to <host>?

也就是说,SDK host 不需要额外实现一套 sandbox 协议,而是复用既有权限请求协议。

8.3 swarm worker 转发

源码位置:

  • src/utils/swarm/permissionSync.ts
  • src/hooks/useSwarmPermissionPoller.ts
  • src/utils/teammateMailbox.ts

当当前 agent 是 swarm worker 时:

  1. worker 生成 requestId。
  2. 通过 mailbox 发送 sandbox_permission_request 给 leader。
  3. leader 审批后,再通过 mailbox 发回 sandbox_permission_response。
  4. worker 本地 registry 根据 requestId 找到 callback,resolve 这次网络访问是否允许。

消息结构在 teammateMailbox.ts 中有明确 schema:

  • sandbox_permission_request 包含 requestId、workerId、workerName、hostPattern.host。
  • sandbox_permission_response 包含 requestId、host、allow。

8.4 managed-only 域名策略

sandbox-adapter.ts 在 initialize() 里会包一层 wrappedCallback:

  • 如果 shouldAllowManagedSandboxDomainsOnly() 为真,直接拒绝所有运行期 ask,不再把问题转给用户。

也就是说,这个策略不是“UI 提示用户只可选 managed 域名”,而是直接在回调入口处短路拒绝。

9. 用户侧触发方式

这里把仓库内明确存在的触发入口单列出来。

9.1 settings

直接来自 settings.json / settings.local.json / policy / flags 的字段:

  • sandbox.enabled
  • sandbox.failIfUnavailable
  • sandbox.autoAllowBashIfSandboxed
  • sandbox.allowUnsandboxedCommands
  • sandbox.excludedCommands
  • sandbox.network.*
  • sandbox.filesystem.*

这是最基础的触发入口。

9.2 /sandbox

源码位置:src/commands/sandbox-toggle/*

/sandbox 提供交互式设置界面,可以切换:

  • disabled
  • regular
  • auto-allow

还可以配置 overrides:

  • Allow unsandboxed fallback
  • Strict sandbox mode

9.3 /sandbox exclude "pattern"

会调用 addToExcludedCommands(...),把模式写入本地 settings 的 sandbox.excludedCommands。

后续 shouldUseSandbox() 再遇到匹配命令时,就不会进入 sandbox。

9.4 /add-dir

源码位置:src/commands/add-dir/add-dir.tsx

该命令会:

  • 把目录加入工具工作目录权限。
  • 更新 bootstrap state 中的额外目录。
  • 立即调用 SandboxManager.refreshConfig()。

所以 /add-dir 不只是工具层放行,也会同步扩展 Bash sandbox 的可写目录。

9.5 dangerouslyDisableSandbox

这是单次 Bash 调用参数级触发方式,不是全局设置。

前提是:

  • 该参数被传入。
  • allowUnsandboxedCommands=true。

满足时,这一条命令不进 sandbox。

9.6 bridge --sandbox 与 CLAUDE_CODE_FORCE_SANDBOX

这条链属于远程会话层,不是本地 BashTool 自己的判定逻辑。

  • src/bridge/bridgeMain.ts 解析 --sandbox / --no-sandbox。
  • src/bridge/sessionRunner.ts 在启用时给子进程注入 CLAUDE_CODE_FORCE_SANDBOX=1。

这表示“远程 session 启动时,带上强制 sandbox 的环境参数”,而不是给 BashTool 新加一套独立权限系统。

10. 使用触发矩阵

场景是否进 sandbox仓库内依据
普通 Bash,sandbox 启用,未命中排除是shouldUseSandbox() 返回 true
Bash 带 dangerouslyDisableSandbox=true,且允许 fallback否allowUnsandboxedCommands=true 时 override 生效
Bash 带 dangerouslyDisableSandbox=true,但 strict mode是override 被策略禁用
命中 excludedCommands否containsExcludedCommand()
auto-allow 开启,且命令会进入 sandbox通常直接 allow 后在 sandbox 内执行checkSandboxAutoAllow()
auto-allow 开启,但命中 ask/deny 规则ask 或 deny同上
网络访问非白名单 host触发 ask callbackREPL / SDK / swarm
allowManagedDomainsOnly=true直接拒绝 ask callbackwrappedCallback 短路
worker 模式下网络访问受限 host请求转发给 leadermailbox sandbox_permission_request/response
bridge --sandbox远程子 session 带 sandbox 环境CLAUDE_CODE_FORCE_SANDBOX

11. 远程 bridge 的 sandbox

这部分容易和本地 Bash sandbox 混淆,单独说明。

源码位置:

  • src/bridge/bridgeMain.ts
  • src/bridge/types.ts
  • src/bridge/sessionRunner.ts
  • src/bridge/bridgeUI.ts

仓库内可以确认的行为是:

  • remote control 启动参数里有 sandbox: boolean。
  • CLI 支持 --sandbox。
  • bridge UI 会显示 Sandbox: Enabled。
  • session runner 会把该值转成子进程环境变量 CLAUDE_CODE_FORCE_SANDBOX=1。

仓库内不能确认的是:

  • 这个环境变量在子进程更深层到底如何影响底层 runtime。

因此这部分在本文中只归纳为“远程会话启动参数”,不把它误写成 BashTool 的另一套 sandbox 判定链。

12. 仓库边界:哪些能确认,哪些不能

当前仓库源码能确认:

  • sandbox 的配置结构。
  • sandbox 是否启用的判定条件。
  • BashTool 何时进入 sandbox。
  • 命令执行前如何包裹到 sandbox runtime。
  • 网络访问受限时如何把授权请求回传到 REPL / SDK / swarm。
  • sandbox 命令结束后有哪些仓库自定义清理逻辑。

当前仓库源码不能确认:

  • @anthropic-ai/sandbox-runtime 内部怎样调用 bwrap、seccomp、代理、socket 拦截或 macOS 沙箱机制。
  • 底层 runtime 如何具体实现 violation 检测和 host 级网络阻断。
  • CLAUDE_CODE_FORCE_SANDBOX 在子进程更深处的最终解释逻辑。

所以如果问题是“仓库里有没有 sandbox,以及它是如何接入和触发的”,当前文档已经完整回答。

如果问题升级成“底层 OS 级隔离到底怎么实现”,就必须继续分析外部依赖包,而这已经超出本仓库源码范围。

13. 总结

仓库内的 sandbox 机制可以概括成一句话:

Claude Code 在仓库内部并不自己实现 OS 级沙盒,而是围绕 @anthropic-ai/sandbox-runtime 建了一层完整的接入与治理框架,负责配置转换、启用判定、Bash 入沙盒决策、运行时包装、网络授权回调、swarm 转发,以及命令后的安全清理。

从调用链上看,最关键的主路径是:

settings -> SandboxManager.isSandboxingEnabled -> shouldUseSandbox -> Shell.exec -> wrapWithSandbox -> sandbox runtime -> violation/ask callback -> UI/SDK/swarm response

从使用方式上看,最关键的触发入口是:

  • settings 中的 sandbox.*
  • /sandbox
  • /sandbox exclude ...
  • /add-dir
  • Bash 参数 dangerouslyDisableSandbox
  • remote bridge 的 --sandbox

这说明 sandbox 在本仓库里不是孤立组件,而是权限系统、Shell 系统、会话系统、远程控制系统共同参与的一条主干能力链。

Prompt 系统

这份文档回答一个更具体的问题:

这个仓库里的 “prompt 系统” 不是一段固定字符串,而是怎样被组装、缓存、注入、压缩,并最终发给模型的?

先说结论:

这里的 prompt 系统本质上是一个多层拼装管线,不只是 system prompt。 它至少包含 6 类内容:

  • 默认 system prompt
  • 运行时覆写/追加 prompt
  • user context / system context
  • 工具 schema 里的 tool prompt
  • slash command / skill 扩展出来的 prompt
  • compact / summary 等特殊场景 prompt

可以把它理解成:

用户输入
-> processUserInput / slash command 展开
-> getSystemPrompt() 生成默认 system prompt 片段
-> buildEffectiveSystemPrompt() 或等价逻辑做最终合并
-> prependUserContext() / appendSystemContext()
-> toolToAPISchema() 注入工具 prompt
-> buildSystemPromptBlocks() 切成带 cache 语义的 API blocks
-> query() 发给模型

1. Prompt 系统由哪些层组成

1.1 默认 system prompt

默认 system prompt 的核心在 src/constants/prompts.ts 的 getSystemPrompt()。

它不是返回一个大字符串,而是返回 string[],每个元素都是一个 section。这样做有两个直接收益:

  • 方便按 section 缓存和增删
  • 方便在 API 层按 block 切分,做 prompt cache

这层负责放入模型最基础的行为约束,例如:

  • 身份与任务定位
  • 工具使用原则
  • 安全边界
  • 输出风格
  • 环境信息
  • MCP 指令
  • memory / scratchpad / proactive 等动态能力说明

1.2 运行时有效 system prompt

默认 prompt 还不是最终发给模型的 prompt。

真正的“有效 prompt”在两处形成:

  • 交互主线程等路径:src/utils/systemPrompt.ts 的 buildEffectiveSystemPrompt()
  • SDK / headless 路径:src/QueryEngine.ts 里直接按同样思路拼装

这一步处理:

  • overrideSystemPrompt
  • coordinator prompt
  • agent prompt
  • customSystemPrompt
  • appendSystemPrompt

也就是说,仓库里没有唯一一个 prompt 文件,真正生效的是“默认 prompt + 运行时策略”的结果。

1.3 user context / system context

这部分在 src/utils/queryContext.ts、src/utils/api.ts 和 src/query.ts。

系统把 prompt 分成两种不同的上下文载体:

  • userContext:通过 prependUserContext() 伪装成一个 meta user message,包在 <system-reminder> 里插到消息前面
  • systemContext:通过 appendSystemContext() 直接追加到 system prompt 尾部

也就是说,仓库不把所有上下文都塞进 system prompt,而是区分:

  • 哪些更像“用户可参考背景”
  • 哪些更像“系统级环境补充”

1.4 工具 prompt

工具本身也是 prompt 系统的一部分。

在 src/Tool.ts 里,每个 Tool 不只有执行逻辑,还有:

  • prompt()
  • description()
  • 输入 schema

而 src/utils/api.ts 的 toolToAPISchema() 会调用 tool.prompt(),把它变成 API 里的工具描述。

这意味着模型看到的不只是 “有一个工具叫 BashTool”,还会看到这个工具的使用说明,而这份说明也是 prompt。

1.5 slash command / skill prompt

仓库里很多 command 不是本地命令,而是 type: 'prompt' 的 prompt command。

相关逻辑在:

  • src/commands.ts
  • src/utils/processUserInput/processSlashCommand.tsx

这类命令在执行时会调用 getPromptForCommand(),把 /commit、/brief、skill 等展开成一段新的模型输入,并以 meta message 的形式插回消息流。

所以 skill 系统本质上也是 prompt 扩展系统。

1.6 compact prompt

当上下文过长时,系统会用另一套 prompt 要求模型“总结对话”。

这部分在:

  • src/services/compact/prompt.ts
  • src/services/compact/compact.ts

它不是沿用主对话 prompt,而是专门构造一段“只输出文本 summary、禁止调工具”的 compact prompt。

所以 compact 不是对消息做字符串裁剪,而是让模型在另一条 prompt 轨道上生产压缩摘要。


2. 默认 system prompt 是怎么生成的

2.1 getSystemPrompt() 返回的是分段结构

src/constants/prompts.ts 的 getSystemPrompt() 会按 section 组装 prompt,典型包含:

  • intro
  • system
  • doing tasks
  • actions
  • using your tools
  • tone and style
  • output efficiency
  • dynamic sections

这里最关键的设计不是内容本身,而是 “静态 section + 动态 section” 的拆分。

2.2 静态段和动态段被显式分界

文件里定义了:

export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
  '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

这个 marker 很重要。

它告诉后面的 API 层:

  • marker 之前的内容可以作为更稳定的静态前缀
  • marker 之后的内容是动态 section,不应和静态前缀共享同一层 cache 语义

也就是说,这个仓库从设计上就把 “prompt 内容” 和 “prompt cache 命中率” 绑在一起考虑了。

2.3 动态 section 有自己的缓存层

src/constants/systemPromptSections.ts 提供了:

  • systemPromptSection()
  • DANGEROUS_uncachedSystemPromptSection()
  • resolveSystemPromptSections()

普通 dynamic section 会在 session 内缓存,直到 /clear 或 /compact 清空。

这意味着很多“动态 section”其实不是每轮重算,而是“本 session 稳定”。

默认 dynamic section 里比较典型的有:

  • session-specific guidance
  • memory
  • ant model override
  • env info
  • language
  • output style
  • scratchpad
  • function result clearing

2.4 只有少数 section 被允许显式破坏 cache

最典型的是 MCP instructions。

在 getSystemPrompt() 里,它被放进 DANGEROUS_uncachedSystemPromptSection(),原因写得很直接:MCP server 可能在 turn 之间连接/断开。

这说明系统对 cache 稳定性是强约束:

  • 默认 section 一律缓存
  • 只有确实会在 turn 间变化、并且必须被模型看到的内容,才允许 cache-break

2.5 proactive 模式走的是另一条 prompt 分支

如果启用了 proactive / kairos,getSystemPrompt() 会直接走一条更短、更自治的 prompt 路径,而不是复用普通交互态的全部 sections。

这说明 prompt 系统不是“所有模式共享一个模板”,而是按运行模式分叉。


3. 最终有效 prompt 的优先级

src/utils/systemPrompt.ts 的 buildEffectiveSystemPrompt() 已经把优先级写得很清楚:

  1. overrideSystemPrompt
  2. coordinator system prompt
  3. agent system prompt
  4. customSystemPrompt
  5. default system prompt

然后:

  • appendSystemPrompt 一般总是追加在最后
  • 但如果用了 overrideSystemPrompt,它直接替换全部内容

这里有两个细节很关键。

3.1 agent prompt 有时替换默认 prompt,有时追加

普通情况下,agent prompt 会替换默认 prompt。

但在 proactive 模式下,agent prompt 会被作为:

# Custom Agent Instructions
...

追加到默认 prompt 后面。

也就是说,agent 在这个仓库里不是固定的“独立人格 prompt”,而是会随模式切换“替换式”或“增量式”合并。

3.2 SDK 路径会跳过部分默认上下文

src/utils/queryContext.ts 的 fetchSystemPromptParts() 有一个重要分支:

  • 如果设置了 customSystemPrompt
    • 跳过 getSystemPrompt()
    • 跳过 getSystemContext()

src/QueryEngine.ts 还会在这种情况下,按需额外注入 memoryMechanicsPrompt。

这意味着 customSystemPrompt 在 SDK/headless 模式里不只是“覆盖默认文案”,而是会改变整条上下文装配路径。


4. Prompt 是怎样进入 query 主循环的

4.1 fetchSystemPromptParts() 先取三块前缀内容

src/utils/queryContext.ts 会先收集:

  • defaultSystemPrompt
  • userContext
  • systemContext

这三者一起构成 API cache-key 前缀里的核心部分。

4.2 processUserInput() 会先把 slash command 展开

src/utils/processUserInput/processUserInput.ts 和 processSlashCommand.tsx 会先处理:

  • 普通文本输入
  • slash command
  • skill
  • attachment
  • hook 注入

其中 prompt command 会被展开成新的 meta user message。

所以模型真正看到的 “用户输入”,很多时候已经不是原始键入文本,而是“扩展过的 prompt 流”。

4.3 query.ts 把三类上下文放到不同位置

src/query.ts 里有两行最关键:

const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext),
)

messages: prependUserContext(messagesForQuery, userContext)

也就是说:

  • systemContext 被拼到 system prompt
  • userContext 被伪装成一个前置 user message

这是 prompt 系统最重要的结构化分层之一。

4.4 发送 API 前还会再加工一遍 system prompt

到 src/services/api/claude.ts,system prompt 还会被继续处理:

  • 加 attribution header
  • 加 CLI sysprompt prefix
  • 按 cache 规则拆 block
  • 生成 Anthropic API 需要的 TextBlockParam[]

真正发给模型的并不是 getSystemPrompt() 的原始返回值,而是经过 API 层重排后的 block 集合。


5. 工具 prompt 系统:模型如何理解工具

5.1 工具 schema 不是静态 JSON,而是运行时生成

src/utils/api.ts 的 toolToAPISchema() 会把 Tool 转成 API schema。

这个过程至少会用到:

  • tool.name
  • tool.prompt()
  • tool.inputSchema / inputJSONSchema
  • strict
  • eager_input_streaming
  • defer_loading

因此,工具系统本身就是模型 prompt 的一部分,而不是仅仅运行时可调用能力。

5.2 tool.prompt() 结果会被 session 级缓存

toolToAPISchema() 会把 base schema 缓存在 src/utils/toolSchemaCache.ts。

缓存 key 通常是:

  • tool.name
  • 某些场景下再加 inputJSONSchema

原因很直接:工具 schema 在 system prompt 前面,一旦字节变化,就会打碎整个 prompt cache 前缀。

所以这里缓存的不是“为了省一点 CPU”,而是为了稳定 prompt bytes。

5.3 工具列表本身也为了 cache 稳定而排序

src/utils/toolPool.ts 的 mergeAndFilterTools() 明确把工具分成:

  • built-in contiguous prefix
  • MCP suffix

再按名字排序。

这不是美观问题,而是为了尽量避免:

  • 某个 MCP 工具晚连接
  • 某个工具被去重/过滤

时导致整段工具 schema 顺序抖动。

5.4 ToolSearch 本质上是“按需暴露 prompt”

当 tool search 启用时,src/services/api/claude.ts 会决定哪些工具带 defer_loading,哪些真正进本轮 schema。

所以这个系统不是一次性把所有工具 prompt 全塞给模型,而是支持:

  • 先暴露一部分
  • 其余工具延迟发现
  • 被发现后再进入下一轮 prompt

从 prompt 系统视角看,这其实是“工具 prompt 的按需分页”。


6. Prompt cache 是这个系统的第一原则之一

这个仓库里很多设计看起来像工程细节,实质上都在服务 prompt cache。

6.1 system prompt 被拆成带 scope 的 block

src/utils/api.ts 的 splitSysPromptPrefix() 会把 system prompt 拆成不同 block,并打上:

  • global
  • org
  • null

几种 cache scope。

当存在 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 时:

  • boundary 前静态部分可以走更激进的 cache
  • boundary 后动态部分单独处理

6.2 如果 MCP 工具真正出现在工具列表里,就不能安全做全局 system prompt cache

src/services/api/claude.ts 里有 needsToolBasedCacheMarker:

  • 如果本轮会渲染 MCP tool schema
  • 那么 system prompt 的全局缓存策略要收缩

因为 MCP 工具天然带用户态、连接态差异,不适合和全局静态 prompt 共用同一层缓存假设。

6.3 很多“不要动态变化”的约束都在保护 cache key

例如:

  • tool schema session cache
  • system prompt section cache
  • built-in/MCP 工具分区排序
  • dynamic boundary
  • clear on /compact / /clear

所以理解这个仓库的 prompt 系统,不能只盯着文案内容,还要盯着“哪些字节必须稳定”。


7. Compact 与 Prompt Too Long 的处理

7.1 compact 本身也是一次单独的 prompt 任务

src/services/compact/prompt.ts 里专门定义了 compact prompt。

它会强调:

  • 只能输出文本
  • 不许调工具
  • 要按固定结构总结历史

这说明 compact 不是 message 层机械裁剪,而是让模型执行一项新的“总结任务”。

7.2 如果 compact 自己也触发 prompt too long,不会直接失败

src/services/compact/compact.ts 会在 compact 请求本身遇到 PTL 时调用:

  • truncateHeadForPTLRetry()

它会丢掉最旧的一批 API-round groups,再重试 compact。

也就是说,系统对 prompt 过长的处理是分层降级的:

  • 先 normal query
  • 再 auto/reactive compact
  • 如果 compact 也过长,再做 head truncation retry

7.3 compact 后要恢复一些“非摘要状态”

compact 不是只留下 summary。

src/services/compact/compact.ts 还会恢复或保留:

  • attachment
  • hook 结果
  • skill 内容
  • plan mode / discovered tools 等元信息

这说明 prompt 系统维护的不只是对话文本,还有“让后续 prompt 继续可用的执行态”。


8. 这个仓库里的 prompt 系统本质是什么

可以把它总结成一句话:

这个仓库实现的不是 “一段 system prompt”,而是一套面向 agent runtime 的 prompt 装配系统。

它有几个鲜明特征:

  • prompt 被拆成多层:system、context、tool、skill、compact
  • prompt 不是一次性拼接,而是在不同阶段逐层注入
  • prompt 内容设计和 cache 稳定性是同时设计的
  • 工具描述本身也是 prompt
  • slash command / skill 本质上也是 prompt 扩展
  • compact 不是裁剪字符串,而是用另一条 prompt 链路做摘要重建

如果只看 getSystemPrompt(),只能看到这套系统的一部分。 真正完整的 prompt 链路,需要一起看:

  • src/constants/prompts.ts
  • src/utils/systemPrompt.ts
  • src/utils/queryContext.ts
  • src/utils/api.ts
  • src/query.ts
  • src/services/api/claude.ts
  • src/utils/processUserInput/processSlashCommand.tsx
  • src/services/compact/prompt.ts

9. 相关阅读

CLAUDE.md 工作机制

1. 文档目标

这份文档回答一个具体问题:

这个仓库里的 CLAUDE.md 到底是怎样工作的?

先说结论:

它不是“启动时读一个根目录文本文件”这么简单,而是一套 instruction / memory 加载系统。系统会:

  • 按不同作用域发现多类 instruction 文件
  • 对文件内容做统一预处理
  • 在会话开始时把一部分内容 eager load 到上下文里
  • 在 Claude 触达具体文件时,再按路径懒加载补充规则
  • 在 compact 之后清缓存并重新建立这些 instruction

如果只记 3 个主入口,最重要的是:

  • src/utils/claudemd.ts:发现、解析、过滤、匹配
  • src/context.ts:会话级注入
  • src/utils/attachments.ts:文件级懒加载

2. 为什么它不是“单个文件”

src/utils/claudemd.ts 顶部已经把设计意图写得很清楚:Claude Code 读取的不是单一 CLAUDE.md,而是按层级组合出的 instruction 集合。

在当前实现里,核心层级有 4 层:

  • Managed:受控全局指令,例如 /etc/claude-code/CLAUDE.md
  • User:用户级全局指令,例如 ~/.claude/CLAUDE.md
  • Project:项目级共享指令,例如仓库里的 CLAUDE.md、.claude/CLAUDE.md、.claude/rules/*.md
  • Local:项目本地私有指令,例如 CLAUDE.local.md

这说明 CLAUDE.md 机制更像“多层 memory/source merge”,而不是“把一个 Markdown 文件原样塞进 prompt”。

另外,这个文件族也不是只有 eager load 这一种读法。项目启动时会先读一批“默认应该进上下文”的 instruction;当 Claude 进一步读取、编辑某个具体文件时,还会沿着文件路径去补充更细粒度的目录规则和 paths 规则。

3. 加载层级与优先级

3.1 总体层级

当前代码定义的总体顺序是:

Managed -> User -> Project -> Local

但这只是“大类顺序”。

真正影响优先级的还有两个实现细节:

  • 不同类型的文件按顺序追加到结果数组中
  • 越靠近当前工作目录的项目级 / 本地级文件,会越晚被加载

因此对模型来说,后加载的文件优先级更高。

3.2 目录遍历顺序

对项目目录的遍历发生在 getMemoryFiles() 中。

它会:

  1. 从 originalCwd 向上一路走到文件系统根目录
  2. 先把这些目录收集起来
  3. 再按“从根到当前目录”的顺序处理

这样做的效果是:

  • 上层目录的 CLAUDE.md 会先进入结果
  • 越靠近当前目录的 CLAUDE.md / CLAUDE.local.md / .claude/rules/*.md 会越晚进入结果
  • 因而更贴近当前工作区的指令拥有更高优先级

这也是为什么一个 monorepo 可以同时拥有“仓库根规则”和“子模块规则”。

3.3 Nested worktree 的特殊处理

实现里还专门处理了 nested worktree。

如果当前目录是嵌套在主仓库里的 git worktree,系统会跳过主仓库工作树里那些会被重复加载的 Project 指令文件,避免同一套 checked-in 规则被读两遍;但 CLAUDE.local.md 这类本地文件仍然可以继续向上继承。

这说明加载顺序不只是目录遍历,还要兼顾 worktree 场景下的去重。

4. 文件发现范围

4.1 会话启动时默认发现哪些文件

getMemoryFiles() 会优先发现这几类文件:

  • CLAUDE.md
  • .claude/CLAUDE.md
  • .claude/rules/**/*.md
  • CLAUDE.local.md

其中:

  • Managed 和 User 层来自固定路径
  • Project 与 Local 层通过“从当前目录向上遍历”发现

4.2 --add-dir 扩展目录

如果开启 CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD,系统还会读取 --add-dir 指定目录里的:

  • CLAUDE.md
  • .claude/CLAUDE.md
  • .claude/rules/**/*.md

这意味着在 bare/受限模式下,Claude 仍然可以显式加载用户主动指定目录的 instruction。

4.3 项目初始化和 onboarding 只检查一部分

需要注意的是,onboarding 逻辑并不理解整套 memory 系统。

src/projectOnboardingState.ts 只是简单检查当前工作目录下是否存在根级 CLAUDE.md,并据此决定是否把“Run /init to create a CLAUDE.md file”标记为完成。

所以:

  • onboarding 用的是“项目是否已有根级 CLAUDE.md”这个简化信号
  • 真正运行时的 instruction 加载远比 onboarding 检查更复杂

5. 文件内容预处理

CLAUDE.md 文件被读到内存后,不会原样直接注入。

src/utils/claudemd.ts 在 parseMemoryFileContent() 里做了几层统一预处理。

5.1 frontmatter paths

文件 frontmatter 里的 paths 会通过 parseFrontmatterPaths() 解析出来。

它的作用不是给文件改内容,而是为这个 memory 文件附加一组 glob 模式,后续可以根据目标文件路径决定它是否生效。

这里还有两个实现特点:

  • /** 后缀会被折叠成目录本体,方便后续匹配
  • 如果 frontmatter 最终等价于全匹配,例如只有 **,实现会把它当成“没有条件限制”

5.2 HTML 注释剥离

stripHtmlComments() 会删除 Markdown 里的块级 HTML 注释,例如:

<!-- 这段只是给维护者看的 -->

但它不会粗暴地把所有 <!-- --> 文本删光,而是通过 marked 的 lexer 只处理块级 comment,尽量避免误伤代码块和行内代码。

5.3 @include

memory 文件支持 @path 风格的 include 引用,例如:

  • @./relative/path.md
  • @~/path.md
  • @/absolute/path.md
  • @some-file.md

实现会在解析阶段把这些引用提取出来,解析成绝对路径,再递归读取。

这里要注意几个实现细节:

  • include 只在普通文本节点里生效,不在 code block 和 code span 里生效
  • include 有最大深度限制,当前是 5
  • 系统会记录 parent,所以被 include 的文件不会简单内联成字符串,而是作为独立 memory entry 参与后续处理
  • 非文本扩展名文件会被跳过,避免把图片、PDF 等二进制内容读进 instruction

5.4 文本扩展名限制

实现维护了一份允许被 @include 的文本扩展名白名单,覆盖:

  • Markdown / 文本
  • JSON / YAML / TOML / XML / CSV
  • 各主流编程语言源码
  • 常见配置文件与构建文件

如果 include 指向的文件扩展名不在允许范围内,就不会被读入 memory。

5.5 外部 include 限制

不是所有 memory 文件都能随意 include 工作区外的文件。

当前规则大致是:

  • User memory 可以包含外部文件
  • Project / Local memory 是否允许外部 include,取决于是否已获批准
  • 系统也支持通过 forceIncludeExternal 路径先探测外部 include,再决定是否展示 warning

换句话说,@include 是能力,但不是无条件开放的能力。

5.6 claudeMdExcludes

isClaudeMdExcluded() 会检查用户配置里的 claudeMdExcludes。

它适用于:

  • User
  • Project
  • Local

但不作用于:

  • Managed
  • AutoMem
  • TeamMem

实现还会同时匹配原始路径和 realpath 解析后的路径,以处理 macOS 这类符号链接路径差异。

6. 两种注入方式

这是整个机制最容易被误解的地方。

当前实现不是“把所有 instruction 一次性塞进 system prompt”,而是至少有两条注入路径。

6.1 会话级 eager load

src/context.ts 的 getUserContext() 会在生成用户上下文时调用:

getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))

这一步的作用是:

  • 调用 getMemoryFiles() 收集当前会话默认应加载的 instruction
  • 过滤掉某些不该直接注入的 memory entry
  • 通过 getClaudeMds() 拼成统一的文本块
  • 放进 userContext.claudeMd

getClaudeMds() 还会在最前面加上一段统一提示,大意是:

  • 下面是代码库和用户提供的 instructions
  • 这些 instructions 会覆盖默认行为
  • 模型必须遵循这些 instructions

所以对主会话来说,CLAUDE.md 的第一种生效方式是:在 query 开始前,被整理成一段统一上下文文本进入 prompt。

6.2 文件级懒加载

第二种路径发生在 Claude 触达某个具体文件时。

src/utils/attachments.ts 会在相关工具读取/编辑文件时,调用 getNestedMemoryAttachmentsForFile(),再按目标文件路径补充 nested memory attachment。

这条路径不是重新构造整段 claudeMd 文本,而是额外注入 attachment。

它主要解决两个问题:

  • 某些目录级规则只有在真正进入那个目录或触达那个文件时才应该生效
  • 带 paths frontmatter 的 scoped rule,只有命中目标文件时才值得加载

6.3 这两条路径的分工

可以把它理解成:

  • eager load 解决“会话开始时的默认指令底座”
  • nested attachment 解决“操作具体文件时的局部补充”

两条路径配合后,Claude 才既能拿到全局上下文,又不会把所有细粒度规则都提前塞进主 prompt。

7. 条件规则与按路径匹配

.claude/rules/*.md 不只是普通的拆分文档,还支持条件匹配。

7.1 哪些文件会被当成 conditional rule

processMdRules() 读取 .claude/rules/ 时,会区分两类规则:

  • 没有 paths frontmatter 的 unconditional rule
  • 带 paths frontmatter 的 conditional rule

后者只有在目标文件路径命中时,才会真正加入上下文。

7.2 匹配是如何计算的

真正的过滤逻辑在 processConditionedMdRules()。

它会:

  1. 先读取 .claude/rules/ 下所有带 paths 的 Markdown 文件
  2. 为每个文件拿到 globs
  3. 把目标文件路径转换成相对路径
  4. 用 ignore() 判断该相对路径是否命中规则

基准目录也不是统一的:

  • 对 Project 规则,匹配基准是 .claude 的父目录,也就是项目目录本身
  • 对 Managed / User 规则,匹配基准是 originalCwd

所以同样一条 paths 规则,在 project 和 user 作用域下的解释上下文并不完全相同。

7.3 Nested attachment 的加载顺序

对某个目标文件,getNestedMemoryAttachmentsForFile() 的处理顺序是:

  1. 先加载 Managed / User 里命中的 conditional rules
  2. 再处理从 CWD -> target 之间每一层目录的 CLAUDE.md、unconditional rules、conditional rules
  3. 最后处理 root -> CWD 这些目录层级里命中的 conditional rules

这个顺序让 scoped rule 既能继承全局条件,又能叠加目录局部条件。

8. 生命周期事件

CLAUDE.md 机制不仅是文件读取,还带有 hook 观测点。

8.1 InstructionsLoaded hook

当前实现支持 InstructionsLoaded hook。

它是一个 observability-only 的 hook,不负责拦截,只负责在 instruction 被加载时发出事件。

hook 元数据定义在 src/utils/hooks/hooksConfigManager.ts,而实际触发在两处:

  • eager load:src/utils/claudemd.ts
  • nested attachment:src/utils/attachments.ts

8.2 load_reason

当前实现里,instruction 文件被加载时会带上这些 load_reason:

  • session_start
  • nested_traversal
  • path_glob_match
  • include
  • compact

可以这样理解:

  • session_start:会话启动后的默认加载
  • nested_traversal:因为 Claude 进入某个更深目录或文件上下文,触发了目录级补充
  • path_glob_match:某条 paths 规则命中了目标文件
  • include:该文件不是直接发现的,而是通过 @include 被读到
  • compact:compact 清缓存后重新加载

这套 reason 让外部 hook 可以观察 instruction 生命周期,而不需要介入具体实现。

9. 与 /init、onboarding、compact 的关系

9.1 /init

/init 的作用不是参与运行时加载,而是帮助用户生成这些 instruction 文件。

从 src/commands/init.ts 的 prompt 可以看出,/init 明确把这些产物当作同一套系统的一部分来组织:

  • CLAUDE.md
  • CLAUDE.local.md
  • .claude/rules/*.md
  • 可选的 skills / hooks

也就是说,/init 是 instruction 系统的“生成入口”,不是“执行入口”。

9.2 project onboarding

项目 onboarding 比 /init 更简单。

它只检查当前工作目录下有没有根级 CLAUDE.md,据此决定项目是否完成初始化提示。这是一个产品层面的引导信号,不等价于完整 instruction 系统是否齐备。

9.3 compact 后会重新加载

compact 之后,系统不会假设旧的 instruction 仍然安全可用。

src/services/compact/postCompactCleanup.ts 会:

  • 清掉 getUserContext() 的 memoized cache
  • 调用 resetGetMemoryFilesCache('compact')

这样下一轮请求重新构建上下文时,就会再次走 getMemoryFiles() 和 getClaudeMds(),并且 InstructionsLoaded hook 会把这次重建标记为 compact。

这也解释了为什么在《上下文预处理》里会说 compact 后需要“重新建立” CLAUDE.md 和相关指令。

10. 一个端到端例子

可以用一个具体场景把整条链路串起来。

假设当前仓库有这些文件:

/repo/CLAUDE.md
/repo/.claude/rules/testing.md
/repo/packages/web/CLAUDE.md
/repo/packages/web/.claude/rules/react.md

其中 react.md 带有 frontmatter:

---
paths: src/**/*.{ts,tsx}
---

当一次会话开始时,大致会发生这些事情:

  1. getUserContext() 调用 getMemoryFiles()
  2. 系统先收集用户级 / 项目级 / 本地级默认 instruction
  3. 根目录 /repo/CLAUDE.md 会被 eager load
  4. /repo/packages/web/CLAUDE.md 是否 eager load,取决于当前 originalCwd 是否已经位于这个子目录或其下方
  5. 这些文件通过 getClaudeMds() 被拼成统一的 claudeMd 文本进入上下文

接着,如果 Claude 读取了:

/repo/packages/web/src/App.tsx

那么文件级懒加载会继续发生:

  1. attachments.ts 以 App.tsx 为目标文件路径
  2. 系统沿目录关系补充 /repo/packages/web/CLAUDE.md
  3. 检查 /repo/packages/web/.claude/rules/react.md 的 paths
  4. 因为 src/App.tsx 命中 src/**/*.{ts,tsx},所以这条 scoped rule 会以 nested attachment 形式注入
  5. 如果 react.md 里还有 @./shared-style.md,对应文件也会继续被读入,并带上 parent / include 信息

之后如果上下文触发 compact:

  1. compact 会清理用户上下文缓存与 memory 文件缓存
  2. 下一轮 query 重新执行 getUserContext()
  3. 会话级 claudeMd 再次构建
  4. InstructionsLoaded hook 会把这轮重建标记为 compact
  5. 后续如果 Claude 再次触达 App.tsx,对应的 nested rule 仍然可以继续懒加载

这就是 CLAUDE.md 在当前仓库中的完整工作方式:

  • 先建立会话级 instruction 底座
  • 再按文件路径叠加局部规则
  • compact 后刷新底座并继续支持局部补充

11. 结论

把 CLAUDE.md 当成“一个提示词文件”会低估当前实现。

更准确的说法是:

  • 它是一套多来源、多层级的 instruction 发现与注入机制
  • 它既支持启动时 eager load,也支持按目标文件懒加载
  • 它支持 scoped rule、@include、excludes、external include 控制和 hook 观测
  • 它和 /init、onboarding、compact 共同构成了 Claude Code 的 instruction 生命周期

如果要继续读源码,最推荐的顺序仍然是:

  1. src/utils/claudemd.ts
  2. src/context.ts
  3. src/utils/attachments.ts

读完这三处,再回头看《Prompt系统》和《上下文预处理》,整个链路会更容易串起来。

生态扩展总览

1. 定位

从源码结构看,Claude Code 的“生态扩展”并不是一个单点模块,而是三套相互配合的扩展机制:

  • src/skills/:把提示能力、命令能力和少量运行时约束封装成可调度的 Skill。
  • src/utils/plugins/:负责插件的发现、安装、缓存、校验、装配与失效刷新。
  • src/services/mcp/:负责把外部 MCP 服务接入为系统内的 tools、commands、resources 与连接状态。

如果只看目录名,容易把三者理解成并列功能;但从运行链路看,它们更像分层架构:

  • Skill 是提示与命令层扩展。
  • Plugin 是本地分发、组件注入与能力装配层。
  • MCP 是运行时协议接入层。

也就是说,系统并不是“先有一个核心,再给它外挂几个补丁”,而是从设计上就允许多种能力来源进入统一会话。

2. 总装配链路

生态扩展的装配顺序,大致可以抽象为下面这条链路:

main.tsx
  -> 初始化 built-in plugins / bundled skills
  -> getCommands() 汇总 bundled、本地、plugin、workflow 等命令
  -> pluginLoader 加载 marketplace / inline / builtin plugins
  -> MCP config 汇总 user / project / local / plugin / claude.ai 等 server 配置
  -> MCP client 建连并拉取 tools / prompts / resources / skills
  -> 会话运行时统一消费这些能力

从 src/main.tsx 可以看到,initBuiltinPlugins() 与 initBundledSkills() 会在启动早期执行,目的是让 getCommands() 在首次汇总命令时就能读到这些内存态扩展,而不是等到后续异步流程结束后再补注册。

再往后,src/commands.ts 会把多类来源统一折叠为 Command[]:

  • bundled skills
  • 内置插件导出的 skill commands
  • 磁盘目录中的 skills
  • plugin commands / plugin skills
  • builtin commands

这一步已经说明,Skill 并不是孤立于命令系统之外的“附属提示词”,而是命令汇总阶段的一等公民。

3. 三套机制的职责分层

3.1 Skill:提示与命令层

Skill 的核心目标是把一段能力描述、一组 frontmatter 元数据和可选的运行约束,转成可被模型或用户触发的 Command。它关注的是:

  • 如何定义能力
  • 如何声明工具权限与适用场景
  • 如何进入命令体系

它更像“能力说明书 + 调度入口”。

3.2 Plugin:分发与装配层

Plugin 系统不直接等价于某一种能力,而是负责把多种组件打包、安装并送进运行时。一个插件既可以提供 commands,也可以提供 skills、hooks、output styles、settings、LSP servers,或者继续提供 MCP servers。

因此 Plugin 更像“本地生态分发容器”。

3.3 MCP:协议接入层

MCP 集成负责对接外部服务。它既处理配置聚合,也处理 transport、auth、session 与 tool call 的真实执行。进入系统后,这些远端能力会被重新包装为:

  • MCP tools
  • MCP prompts / commands
  • MCP skills
  • MCP resources

因此 MCP 更像“把外部能力接成内部能力总线”的协议层。

4. 三者之间的耦合关系

这三层并不是单向串联,而是存在几个关键耦合点。

4.1 Plugin 可以向下提供 Skill 与 MCP

插件 manifest 与 marketplace entry 不只声明元信息,也可以声明:

  • commands
  • agents
  • skills
  • hooks
  • outputStyles
  • settings
  • mcpServers
  • lspServers

这意味着 Plugin 是向 Skill 系统和 MCP 系统输送能力的上游。

4.2 MCP 不只提供 tools,也会回流到 commands / skills

从 src/services/mcp/client.ts 与 src/services/mcp/useManageMCPConnections.ts 可以看到,MCP 客户端在建连后并不只拉取 tools,也会拉取 prompts、resources,以及 feature 打开时的 MCP skills。src/commands.ts 里还专门有 getMcpSkillCommands(),用于把 loadedFrom === 'mcp' 的命令筛出来并并入 skill 视图。

这说明 MCP 是“运行时接入层”,但它的结果会直接影响命令层和技能层的可见能力集合。

4.3 Skill 解析逻辑被 MCP 复用

src/skills/mcpSkillBuilders.ts 的存在很关键。它把 loadSkillsDir.ts 里的 createSkillCommand() 与 parseSkillFrontmatterFields() 以注册表方式暴露出来,供 MCP skill 发现逻辑复用,同时避免 client.ts -> mcpSkills -> loadSkillsDir.ts 形成循环依赖。

这意味着系统并没有为“本地 skill”和“MCP skill”维护两套独立建模逻辑,而是在命令对象构造层尽量统一。

5. 生态扩展的统一落点

无论能力来自本地目录、插件、还是远程 MCP 服务,最终都要进入同一套运行时对象:

  • Command
  • Tool
  • ServerResource
  • MCPServerConnection

也正因为落点统一,Claude Code 才能在同一轮会话里把内建工具、插件组件、Skill 和远端协议能力一起交给模型使用。

所以,“生态扩展”这一章最重要的结论不是三套机制各自做了什么,而是它们如何共同构成一个统一的能力接入面:

  • Skill 负责定义和暴露能力。
  • Plugin 负责分发和装配能力。
  • MCP 负责接入和执行外部能力。

下面三个小节分别拆开分析它们的内部机制。

Skill系统

1. 定位

src/skills/ 这套机制的目标,不是单纯“加载一些 Markdown 文件”,而是把一段提示资产转成可运行时调度的 Command。这使 Skill 同时具有两层身份:

  • 对模型来说,它是可被自动选择的能力说明。
  • 对用户来说,它又可能表现为显式的 slash command。

因此,Skill 系统本质上是“提示工程资产化 + 命令建模”的结合体。

2. 三类来源

从当前源码看,Skill 至少有三类来源。

2.1 Bundled Skills

src/skills/bundledSkills.ts 定义了 BundledSkillDefinition 与注册表。bundled skill 不是从磁盘扫描出来的,而是在启动时通过代码注册:

  • registerBundledSkill() 把定义转成 Command
  • getBundledSkills() 返回注册后的内存列表
  • clearBundledSkills() 主要用于测试

真正的初始化入口在 src/skills/bundled/index.ts。这里会调用一组 registerXxxSkill() 方法,把内置 skill 注册进系统。当前目录 src/skills/bundled/ 下可以看到 17 个 bundled skill 相关文件,但并不代表 17 个能力都会在所有运行环境下暴露出来:

  • 一部分受 feature('...') 控制
  • 一部分受运行时可用性判断控制,例如 shouldAutoEnableClaudeInChrome()
  • 个别 skill 自身还带 isEnabled() 回调

因此,bundled skill 是“代码内置 + 运行时显隐”的模式,而不是静态清单。

2.2 磁盘目录 Skills

src/skills/loadSkillsDir.ts 负责从磁盘目录加载 skills。它支持多种来源:

  • managed / policy 路径
  • 用户目录
  • 当前项目及向上层级的 .claude/skills
  • 通过 --add-dir 注入的附加目录
  • 兼容旧版 commands/ 目录中的 skill / command 形式

这里真正重要的不是“从哪里读文件”,而是这些来源最终都会被折叠成统一的 Command 对象,再参与后续去重和排序。

2.3 MCP Skills

Skill 系统还有一条远端来源。src/services/mcp/client.ts 和 src/services/mcp/useManageMCPConnections.ts 在 feature 打开时会拉取 MCP skills,并把它们和 MCP prompts 一起放进 mcp.commands。

随后,src/commands.ts 通过 getMcpSkillCommands() 专门筛出 loadedFrom === 'mcp' 的命令,使它们进入 Skill 视图与 SkillTool。

这意味着 Skill 并不限于本地文件系统;远端协议返回的 skill,也会被归一成同一种命令抽象。

3. 从 Markdown 到 Command

loadSkillsDir.ts 的核心价值,在于它把 skill 从“文本文件”变成“命令对象”。

3.1 frontmatter 解析

parseSkillFrontmatterFields() 会抽取一组共享字段,包括:

  • description
  • allowed-tools
  • argument-hint
  • arguments
  • when_to_use
  • model
  • disable-model-invocation
  • user-invocable
  • hooks
  • context
  • agent
  • effort
  • shell

这些字段决定 Skill 在运行时如何暴露、能调用什么工具、适合何时触发,以及是否允许模型直接调用。

3.2 Command 构造

createSkillCommand() 则负责把解析结果组装成 Command。这里会统一处理:

  • name
  • description
  • argNames
  • whenToUse
  • source
  • loadedFrom
  • hooks
  • skillRoot
  • context
  • agent
  • paths

同时,真正执行 getPromptForCommand() 时,还会继续做几件事:

  • 参数替换
  • ${CLAUDE_SKILL_DIR} 与 ${CLAUDE_SESSION_ID} 注入
  • 非 MCP skill 的内联 shell 执行
  • 为磁盘型 skill 自动加上 Base directory for this skill: ... 前缀

所以 Skill 不是“读取后原样返回文本”,而是一套带运行时预处理的 prompt command 构造器。

4. 动态加载能力

Skill 系统真正复杂的部分,在于它不是一次性扫描,而是会持续按上下文扩展。

4.1 多来源加载与去重

getSkillDirCommands() 会并行加载 managed、user、project、additional dirs 与 legacy commands,再按真实路径做去重。这里用 realpath 作为文件身份,目的是处理:

  • 软链接
  • 重叠父目录
  • 同一文件被多路径访问

所以,Skill 系统不是简单“按名字覆盖”,而是先按物理文件身份消重,再进入命令层。

4.2 Conditional Skills

frontmatter 中的 paths 会把 skill 标记为 conditional skill。它不会在启动时立即暴露,而是先放进 conditionalSkills,等待文件操作触发。

activateConditionalSkillsForPaths() 会在文件路径匹配成功时,把这些 skill 激活到 dynamicSkills 中。这里用的是 gitignore 风格匹配,因此它更像“按工作区上下文自动启用的 skill”。

4.3 动态目录发现

discoverSkillDirsForPaths() 与 addSkillDirectories() 负责沿文件路径向上查找嵌套的 .claude/skills。这使 Skill 的作用域可以比项目根更细,表现为“离当前文件越近的 skill,优先级越高”。

这是一种很典型的上下文感知扩展机制。

5. Bundled Skill 的懒提取与安全写盘

bundledSkills.ts 里有一个容易被忽略但很关键的机制:bundled skill 可以携带额外参考文件,并在首次调用时懒提取到磁盘。

这部分处理包括:

  • 为每个 skill 分配确定性的提取目录
  • 以 Promise 方式做进程内单次提取
  • 检查相对路径,阻止目录逃逸
  • 使用安全写入标志和权限模式写文件

这里的目的,是让模型在运行 skill 时还能按需 Read/Grep 这些参考资产,同时尽量降低路径穿越和竞争写入风险。

6. 与 Commands、Plugin、MCP 的关系

Skill 系统虽然位于 src/skills/,但它并不自成孤岛。

6.1 与 Commands 的关系

src/commands.ts 会把以下内容一起汇总:

  • getBundledSkills()
  • getSkillDirCommands()
  • getPluginSkills()
  • getBuiltinPluginSkillCommands()

也就是说,Skill 最终是被命令系统消费的,而不是独立执行框架。

6.2 与 Plugin 的关系

插件可以提供 skill 目录或额外的 skills 路径,因此 Plugin 是 Skill 的一个上游分发渠道。插件解决“skill 从哪里来”,Skill 系统解决“skill 怎样变成命令对象”。

6.3 与 MCP 的关系

src/skills/mcpSkillBuilders.ts 把 parseSkillFrontmatterFields() 与 createSkillCommand() 注册出来,供 MCP skill 发现逻辑复用。这一点很重要,因为它保证了:

  • 本地 skill 与 MCP skill 共享相同的建模语义
  • 系统不会为远端 skill 维护一套平行的命令构造逻辑

所以,Skill 系统表面上看是在“加载 Markdown”,本质上是在提供一套统一的 prompt-command 规范。

Plugin系统

1. 定位

如果说 Skill 解决的是“如何描述一种能力”,那么 Plugin 解决的是“如何把多种能力作为一个发行单元装进系统”。src/utils/plugins/ 的职责不是只做安装,而是完整覆盖:

  • 发现来源
  • 拉取内容
  • 校验 manifest
  • 缓存版本
  • 合并多来源插件
  • 把插件组件注入到运行时

因此,Plugin 系统更接近 Claude Code 的本地生态分发层。

2. 插件能提供什么

从 src/utils/plugins/schemas.ts 与 src/types/plugin.ts 可以看到,插件不是只扩展命令。一个插件或 marketplace entry 可以声明的组件面包括:

  • commands
  • agents
  • skills
  • hooks
  • outputStyles
  • settings
  • mcpServers
  • lspServers

LoadedPlugin 结构里也保留了对应路径与缓存槽位,例如:

  • commandsPaths
  • agentsPaths
  • skillsPaths
  • outputStylesPaths
  • mcpServers
  • lspServers
  • hooksConfig
  • settings

这说明 Plugin 是“多组件封装单元”,而不是单一功能插件。

3. 来源、优先级与装配顺序

pluginLoader.ts 顶层注释已经把插件来源说得很清楚。当前系统主要处理三类来源:

  • marketplace plugins
  • session-only plugins,例如 --plugin-dir
  • builtin plugins

真正的合并发生在 mergePluginSources() 与 assemblePluginLoadResult() 中,整体顺序是:

  1. 加载 marketplace plugins
  2. 加载 session-only plugins
  3. 加载 builtin plugins
  4. 做来源合并与覆盖处理
  5. 做依赖校验与降级
  6. 产出 enabled / disabled / errors

这里最关键的优先级规则是:

  • --plugin-dir 这种 session 插件可以覆盖已安装插件
  • 但 managed settings 锁定的插件不能被 session 插件覆盖
  • builtin plugins 作为最后一层补充

也就是说,Plugin 系统不是简单拼接,而是带有策略优先级的合并器。

4. 拉取、缓存与版本化

Plugin 的工程复杂度主要集中在缓存策略上。

4.1 版本化缓存

pluginLoader.ts 为插件提供了 versioned cache 路径,格式上是:

~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/

这样做的意义是把“插件名”与“插件版本”解耦,避免不同版本互相覆盖。

4.2 Seed Cache 与 Zip Cache

当前实现不只支持本地主缓存,还支持:

  • seed cache:用于预置缓存或首启命中
  • zip cache:把缓存内容压成 zip 作为规范格式

这些机制说明插件系统已经不是“下载到一个目录就完事”,而是朝更稳定的分发缓存体系演化。

4.3 多种远端来源

从 schema 可见,Plugin Source 支持的不只是相对路径,还包括多种远端来源,例如 git、github、npm、url 等。pluginLoader.ts 内部也有对应的:

  • git clone
  • npm 安装
  • 目录复制
  • 缓存命中与回退

这让 Plugin 成为统一的“来源适配层”。

5. manifest、marketplace 与校验

5.1 Manifest 负责描述组件面

插件自身通过 plugin.json 提供元数据与组件声明,重点包括:

  • 名称、版本、作者、描述
  • 依赖
  • commands / agents / skills / hooks / outputStyles
  • mcpServers / lspServers
  • userConfig / channels

这一步描述的是“插件自身是什么”。

5.2 Marketplace Entry 负责分发信息

marketplace entry 则补充了另一层信息:

  • 插件来源
  • 类别与标签
  • 严格模式
  • 可补充部分 manifest 字段

这一步更接近“插件从哪里来、怎样被发现与安装”。

5.3 校验不是单点动作

Plugin 的校验分散在多个阶段:

  • marketplace 名称与来源校验
  • plugin source 校验
  • manifest schema 校验
  • 路径存在性校验
  • 依赖满足性校验
  • 组件读取错误收集

最终它们都通过 PluginError 统一进入错误模型,而不是靠零散字符串拼接来处理。

6. 运行时装配

Plugin 系统真正重要的,不是“下载成功”,而是“装进去以后系统怎样消费”。

6.1 Commands / Skills / Agents / Hooks

loadPluginCommands.ts、loadPluginAgents.ts、loadPluginHooks.ts、loadPluginOutputStyles.ts 等模块负责把插件声明的不同组件加载进各自子系统。

其中最关键的一点是:这些模块普遍依赖 loadAllPluginsCacheOnly(),也就是尽量复用同一份插件发现结果,避免启动过程因为插件刷新而重复走重型加载链路。

6.2 Settings 注入

cachePluginSettings() 会把启用插件导出的 settings 合并进同步缓存层。这样插件不仅能提供能力,还能影响运行时配置读取结果。

这让 Plugin 不只是“扩展功能”,还是“扩展配置层”。

6.3 失效刷新

clearPluginCache()、refresh.ts 等逻辑表明,Plugin 系统显式处理:

  • 安装后刷新
  • 市场变更后刷新
  • 下游命令 / hooks / MCP / LSP 视图刷新

所以它更像一套有状态的装配基础设施,而不是一次性加载器。

7. 与 Skill 和 MCP 的接口面

Plugin 系统之所以属于“生态扩展”主章节,核心原因就在这里。

7.1 Plugin 向 Skill 系统输送能力

插件既可以提供 skill 目录,也可以提供额外 skills 路径。之后这些内容会被 Skill 加载链路转换成 Command,并进入 slash command 与 SkillTool 视图。

换句话说:

  • Plugin 负责交付 skill 资产
  • Skill 系统负责解释和建模这些资产

7.2 Plugin 向 MCP 系统输送能力

src/utils/plugins/mcpPluginIntegration.ts 专门负责从插件中提取 MCP servers。插件可以通过:

  • .mcp.json
  • manifest mcpServers
  • MCPB / DXT bundle
  • channel userConfig

把 MCP server 注入系统。随后这些 server 会进入 src/services/mcp/config.ts 的统一配置聚合链路。

7.3 Plugin 自己不是执行层

这一点很重要。Plugin 虽然能把很多东西带进来,但它本身不直接承担最终执行:

  • skill 执行由命令/Skill 体系处理
  • tool 调用由工具执行与权限体系处理
  • MCP server 建连与调用由 MCP client 处理

所以 Plugin 的本质仍然是“分发与装配”,不是运行时执行总线。

8. 小结

Plugin 系统把生态扩展从“用户手动拷文件”提升成了“可发现、可安装、可缓存、可组合的分发机制”。它的真正价值不在某一个接口,而在于它把本地生态接入统一成一个中间层:

  • 上游接 marketplace、git、npm、inline 目录等来源
  • 中游做 manifest 校验、缓存与依赖管理
  • 下游把 commands、skills、hooks、MCP、LSP 等组件送入各个运行子系统

因此,在 Claude Code 的扩展架构里,Plugin 是连接“分发世界”和“运行时世界”的桥梁。

MCP集成

1. 定位

src/services/mcp/ 不是一个单纯的“第三方连接器目录”,而是 Claude Code 对外部能力的运行时协议接入层。它做的事情可以概括成两部分:

  • 配置汇总:决定有哪些 MCP server 应该进入系统
  • 连接执行:真正建立 transport、处理 auth、拉取 tools / prompts / resources / skills,并执行调用

因此,MCP 集成是整个生态扩展体系里最接近“运行时总线”的一层。

2. 配置汇总层

配置汇总的主入口是 src/services/mcp/config.ts。

2.1 配置模型

src/services/mcp/types.ts 定义了 McpServerConfig 与 ScopedMcpServerConfig。当前支持的 transport / 配置类型包括:

  • stdio
  • sse
  • sse-ide
  • http
  • ws
  • ws-ide
  • sdk
  • claudeai-proxy

其中,对外文档通常只需要把 stdio、sse、http、ws、sdk 视为主要 transport;其余更偏内部接入或特化场景。

2.2 Scope 与来源

MCP 配置不是单一文件读取,而是多 scope 汇总。当前类型系统里的 scope 包括:

  • enterprise
  • user
  • project
  • local
  • dynamic
  • claudeai
  • managed

在 getClaudeCodeMcpConfigs() 这条链路中,真正参与 Claude Code 本地配置聚合的重点来源是:

  • enterprise
  • user
  • project
  • local
  • plugin dynamic

之后 getAllMcpConfigs() 还会继续把 claude.ai connectors 合并进来。

2.3 策略过滤

配置汇总阶段还会执行策略过滤:

  • allowedMcpServers
  • deniedMcpServers
  • allowManagedMcpServersOnly

config.ts 中分别实现了名字、命令数组和 URL 模式级别的 allowlist / denylist 判断。这说明 MCP 的安全约束并不是只在建连时拦截,而是在配置进入系统前就开始过滤。

2.4 项目级审批与显式启停

对 project .mcp.json 中的 server,系统还会结合 getProjectMcpServerStatus() 判断其状态:

  • approved
  • rejected
  • pending

此外,isMcpServerDisabled() 与 setMcpServerEnabled() 负责显式启停。也就是说,MCP server 的可见性并不是“配置存在就一定连接”,而是叠加了策略、审批与用户开关。

3. 插件提供的 MCP Server 如何并入

src/utils/plugins/mcpPluginIntegration.ts 是 Plugin 与 MCP 的接口层。

3.1 输入来源

插件可以通过几种方式提供 MCP server:

  • 插件目录中的 .mcp.json
  • manifest 中的 mcpServers
  • MCPB / DXT bundle
  • assistant-mode channels 上的 userConfig

3.2 环境变量与用户配置注入

resolvePluginMcpEnvironment() 会在插件 server 进入 MCP 总配置前做变量展开,主要处理:

  • ${CLAUDE_PLUGIN_ROOT}
  • ${user_config.X}
  • 一般环境变量 ${VAR}

同时还会为 stdio server 注入:

  • CLAUDE_PLUGIN_ROOT
  • CLAUDE_PLUGIN_DATA

这一步让插件 MCP server 具有“插件上下文感知”。

3.3 作用域前缀与去重

插件 server 被装配时会调用 addPluginScopeToServers(),统一加上类似 plugin:<pluginName>:<serverName> 的作用域前缀,并标记 scope: 'dynamic'。

之后 config.ts 还会对 plugin MCP servers 做去重,避免:

  • 和手工配置的 server 重复
  • 插件之间提供同一底层命令或 URL 的 server

因此 Plugin 提供 MCP server 时,不会直接无条件覆盖主配置,而是进入统一合并与去重规则。

4. 连接执行层

MCP 执行的主入口在 src/services/mcp/client.ts。

4.1 建连与 transport

client 层会基于不同配置选择不同 transport,例如:

  • StdioClientTransport
  • SSEClientTransport
  • StreamableHTTPClientTransport
  • WebSocket transport
  • SDK control transport

这说明 MCP 集成不是面向单一协议实现,而是面向“多 transport 的统一客户端封装”。

4.2 连接状态模型

types.ts 中定义的 MCPServerConnection 不是简单“连上/没连上”二元状态,而是多态状态:

  • connected
  • failed
  • needs-auth
  • pending
  • disabled

这个状态模型会被会话层、UI 层和 /mcp 命令共同消费。

4.3 建连后拉取的能力

建连成功后,MCP 客户端不会只拉取 tools,还会根据 server capabilities 拉取:

  • tools
  • prompts / commands
  • resources
  • skills

其中 skill 是 MCP 集成里最容易被忽略的一环。当前实现会在 prompts 或 resources 发生 list_changed 时,刷新相关缓存,并把新的 MCP skills 与 prompts 一起写回命令集合。

这说明 MCP 的输出不是“只为 Tool 层服务”,而是会回流到命令层与技能层。

5. 关键运行机制

5.1 OAuth 与 needs-auth

client.ts 对远程 server 的 auth 处理相当重。它显式区分:

  • 已连接
  • 需要认证
  • 401 后的 token refresh
  • 会话级 auth 缓存

遇到 401 或未授权状态时,server 会被标记为 needs-auth,并写入本地缓存,避免持续重试导致的噪音和延迟。

5.2 Session Expired 与自动恢复

对于 HTTP 类 MCP server,代码里单独识别了 session 失效场景,例如:

  • 404 + JSON-RPC -32001
  • “Connection closed” 派生错误

一旦识别,会清理连接缓存并要求下次工具调用重新建连。这说明 MCP 集成在设计上已经把“长连接会过期”视为常态,而不是异常边角。

5.3 Large Result 持久化与截断

当 MCP tool 返回结果过大时,client.ts 不会简单抛错,而是会:

  • 估算内容大小
  • 决定是否截断
  • 必要时把内容持久化到文件
  • 返回一段引导文本,提示后续如何读取

这一步把 MCP 大输出从“模型上下文风险”转成了“可回读的外部工件”。

5.4 URL Elicitation Retry

对 MCP 的 URL elicitation,callMCPToolWithUrlElicitationRetry() 会在检测到 UrlElicitationRequired 后:

  • 调用 hook
  • 或将请求送入 UI / SDK 交互层
  • 等待用户完成
  • 再重试工具调用

这体现出 MCP client 不是“盲目 RPC 转发器”,而是能和用户交互流程配合的协议适配器。

6. 与 Commands、Skills、Resources 的关系

MCP 集成的一个关键特点,是它同时影响多种运行时对象。

6.1 Tools

这是最直观的一层。MCP tool 会被包装为内部 Tool,供模型调用。

6.2 Commands

MCP prompts 会作为命令进入系统,命名规则通常是 mcp__<server>__<prompt>。src/services/mcp/utils.ts 里也专门区分了 MCP prompts 与 MCP skills 的名字模式与过滤逻辑。

6.3 Skills

MCP skills 则会被建模为 loadedFrom === 'mcp' 的 prompt commands,并进入 mcp.commands。之后:

  • commands.ts 可以把它们筛出
  • attachments.ts 会把它们并入 skill listing
  • SkillTool 也会把它们和本地 skills 合并展示

所以,从用户和模型的视角看,MCP 并不只是“多了几个远端工具”,而是整个能力集合都可能因远端 server 而变化。

6.4 Resources

MCP resources 则以 ServerResource 形式进入资源视图,用于后续浏览、读取和引用。

7. 小结

MCP 集成在 Claude Code 中承担的是外部能力接入总线的角色。它并不只负责“连上一个 server”,而是把外部能力完整转换为内部运行时对象,并在这个过程中处理:

  • 多 scope 配置聚合
  • 策略过滤与审批
  • 插件 server 注入
  • transport 适配
  • auth 与会话恢复
  • tool / prompt / skill / resource 拉取
  • 大结果处理与交互式重试

因此,在生态扩展三层里,MCP 是最靠近执行面的那一层;它把 Plugin 分发进来的 server,或者用户直接配置的 server,真正变成了会话可用能力。