最近研读了 Claude Code 源码,顺便剖析了目前最前沿编程 Agent 的架构设计与核心实现。最大的体会是:Agent 开发并未脱离传统软件工程的范畴。剥开 AI 的功能外衣,其底层非功能性的设计理念与传统软件开发大同小异。
一、系统总览:边界、分层与运行方式
1.1 边界与风险
- 系统负责 :编排对话回合、在权限内暴露能力、维护会话与恢复,并把结果输出到 TUI (终端交互界面)、SDK (给宿主程序调用的接口)或 headless(无界面批处理/脚本模式)。
- 外部参与方 :
- 调用方:用户 / SDK 宿主
- 本地环境:工作区 / Git 仓库 / 本地配置
- 推理与扩展:模型 API、MCP / Plugin / Skill / Agent
- 平台能力:OAuth / 遥测 / 远程配置
- 关键边界:工作区默认不可信;Shell / 写文件 / Git / hooks / MCP 属于高风险能力;扩展不能绕过主循环、权限与状态收口。
1.2 分层架构图
⑥ 基础设施层 (Utils & Infra)
⑤ 服务层 (Services)
④ 工具层 (Tools)
③ 核心编排层 (Core Loop & Core Subsystems)
② 界面层 (UI / Ink TUI)
① 入口 / 初始化层 (Entrypoints & Bootstrap)
动态加载主模块
共享入口
preAction / action
交互模式
非交互 / print 模式
共享服务能力
共享工具能力
cli.tsx
CLI 入口 / fast-path / 动态 import main.tsx
mcp.ts + sdk/
MCP Server / Agent SDK 入口
main.tsx
Commander 路由 / 启动编排
init.ts
全局初始化
(配置 / 网络 / OAuth / 遥测)
bootstrap/state.ts + setup.ts
全局状态 + 会话初始化
(cwd / worktree / hooks / UDS)
screens/REPL.tsx
交互式终端 REPL
components/App.tsx + components/* + ink/*
终端 UI 渲染 / Message / Diff / Dialog
QueryEngine.ts + query.ts
对话引擎 / 主循环
commands.ts
命令注册 / 过滤 / 分发
context.ts
userContext / systemContext 注入
AppStateStore.ts
会话级运行态
Tool.ts + tools.ts
工具契约 / 工具池组装
services/tools/* + tools/*
工具实现 / 权限检查 / 流式与批量执行
services/api/*
模型 API / 流式 / 重试
services/mcp/*
MCP 客户端 / Server 连接
services/compact/* + services/SessionMemory/*
上下文压缩 / 会话记忆
services/oauth/* + services/analytics/* + services/remoteManagedSettings/* + services/lsp/*
OAuth / 遥测 / 企业配置 / LSP
utils/config.ts + utils/settings/*
配置系统 / 多源设置合并
utils/permissions/* + utils/hooks/*
权限管理 / Hook 机制
utils/git.ts + utils/Shell.ts + utils/claudemd.ts + utils/model/* + utils/telemetry/*
Git / Shell / CLAUDE.md / 模型能力 / OTel
1.3 分层职责与源码目录对应
| 层 | 主要源码目录 / 文件 | 负责什么 | 不负责什么 |
|---|---|---|---|
| 入口 / 初始化层 | src/cli/、src/entrypoints/、src/setup.ts、src/main.tsx、src/replLauncher.tsx |
进程启动、命令路由、环境初始化、会话 setup | 单轮 query 细节、工具执行语义 |
| 核心编排层 | src/query.ts、src/query/、src/QueryEngine.ts |
输入分流、主循环、上下文拼装、结果推进下一轮 | 具体工具实现、具体外部服务连接 |
| 界面层 | src/screens/、src/components/、src/ink/、src/keybindings/、src/vim/ |
REPL、Ink 渲染、键盘交互、Diff/Dialog 呈现 | 模型决策、工具协议定义 |
| 工具层 | src/Tool.ts、src/tools/、src/tools.ts |
工具契约、工具池、单工具与多工具执行 | 扩展源配置发现、身份/OAuth |
| 服务层 | src/services/、src/server/、src/plugins/、src/skills/ |
模型 API、MCP、Plugin/Skill/Agent、上下文压缩、LSP | UI 渲染、单轮编排主权 |
| 基础设施层 | src/utils/、src/constants/、src/types/、src/bootstrap/ |
配置、权限、hooks、Git/Shell、CLAUDE.md、types/constants 等跨层通用能力 | 会话级编排与产品逻辑 |
| 横切专题(不单独算层) | src/state/、src/context/、src/commands/、src/tasks/ |
状态、上下文、命令、任务这些跨层主题,后文单独拆专题分析 | 不能简单视为只服务某一层 |
按静态依赖理解即可:上层依赖下层,下层不反向依赖上层;上表列的是"主要落点",不是一一唯一归属。
1.4 运行模式与状态
| 运行形态 | 入口 / 复用 | 说明 |
|---|---|---|
| Interactive | launchRepl() |
有 Ink UI,适合终端对话 |
| Headless / SDK | print.ts / QueryEngine.ts / query() |
无 TUI,适合脚本、CI、宿主集成 |
| MCP Server | 共享服务与工具能力 | 暴露服务能力,不是 UI 会话 |
| Sub-agent / Background Task | 复用 query() |
独立消息与 prompt,部分共享工具和状态 |
| Fast-path 子命令 | cli.tsx 快速分支 |
直接执行轻量命令,不进入完整主循环 |
- 每轮刷新:工具池、命令集、上下文按快照更新。
- 会话内共享 :
AppState、连接状态、通知队列、plan mode 等持续存在。 - 跨会话恢复 :
--resume/--continue依赖持久化 transcript、metadata 与缓存重建。
二、全局设计原则与架构不变量
2.1 Design Principles
- 启动快于完备:能走快速路径就不加载完整系统,能延迟连接就不阻塞首屏
- 每轮快照优于全局强一致:本轮可见能力固定,变化在下一轮生效
- 安全先于便利:信任建立、权限审批、策略过滤优先于功能暴露
- 本地优于远端:优先利用本地状态、工作区与现有连接,避免不必要网络依赖
- 副作用统一收口:状态变更、工具结果、hooks、权限同步都有明确收口点
2.2 Architectural Invariants
编排主权
query()是模型调用唯一收口点 。所有对话推进------REPL、SDK、子 Agent、forked agent、agentic hook------必须经过query()/queryLoop()。旁路直接调 API 绕开主循环属于违规。tool_result必须配对且进入下一轮消息流 。每个tool_use有且仅有一个tool_result;中断时由StreamingToolExecutor生成合成的 error result 补齐配对。违反此不变量会导致 API 协议错误或模型推理失忆。- 能力变化按轮生效,mid-turn 不可变 。工具池、命令集、MCP 连接状态在当前轮开始时快照固定;
refreshTools()的结果只在下一轮可见。这保证主循环和工具执行器在单轮内看到一致的options.tools。
状态与副作用
AppState是会话级共享状态的单一事实源 。UI、工具运行时、bridge、权限流、agent 协调都读写同一个AppStateStore;不允许另建平行的会话级状态对象。- 副作用统一经过
onChangeAppState()收口 。权限模式同步、设置持久化、认证缓存清理等副作用不在调用点触发,而在 diff-basedonChangeAppState()中集中触发。调用点只负责setState()。 - 权限只收紧不放宽 。进入 auto mode / background agent 后,
stripDangerousPermissionsForAutoMode()剥离危险 allow 规则;shouldAvoidPermissionPrompts让无 UI 场景倾向保守拒绝。子 Agent 不能继承比父更宽松的权限。
安全边界
- 信任建立前不应用高风险配置 。项目级
.claude/settings.json的完整环境变量(包括ANTHROPIC_BASE_URL、HTTP_PROXY等)在showSetupScreens()通过后才生效;信任前只应用SAFE_ENV_VARS白名单。 - 工具暴露遵循"先不可见,再不可执行"原则。deny rules 和 mode 过滤在工具组装阶段就把不合规工具从 schema 中移除(第一道关),而不是暴露后再在执行时拦截。参见 §7.4 难点 5 的"四道关"模型。
- 扩展不能绕过主循环、权限与状态收口。MCP server、Plugin、Skill、Agent 的能力必须通过 Tool / Command / AppState 抽象接入系统;不允许扩展直接调模型 API 或直接修改 messages 数组。
生命周期与资源
- 静态依赖方向不可反转。基础设施层(utils/)和服务层(services/)不反向依赖 UI 层或主循环;运行时回调 / 回流不改变编译期依赖方向。
- MCP 连接创建与清理必须在同一作用域配对 。Agent 内联 MCP 连接在
finally块中 cleanup;引用型连接不跟随 Agent 清理。违反此不变量会导致连接泄漏或父上下文工具丢失。 - Context 缓存失效从底层往上层传播 。
clearMemoryFileCaches()(第 3 层)先清,getUserContext()/getSystemContext()的 memoized 结果(第 2 层)随之失效,getGitStatus()(第 1 层)独立清。跳层清缓存会导致读到过期的组合结果。
协议与一致性
userContext走消息通道,systemContext走 system prompt 通道 。两路注入的落点由context.ts统一决定,下游query()/compact()/runAgent()不自行判断上下文该塞到哪里。- Stop hooks 的执行序列严格串行:PostSampling(fire-and-forget)→ Stop hooks(blockingError / preventContinuation)→ Teammate hooks → Token budget check。顺序不可调换,否则 blocking error 和 preventContinuation 的语义会被打破。
- Agent 结束后不留痕 。
runAgent()的finally块逐一清理 MCP 连接、session hooks、prompt cache tracking、messages 数组、orphan todos、background bash tasks。Agent 可以持有父上下文引用,但不能在结束后残留副作用。 - Hook 执行不阻塞主循环的正常路径 。PreToolUse / PostToolUse hooks 可能 deny 或 modify 工具调用,但不会让主循环卡住;Stop hooks 的
blockingError通过continue回到循环顶部,不引入新的等待状态。
2.3 全局失败恢复原则
- 先在当前层兜底,兜不住再上抛:例如流式模型错误、工具权限拒绝、上下文过长恢复
- 恢复动作尽量显式:reactive compact、fallback model、权限拒绝、连接重试都应可追踪
- 不追求中途强一致:连接变化、工具更新、缓存失效以"下一轮生效"换取稳定性
- 失败不应默默吞掉:应尽量返回结构化结果、UI 提示或终止原因
2.4 演进约束
- 新能力应尽量接入现有 Tool / Command / Agent / AppState 抽象,而不是旁路实现
- 不应在信任建立前引入新的高风险 I/O 或网络副作用
- 不应让专题级复杂度重新散落回主循环章节
- 不应让恢复逻辑依赖隐式全局状态或无法重建的临时对象
三、One Request Lifecycle
3.1 请求进入与输入归一化
一次请求可以来自 REPL、SDK、Agent、forked agent 或后台任务,但最终都会被归一成"消息 + 运行上下文 + 可见工具/命令/状态快照"。
3.2 主循环推进
是
否
否
是
用户 / SDK / Agent 输入
processUserInput / 输入归一化
本地处理结束?
直接返回本地结果 / UI
query() 主循环
拼装 system prompt + user/system context
流式调用模型
是否产生 tool_use?
输出 assistant 结果并结束本轮
runToolUse / runTools / StreamingToolExecutor
生成 tool_result 并回流 UI
assistant + tool_result 进入下一轮 messages
主循环并不关心输入最初来自哪里,它关心的是:当前 messages 是什么、system prompt 是什么、这一轮有哪些工具可见、还有没有继续下一轮的必要。
四、入口 / 初始化层分析
4.1 职责与边界
从命令输入到会话就绪:快速路径分发 → 模块加载 → 环境初始化 → 信任确认 → 启动交互界面或 headless 输出。
边界:本层只负责把进程推进到"会话可运行"状态,不负责每轮 query 内部编排,也不负责具体工具、命令和状态子系统的细节实现。
4.2 关键领域模型
入口 / 初始化层的核心对象不是业务实体,而是几类启动编排角色:
cli.tsx::main():进程第一入口,决定是否命中 fast-pathmain.tsx::main()/run():CLI 主控制器,负责命令注册与 action 分流preAction:所有命令共享的前置初始化钩子init():一次性全局初始化,负责配置、网络、OAuth、遥测等进程级准备setup():会话级初始化,负责 cwd/worktree/hooks/UDS/commands/agents 装配showSetupScreens():信任与 onboarding 闸口,决定何时允许高风险能力生效
把这些对象放在一起看,入口层的真正职责是:区分"进程级一次性初始化"和"会话级每次进入主命令的初始化",并在进入 REPL / headless 之前完成两者衔接。
4.3 核心时序
preAction 与 action 的区别
打个比方:Commander(命令行框架)就像一个餐厅。
preAction是前台接待 ------不管你点什么菜(claude mcp、claude auth、claude plugin),进门都要经过前台登记(加载配置、初始化网络、挂载日志)。action是厨师做菜 ------只有你点了主菜claude(默认命令)时才触发,负责真正的业务逻辑(解析选项、启动会话、进入 REPL)。子命令(mcp/plugin/auth/doctor)有各自的"厨师",不走主命令的action。什么是 REPL?
REPL = Read-Eval-Print-Loop(读取-执行-打印-循环)。就是你在终端里和 Claude 对话的那个界面:你输入一句话(Read)→ Claude 处理并调用工具(Eval)→ 输出结果(Print)→ 等你下一句话(Loop)。它用 React/Ink 在终端里渲染出富文本界面(代码高亮、Diff 展示、进度条等),本质是一个"终端里的 React 应用"。
action 阶段 --- 类似'厨师做菜',仅主命令 claude 触发
preAction 阶段 --- 类似'前台接待',所有命令都经过
命中
未命中
交互模式
非交互 (-p/--print)
$ claude [prompt]
1. cli.tsx::main()
📍 cli.tsx:33
进程入口,解析 argv
文件末尾 void main() 立即调用
2. 快速路径检查
📍 cli.tsx:36-280
--version 零 import 直接输出
daemon/bridge/mcp 独立处理
直接处理并退出
零模块加载开销
3. 动态 import main.tsx
📍 cli.tsx:295
await import('../main.js')
触发 200+ 模块加载 (~135ms)
4. 模块顶层副作用 (import 期间立即执行)
📍 main.tsx:12 profileCheckpoint 打点
📍 main.tsx:16 startMdmRawRead() 启动 plutil 子进程
📍 main.tsx:20 startKeychainPrefetch() 启动 security 子进程
子进程与后续 200+ import 并行运行,不阻塞
5. main()
📍 main.tsx:585
安全设置、警告处理器注册
cc:// URL 重写、deep link 处理
ssh/assistant 参数预处理
6. 判断交互模式
📍 main.tsx:800-812
根据 -p/--print/--sdk-url/TTY 判断
设置 isInteractive 标志
7. run() → 创建 Commander
📍 main.tsx:884 run() 函数入口
📍 main.tsx:902 new CommanderCommand()
注册全部 options 和 subcommands
此时只是注册回调,不执行
8. preAction hook 触发
📍 main.tsx:907
Commander 解析完 argv 后、执行命令前触发
所有子命令共享(mcp/plugin/auth/doctor)
9. 等待预取结果(不是开始加载!)
📍 main.tsx:914
await Promise.all([
ensureMdmSettingsLoaded(),
ensureKeychainPrefetchCompleted()
])
步骤4已启动子进程,135ms import 期间已完成
这里 await 几乎瞬间返回
10. await init()
📍 main.tsx:916 → init.ts:57
memoized 一次性初始化:
enableConfigs() 配置系统 (L65)
applySafeConfigEnvVars() 环境变量 (L74)
setupGracefulShutdown() 优雅退出 (L87)
OAuth + JetBrains/Git 检测 (L94-118)
configureGlobalMTLS() + Proxy (L137-146)
preconnectAnthropicApi() TCP预连接 (L159)
11. initSinks() + runMigrations()
📍 main.tsx:934 挂载日志 sink
📍 main.tsx:950 运行数据结构迁移
12. 异步加载远程配置(非阻塞)
📍 main.tsx:957 void loadRemoteManagedSettings()
📍 main.tsx:958 void loadPolicyLimits()
后台加载企业配置,不等待完成
13. action() handler 触发
📍 main.tsx:1006
仅主命令 claude 触发,子命令有各自 action
解析所有 CLI 选项 (model/permission/tools...)
14. setup() 会话初始化
📍 main.tsx:1927 → setup.ts:56
并行执行三件事:
setup(): setCwd/worktree/UDS/hooks快照
getCommands(): 加载斜杠命令
getAgentDefinitions(): 加载 Agent 定义
15. showSetupScreens() 信任对话框
📍 main.tsx:2241 → interactiveHelpers.tsx:106
Onboarding 引导 / 工作区信任确认 / 登录
安全边界:先信任,再操作
16. initializeTelemetryAfterTrust()
📍 init.ts:247
信任建立后才初始化遥测
避免在不可信目录发送数据
17. 分流:交互 or 非交互?
📍 main.tsx:800-812 的 isInteractive 标志
18a. launchRepl() → REPL
📍 replLauncher.tsx:8
渲染 React/Ink App 组件
进入 Read→Eval→Print→Loop 循环
终端里的富文本对话界面
18b. print.ts headless
📍 main.tsx 约 L2850+
单次请求 → 流式输出 → 退出
适用于 CI/CD 和脚本调用
4.4 难点和设计取舍
难点 1:模块加载慢(~135ms),如何不让用户干等?
- 问题 :
main.tsx依赖 200+ 模块(React/Ink/OpenTelemetry 等),同步 import 约 135ms - 方案 :
- 快速路径分发 (
cli.tsx:36-280):cli.tsx是进程的第一个入口文件,它在 import main.tsx 之前先检查process.argv。如果命令是--version(L37)、--daemon-worker(L100)、remote-control(L112)、daemon(L165)、ps/logs/attach/kill(L185)等,就只await import该功能需要的少量模块(如bridgeMain.js、daemon/main.js),处理完直接return退出,完全不加载 main.tsx 及其 200+ 依赖。这意味着claude --version在几毫秒内就能返回。只有当没有命中任何快速路径时,才会走到 L295 的await import('../main.js')加载完整 CLI - Fire-and-forget 预取 (
main.tsx:12-20):在 import 语句之间插入startMdmRawRead()和startKeychainPrefetch(),利用"主线程加载模块时 CPU 阻塞但子进程可并行"的特性,让 I/O 子进程与 135ms 的模块加载重叠执行。到 preAction(main.tsx:914)await 时,子进程早已完成,等待几乎零耗时。这是 🔥/⏳ 异步编排模式在启动阶段的应用(query 循环中的同类模式见 §5.4 难点 3)
- 快速路径分发 (
难点 2:安全与性能的矛盾------信任建立前能做什么、不能做什么?
- 问题 :用户可能
cd到一个不可信的 Git 仓库,里面的.claude/settings.json可能被恶意 commit 设置了ANTHROPIC_BASE_URL指向攻击者服务器。但网络初始化、配置加载又需要尽早完成 - 方案------两阶段环境变量 (📍
managedEnv.ts):- 信任前 (
applySafeConfigEnvironmentVariables()atinit.ts:74):只应用来自可信来源 (~/.claude/settings.json用户配置、--settings命令行参数、企业托管配置)的所有环境变量 + 来自项目级配置 (.claude/settings.json、.claude/settings.local.json)中仅在SAFE_ENV_VARS白名单里的变量。白名单包含 Claude Code 自身的功能开关(如ANTHROPIC_MODEL、CLAUDE_CODE_USE_BEDROCK、BASH_DEFAULT_TIMEOUT_MS、OTEL_*系列 headers/protocol 等约 80 个),这些变量不会造成安全风险 - 信任后 (
applyConfigEnvironmentVariables()atinteractiveHelpers.tsx:184):信任对话框通过后,应用项目配置中的所有 环境变量,包括危险变量如ANTHROPIC_BASE_URL(可重定向 API 请求)、HTTP_PROXY/HTTPS_PROXY(可劫持网络)、NODE_TLS_REJECT_UNAUTHORIZED(可关闭 TLS 验证)、LD_PRELOAD/PATH(可注入恶意代码)等 showSetupScreens()(📍interactiveHelpers.tsx:104)具体做了什么:- Onboarding --- 首次使用时展示主题选择和引导流程(L111-123)
- TrustDialog --- 检查当前工作区是否已被信任,未信任则弹出对话框让用户确认(L131-140),这是安全边界
- 信任后触发一系列动作:重置 GrowthBook(L149-150)、预取系统上下文(L153)、检查 MCP 服务器审批 (
handleMcpjsonServerApprovalsL160)、检查 CLAUDE.md 外部 include(L164-170) - 应用完整环境变量(L184)、初始化遥测(L190)、Grove 政策弹窗(L191-201)、自定义 API Key 审批(L206-215)
initializeTelemetryAfterTrust()(📍init.ts:247)具体做了什么:- 对于有远程托管配置的用户:先等远程配置加载完成 → 再调
applyConfigEnvironmentVariables()确保 OTEL 端点变量生效 → 最后初始化 OpenTelemetry(doInitializeTelemetry()) - 对于普通用户:直接初始化 OpenTelemetry
- 核心目的:确保在用户确认信任前不发送任何遥测数据到可能被篡改的端点
- 对于有远程托管配置的用户:先等远程配置加载完成 → 再调
- 信任前 (
难点 3:52 个子命令如何共享初始化又各自独立?
- 问题 :
claude共有 52 个子命令(mcp serve/add/remove/list/get、plugin install/list/marketplace、auth login/logout/status、doctor、config、update、skill等),它们都需要配置、网络、日志等基础初始化,但业务逻辑完全不同 - 子命令如何触发 :全部在
run()函数(main.tsx:3892-4684)中通过 Commander 注册。例如claude mcp serve对应program.command('mcp').command('serve').action(async () => { ... })(L3894-3909)。用户执行claude mcp serve时,Commander 解析 argv 匹配到该子命令,先执行preActionhook(通用初始化),再执行该子命令自己的.action()handler。注意这些是 Commander 子命令,不是独立的系统命令------所以直接执行mcp会报command not found,必须通过claude mcp触发 - 架构决策------preAction/action 分层 :Commander 的
program.hook('preAction')天然解决共享初始化问题------注册一次,所有命令执行前自动触发。通用初始化(配置/网络/OAuth/日志/迁移)放preAction;各命令的差异化逻辑放各自的action。主命令的action最重(1800+ 行:选项解析、setup、信任、REPL),子命令的action很轻(通常只是await import('./handler.js'); handler()) - print 模式优化 :
-p/--print模式跳过全部 52 个子命令的注册(main.tsx:3883-3889),因为 Commander 会把 prompt 路由到默认 action,子命令永远不会被触发。这省去了约 65ms 的启动时间
难点 4:MCP 服务器和 Skills/Plugins 的初始化时机
- 问题:MCP 服务器连接涉及网络 I/O(TCP/stdio 子进程),可能耗时数秒;Skills 和 Plugins 需要读取磁盘和 marketplace。这些都不能阻塞首屏渲染
- 方案------延迟到 REPL 渲染后 :
- 内置 Skills + Plugins 注册 (
main.tsx:1923-1925):initBuiltinPlugins()和initBundledSkills()在 action handler 中、setup() 之前调用,是纯内存操作(<1ms),注册内置的 Skills(如/init、/compact)和 Plugins 到全局数组 - MCP 服务器连接 (
screens/REPL.tsx中的useManageMCPConnectionshook):不在启动流程中连接 ,而是在 REPL React 组件挂载后,通过 ReactuseEffect异步触发。先调getClaudeCodeMcpConfigs()(📍mcp/config.ts:1071)从多个配置源(user/project/local/plugin/enterprise)合并所有 MCP 配置,再逐个异步连接。连接过程不阻塞 REPL 渲染和用户输入 - Plugin MCP 服务器 :
getClaudeCodeMcpConfigs()内部调loadAllPluginsCacheOnly()(📍pluginLoader.ts:3137)从本地缓存读取 plugin 列表(不走网络),再通过extractMcpServersFromPlugins()提取每个 plugin 声明的 MCP 服务器 - LSP 服务管理器 (
main.tsx:2321):initializeLspServerManager()在信任对话框之后才初始化,防止不可信目录的 plugin LSP server 在用户授权前执行代码 - 延迟预取 (
main.tsx:388-431):startDeferredPrefetches()在 REPL 首次渲染后才执行,包括initUser()、getUserContext()、prefetchSystemContextIfSafe()、refreshModelCapabilities()、skill/settings 文件变更检测器等,全部是火后不管模式
- 内置 Skills + Plugins 注册 (
五、核心编排层分析
5.1 职责与边界
每轮对话的核心循环:上下文压缩 → 流式调用模型 → 并行执行工具 → 收集附件 → 拼接结果进入下一轮,直到模型结束或异常退出。
边界 :本章只讨论 query() 及其周边编排如何驱动一次对话前进;Command / Context / AppState 的内部机制分别放到后文第 10-12 章专题中展开。
5.2 关键领域模型
核心编排层的关键对象是一轮 query 如何被组织:
query()/queryLoop():模型调用、工具执行、恢复重试的统一主循环State:当前轮及跨轮推进所需的可变状态集合,如messages、toolUseContext、turnCount、transitionToolUseContext:主循环与工具层之间的运行时桥梁budgetTracker / autoCompactTracking:上下文预算与压缩治理对象StreamingToolExecutor / runTools():工具执行编排器,分别对应流式与批量两条执行路径
这层不是"定义全部能力"的地方,而是把命令、上下文、状态、工具和服务能力按正确顺序串成一个可持续推进的对话回合。
5.3 核心时序
query() 从哪里被调用?
query()是所有对话逻辑的统一入口,有 6 个调用方:
- REPL 交互模式 (
screens/REPL.tsx:2793):用户在终端输入 →onQuery()→onQueryImpl()→for await (const event of query(...))→onQueryEvent(event)逐事件驱动 UI 更新- SDK / Headless 模式 (
QueryEngine.ts:675):submitMessage()→for await (const message of query(...))→ 通过回调输出 SDK 事件- 子 Agent (
tools/AgentTool/runAgent.ts:748):AgentTool 为子 agent 创建隔离的消息数组和 systemPrompt,调query()运行独立的多轮对话- Fork Agent (
utils/forkedAgent.ts:545):runForkedAgent()为后台任务(compact、session_memory、auto-dream 等)fork 出独立 query 循环- Agent Hook (
utils/hooks/execAgentHook.ts:167):agentic 类型的 hook 通过query()获得多轮推理能力- 后台会话任务 (
tasks/LocalMainSessionTask.ts:383):后台长期运行的会话任务
什么是 AsyncGenerator?
query() 是一个 async function*,每次处理到一个事件(如模型的一段文本、一个工具执行结果)就通过 yield 吐出,调用方(REPL 或 QueryEngine)通过 for await...of 增量消费。这意味着 UI 不需要等整个对话轮次结束才更新------每个 token、每个工具结果都能实时渲染。
有 tool_use 路径 --- 执行工具 + 收集附件 + 下一轮
无 tool_use 路径 --- 错误恢复 + 停止检查
上下文压缩管线 --- 5 层递进,从轻到重
谁调用 query()?
超限
正常
否 (end_turn)
是
blockingError
(exit code 2)
钩子要求模型修复问题
preventContinuation
(JSON 输出 continue:false)
钩子要求终止整个对话
通过
是
否/完成
继续
完成
是
否
超限
继续
REPL 交互模式
📍 REPL.tsx:2793
onQueryImpl → for await
SDK / Headless
📍 QueryEngine.ts:675
submitMessage()
子 Agent / Fork
📍 runAgent.ts:748
forkedAgent.ts:545
1. query() → queryLoop() 入口
📍 query.ts:219-241
query() 是薄包装:yield* queryLoop() + 命令生命周期通知
queryLoop() 解构不可变参数(systemPrompt/canUseTool/maxTurns)
初始化可变 State + budgetTracker
🔥 启动 memoryPrefetch(整个循环只一次,using 自动 dispose)
2. while(true) 循环顶部
📍 query.ts:307-335
解构 state → 裸名变量(messages/toolUseContext/turnCount)
🔥 启动 skillDiscoveryPrefetch(异步,与后续流式并行)
⏳ await 上一轮的 pendingToolUseSummary 并 yield
yield { type: 'stream_request_start' } 通知 UI
3. 压缩管线入口
3a. toolResultBudget
📍 query.ts:379-394
applyToolResultBudget()
限制单条 tool_result 体积
超大结果替换为占位符 + 存储引用
3b. snip
📍 query.ts:401-410
snipCompactIfNeeded()
移除最早的非关键消息
返回 tokensFreed 供后续阈值判断
3c. microcompact
📍 query.ts:414-426
压缩旧 tool_result 冗余内容
cached microcompact: 利用 API cache 编辑
3d. contextCollapse
📍 query.ts:440-447
将历史分段折叠为摘要
读时投影(不修改 REPL 消息数组)
先于 autocompact------若折叠够了就跳过整体压缩
3e. autocompact
📍 query.ts:454-468
token 超阈值时整体压缩
调 LLM 生成对话摘要替换全部历史
更新 taskBudgetRemaining
4. 阻塞限制检查
📍 query.ts:628-648
仅在 autocompact 未触发
且无 reactiveCompact/collapse 兜底时检查
isAtBlockingLimit?
return { reason: 'blocking_limit' }
5. 调用 Anthropic API
📍 query.ts:659-708
deps.callModel() 流式 SSE
传入 messages/systemPrompt/tools/model
支持 fallbackModel 降级重试
支持 taskBudget remaining 透传
6. 流式响应处理
📍 query.ts:708-863
for await (message of stream):
• yield 流式事件给 UI(实时渲染)
• 收集 assistantMessages
• 识别 tool_use 块 → needsFollowUp=true
• 🔥 StreamingToolExecutor.addTool() 立即启动执行
• ⏳ getCompletedResults() 实时 yield 已完成工具结果
• 扣留可恢复错误(PTL/media/max-output-tokens)
• fallback 降级:清空 + 换模型 + 重试
7. 是否有 tool_use?
📍 query.ts:1062
needsFollowUp?
8a. 错误恢复
📍 query.ts:1062-1256
prompt-too-long → collapse drain → reactive compact
max-output-tokens → 升级到 64k(同请求重试,无额外 API 调用)→ 注入续写提示
media-size-error → reactive compact strip-retry
每种恢复最多尝试一次,失败则 surface 错误
9a. 执行工具
📍 query.ts:1363-1408
⏳ StreamingToolExecutor.getRemainingResults()
(消费流式期间已启动的执行 + 等待剩余)
或 runTools()(非流式回退)
权限检查 → 并行/串行执行 → PostExec hook
8b. PostSampling Hooks
📍 query.ts:999-1009
🔥 executePostSamplingHooks() fire-and-forget
不阻塞主循环,不影响对话结果
8c. Stop Hooks
📍 query.ts:1267 → stopHooks.ts:65
handleStopHooks() 是 AsyncGenerator:
① 执行用户定义的 Stop 钩子
例:command 类型 --- npm test / eslint .
例:prompt 类型 --- 让 LLM 审查回答质量
例:agent 类型 --- 多轮验证代码正确性
② 🔥 提示建议 / 记忆提取 / autoDream(fire-and-forget)
③ 收集 blockingErrors + preventContinuation
hook 结果?
注入 blocking error 到 messages
stopHookActive = true
→ 回到 while 循环让模型重试
return { reason: 'stop_hook_prevented' }
8d. Teammate Hooks?
📍 stopHooks.ts:334-452
仅 Teammate 模式
TaskCompleted hooks
(逐个 in-progress 任务)
- TeammateIdle hooks
8e. Token Budget
📍 query.ts:1308-1355
checkTokenBudget():
输出 token 未达预算?
注入续写提示
state.transition = 'token_budget_continuation'
return { reason: 'completed' }
📍 query.ts:1357
9b. 工具摘要
📍 query.ts:1411-1482
🔥 generateToolUseSummary() fire-and-forget
Haiku ~1s,存入 pendingToolUseSummary
⏳ 下一轮循环步骤 2 才 await 消费并 yield
9c. 用户中断?
📍 query.ts:1485
return { reason: 'aborted_tools' }
9d. 收集附件
📍 query.ts:1580-1628
• getAttachmentMessages():文件变更通知
• ⏳ pendingMemoryPrefetch:零等待轮询(没 settle 就跳过)
• ⏳ pendingSkillPrefetch:消费步骤 2 启动的预取
• queuedCommandsSnapshot:排队的用户命令/通知
9e. 刷新工具列表
📍 query.ts:1660-1671
refreshTools():MCP 新连接的服务器
可用工具列表可能已变化
9f. 轮次上限?
📍 query.ts:1705
return { reason: 'max_turns' }
9g. 构造下一轮 State
📍 query.ts:1715-1727
messages += assistant + toolResults
turnCount++, transition = 'next_turn'
回到 while(true) 循环顶部
流程图中的异步编排标注说明
流程图中用 🔥 标注 fire-and-forget 启动点,用 ⏳ 标注延迟消费点。异步编排的核心思想是"启动和消费分离"------在 CPU 空闲时启动 I/O(🔥),在必须要结果时才等待(⏳)。这不是一个独立的步骤,而是贯穿整个循环的设计模式,所以没有单独的流程图节点,而是用标注嵌入到各个步骤中。具体来说:
- memoryPrefetch:🔥 步骤 1 启动 → ⏳ 步骤 9d 零等待轮询消费
- skillDiscoveryPrefetch:🔥 步骤 2 启动 → ⏳ 步骤 9d 消费(98%+ 已完成)
- StreamingToolExecutor:🔥 步骤 6 流式启动工具 → ⏳ 步骤 6 实时 yield + 步骤 9a 消费剩余
- toolUseSummary :🔥 步骤 9b 启动 → ⏳ 下一轮步骤 2 消费(跨轮延迟!)
- PostSampling/promptSuggestion/memoryExtraction/autoDream:🔥 步骤 8b-8c 启动,完全不等待结果
5.4 难点和设计取舍
难点 1:模型响应流式到达,工具执行如何不等响应结束就开始?
- 问题 :模型一次响应可能包含多个
tool_use块(如先 Read 文件再 Edit 文件),传统做法是等整个响应完成再批量执行工具。但模型响应可能持续 5-30 秒,白白等待浪费时间 - 方案------
StreamingToolExecutor(📍services/tools/StreamingToolExecutor.ts):- 在流式循环(L838-844)中,每识别到一个完整的
tool_use块就立即调streamingToolExecutor.addTool(toolBlock, message)启动执行 - 执行器内部维护并发控制:标记为
concurrent的工具(如 FileRead、Grep)可以并行;非并发工具(如 Bash、FileEdit)必须独占执行 - 流式期间通过
getCompletedResults()(L851)实时 yield 已完成工具的结果给 UI - 响应结束后调
getRemainingResults()(L1381)消费剩余执行中的工具结果 - 中断处理:如果用户 Ctrl+C,executor 为所有未完成工具生成合成的
tool_result(is_error: true),保证 API 协议的 tool_use/tool_result 配对不被破坏
- 在流式循环(L838-844)中,每识别到一个完整的
难点 2:多种异步操作在循环的不同阶段完成,如何高效编排不阻塞?
- 问题:每轮循环涉及多个异步操作------skill 发现(~250ms)、memory 预取(sideQuery 调用)、工具摘要生成(Haiku ~1s)、排队命令快照、文件变更检测------如果串行等待会严重拖慢每轮循环
- 方案------Fire-and-forget + 延迟消费 (见流程图中的 🔥/⏳ 标注):
- Skill 发现 :🔥 步骤 2 循环顶部启动
startSkillDiscoveryPrefetch()→ ⏳ 步骤 9d 工具执行完成后才await collect,此时 prefetch 早已完成(98%+ 命中率),零等待 - Memory 预取 :🔥 步骤 1
queryLoop入口启动一次(不是每轮),用using语法自动 dispose → ⏳ 步骤 9d 每轮通过settledAt零等待轮询------如果还没 settle 就跳过,下一轮再试。consumedOnIteration防止重复消费 - 工具摘要 :🔥 步骤 9b
generateToolUseSummary()fire-and-forget,Promise 存入nextPendingToolUseSummary→ ⏳ 下一轮步骤 2 流式响应结束后才 await------此时 Haiku 的 ~1s 已与当前轮的 API 调用(5-30s)重叠完成 - 排队命令 :步骤 9d
getCommandsByMaxPriority()是同步快照,但通过 agentId 过滤确保主线程和子 agent 各取各的队列 - PostSampling / promptSuggestion / memoryExtraction / autoDream:🔥 步骤 8b-8c 全部 fire-and-forget,完全不等待结果
- Skill 发现 :🔥 步骤 2 循环顶部启动
难点 3:max_output_tokens 截断------模型写到一半被切断怎么办?
-
问题 :模型输出超过
max_tokens限制时,API 返回stop_reason: max_output_tokens,输出被截断在任意位置(可能在代码中间)。用户看到的是不完整的回答 -
方案------三级恢复 (📍
query.ts:1185-1256):- 升级重试 (L1199-1221):如果使用的是默认的 8k 上限,直接把
maxOutputTokensOverride提升到 64k,用同一条消息 重试------不生成任何 meta 消息,用户无感。仅触发一次(maxOutputTokensOverride === undefined守卫) - 注入续写提示 (L1223-1251):如果 64k 也不够,注入一条 meta 消息
"Output token limit hit. Resume directly --- no apology, no recap..."要求模型从截断处继续。最多重试 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) - 放弃恢复(L1254-1256):3 次都不够,surface 被扣留的错误消息,退出循环
- 关键实现细节:流式期间通过
isWithheldMaxOutputTokens()扣留错误(L820),不 yield 给 UI,直到恢复逻辑决定是否可以自动修复。这避免了 SDK 消费方(如 Desktop 应用)看到中间错误就终止会话
- 升级重试 (L1199-1221):如果使用的是默认的 8k 上限,直接把
-
升级重试会消耗更多 token 吗? 不会产生额外的 API 调用开销。理解这一点的关键是区分
max_tokens(输出上限)和实际消耗:max_tokens只是告诉 API "最多输出这么多 token",不是预付费。API 按实际输出的 token 计费- 升级重试的工作原理:第一次请求设
max_tokens=8192,模型输出到 8192 时被截断。循环continue回到步骤 5,用完全相同的 messages (没有新增任何消息)再次调 API,但这次设max_tokens=65536。模型会重新生成回答(因为输入相同,利用 prompt cache 命中,输入 token 几乎零成本),这次有足够空间一次写完 - 所以升级重试确实会多调一次 API(步骤 5→6→7 再走一遍),但:① 输入 token 走 prompt cache 几乎免费;② 第一次被截断的 8k 输出 token 是浪费的,但比起注入续写提示(需要多轮对话、每轮都有输入+输出 token)要便宜得多;③ 只触发一次,不会循环
- 如果第一次就设 64k 呢?默认用 8k 是为了控制成本------大多数回答不需要 8k,用更小的上限可以让 API 更快结束。只有真正需要长输出时才升级
难点 4:Stop Hooks 机制------模型回答完,如何让外部逻辑参与决策?
-
问题 :模型完成一轮回答后(
end_turn),不能直接返回给用户。用户可能配置了自动化检查(如运行测试、lint 代码),这些检查的结果可能要求模型修改回答。同时系统内部也需要在每轮结束时执行副作用(提示建议、记忆提取等) -
先理解 Hook 是什么 :Hook 是用户在
.claude/settings.json或~/.claude/settings.json中配置的自动化动作,绑定到特定事件上。Hook 有 4 种类型:- command (Shell 命令):如
npm test、eslint .、python -m pytest------ 执行一个 shell 命令,根据退出码决定行为 - prompt(LLM 提示):给 Claude 发一段文本让它审查上一轮回答的质量 ------ 相当于让 LLM 做 self-review
- agent(Agentic 验证器):启动一个多轮的子 agent 来验证代码正确性 ------ 比 prompt 更强大,可以读文件、运行测试
- http(HTTP 回调):向外部服务发 HTTP 请求 ------ 用于与 CI/CD 或自定义服务集成
- command (Shell 命令):如
-
Stop Hook 的两种"否决权"------这是最容易混淆的点:
- blockingError (退出码 = 2):钩子发现了问题,要求模型修复后重试 。比如
npm test返回退出码 2 + stderr "3 tests failed",这条错误信息被注入到 messages 数组,循环continue回到步骤 2,模型看到错误信息后会尝试修复代码。打个比方:考官发现答题有错,打回去重做 - preventContinuation (JSON 输出
{ "continue": false }):钩子决定终止整个对话 ,直接return { reason: 'stop_hook_prevented' }。比如一个安全审查 hook 发现模型要删除系统文件,输出{ "continue": false, "stopReason": "dangerous operation detected" },对话立即终止。打个比方:监考官直接叫停考试
- blockingError (退出码 = 2):钩子发现了问题,要求模型修复后重试 。比如
-
完整的 "end_turn" 后执行序列 (📍
query.ts:999-1357+stopHooks.ts:65-473):模型返回 end_turn(无 tool_use) │ ├─ ① 错误恢复(8a):prompt-too-long / max-output-tokens / media-size │ 如果需要恢复 → continue 重试,不走后续步骤 │ ├─ ② PostSampling Hooks(8b)📍 query.ts:999-1009 │ executePostSamplingHooks() ------ 🔥 fire-and-forget │ 把 assistant 消息发给所有注册的 PostSampling 钩子 │ 不等结果、不阻塞、不影响对话走向 │ 用途:外部监控、日志记录、数据收集 │ ├─ ③ Stop Hooks(8c)📍 stopHooks.ts:65-173 │ handleStopHooks() ------ 是 AsyncGenerator,通过 yield* 委托执行 │ ├─ 执行用户配置的 Stop 事件钩子(command/prompt/agent/http) │ │ → 如果 blockingError(exit code 2)→ 注入错误,continue 重试 │ │ → 如果 preventContinuation(continue:false)→ 直接 return 终止 │ │ → 如果正常通过 → 继续 │ ├─ 🔥 executePromptSuggestion():生成"你可能想问..."的建议 │ ├─ 🔥 executeExtractMemories():从对话中提取值得记住的信息 │ ├─ 🔥 executeAutoDream():后台自动整理项目理解 │ └─ await classifyAndWriteState():Templates 场景的任务状态分类 │ ├─ ④ Teammate Hooks(8d)📍 stopHooks.ts:334-452 │ 仅在 Teammate 模式下触发(多 agent 协作场景) │ ├─ TaskCompleted hooks:对每个 in-progress 任务执行 │ └─ TeammateIdle hooks:通知团队该 teammate 已空闲 │ 两者都支持 blockingError 和 preventContinuation │ └─ ⑤ Token Budget(8e)📍 query.ts:1308-1355 checkTokenBudget():输出 token 是否达到预算? → 未达到 → 注入续写提示,continue → 达到 → return { reason: 'completed' }- 关键设计 :②③④⑤ 是严格串行的。② fire-and-forget 不阻塞但必须先启动;③ 必须在 ④ 之前(Stop hook 的 blocking error 应该先被模型处理,再判断 Teammate 状态);④ 必须在 ⑤ 之前(Teammate hooks 可能 preventContinuation)。
hasAttemptedReactiveCompact在 ③ 的 blocking error 路径不重置(L1297),防止 "compact → 还是太长 → stop hook block → compact → ..." 的无限循环
- 关键设计 :②③④⑤ 是严格串行的。② fire-and-forget 不阻塞但必须先启动;③ 必须在 ④ 之前(Stop hook 的 blocking error 应该先被模型处理,再判断 Teammate 状态);④ 必须在 ⑤ 之前(Teammate hooks 可能 preventContinuation)。
六、界面层分析
6.1 职责与边界
界面层基于 React + Ink 负责终端里的读取、展示、交互和局部控制:输入框、消息列表、Diff、对话框、键盘绑定、局部通知、主题和视图切换都属于这一层。
边界:界面层不决定模型推理、工具协议和外部服务连接;它消费核心层产出的事件与状态,并把用户交互转回标准输入路径。
6.2 关键领域模型
UI 层可以抽成 5 组对象:
screens/REPL.tsx:交互式会话外壳,承接输入、渲染和 query 事件消费components/App.tsx + components/*:消息、对话框、Diff、状态提示等组件树hooks/*:把 AppState、命令、工具、Bridge、IDE、clipboard 等能力接入 UI 生命周期keybindings/*/vim/*:终端输入规则、快捷键和 vim 模式ink/*/outputStyles/*:终端渲染能力与样式适配层
6.3 核心时序
是
否
REPL 首次渲染
AppState / Keybinding / UI Providers 建立
用户输入 / 快捷键 / 粘贴 / UI 操作
processUserInput / 命令与文本分流
本地命令或本地 JSX?
直接更新 UI / 本地结果
进入 query()
流式事件 / 工具结果 / 状态变化
消息列表、Diff、Dialog、Footer 实时刷新
界面层最关键的不是"把一段文本画出来",而是把持续流动的 query 事件、工具结果和本地控制行为统一收敛到同一套终端交互体验里。
6.4 难点和设计取舍
难点 1:一边持续收消息,一边还要让用户继续输入和操作,界面怎么不乱?
- 直观问题:REPL 不只是聊天窗口,它还同时要做输入框、Diff 预览、权限弹窗、通知中心。模型 token、工具结果、键盘操作会一起涌进来;如果每个组件都自己保存一份"当前会话状态",界面很快就会互相打架。
- 通俗做法 :让
screens/REPL.tsx当"总控台":- 所有 query 事件、命令结果、本地交互先汇总到 REPL
- 消息列表、Diff、Dialog、Footer 这些组件只负责显示,不负责决定会话怎么推进
- React 渲染期用
useAppState(selector)订阅切片;事件回调里用useAppStateStore().getState()按需读取,避免所有场景都靠组件状态硬扛
- 一句话理解 :只有 REPL 负责主持全场,其他组件只负责把当前局面显示出来。
难点 2:同一套能力既要在交互式终端里显示卡片,也要在 headless / SDK 里工作,怎么不把 UI 写死在能力里?
- 直观问题 :
/config、/permissions这类命令在 TUI 里可以弹界面,但到了 headless / SDK / bridge 模式,未必还有 Ink 可以挂载。如果底层默认"必须有 UI 才能工作",很多运行模式都会失效。 - 通俗做法 :先把能力本身定义清楚,再让不同宿主决定怎么呈现:
- interactive 模式走
launchRepl(),可以挂本地 JSX 和 Dialog - headless / SDK 直接消费
query()的输出事件,不要求 Ink 存在 - remote / bridge 的限制留在命令和能力暴露层处理,而不是塞进每个 UI 组件里
- interactive 模式走
- 一句话理解 :UI 是外壳,不是能力本体;能显示成卡片就显示,不能显示就退回文本/事件流。
七、工具层分析
7.1 职责与边界
统一工具契约定义 → 按权限和连接状态组装工具池 → 流式/批量编排执行 → 校验、权限、Hook 拦截 → 结果回流至 UI 和下一轮上下文。
边界:工具层定义"模型如何看到并调用能力",以及"能力如何被统一执行";但工具来源的发现、扩展接入和外部连接管理属于服务层。
7.2 关键领域模型
什么是 Tool?
Tool不是某个具体工具的类名,而是一份统一协议 (📍Tool.ts:362-695):只要对象满足name、inputSchema、call()、checkPermissions()、mapToolResultToToolResultBlockParam()等方法,它就能被主循环当成"一个可调用工具"处理。BashTool、FileReadTool、AgentTool、MCP tools 虽然行为完全不同,但在query()看来它们都只是Tool[]里的一个元素。什么是 ToolUseContext?
ToolUseContext(📍Tool.ts:158-300)是"工具执行期的运行时现场"。它不仅携带options.tools(当前工具池),还带着abortController、messages、getAppState()/setAppState()、通知/UI 回调、refreshTools()等会话级能力。工具本身尽量不直接依赖全局单例,而是通过这个上下文拿到运行所需的数据和回调。为什么
Tool.ts要同时定义执行协议和 UI 协议?因为 Claude Code 的工具不是"纯后端 RPC"。同一个工具既要告诉模型"我能做什么",也要在终端里显示"我正在做什么/做完了什么/被拒绝了什么"。所以
Tool接口里既有call()、validateInput()、checkPermissions()这类执行方法,也有renderToolUseMessage()、renderToolResultMessage()、renderToolUseProgressMessage()这类 UI 方法。工具定义层本身就同时承担了"协议 + 展示"的双重职责。
先看 Tool 的领域模型
如果把 Tool 系统当成一个小型领域模型来看,核心不是"某个 Bash 命令怎么执行",而是:Tool 作为统一协议,ToolUseContext 作为运行现场,ToolResult 作为回流载体,runToolUse() / runTools() / StreamingToolExecutor 作为执行编排层。下面这张图先回答"有哪些核心对象、它们彼此是什么关系",后面的时序图再回答"这些对象在一次真实调用里按什么顺序协作"。
input
build complete Tool
contains
call(context)
returns
find / invoke
consume
produce
delegate
delegate
read from options.tools
use when streaming enabled
use when streaming disabled
<<interface>>
Tool
+name
+inputSchema
+call(args, context, canUseTool, parentMessage)
+validateInput(input, context)
+checkPermissions(input, context)
+isConcurrencySafe(input)
+mapToolResultToToolResultBlockParam(content, toolUseID)
+renderToolUseMessage()
+renderToolResultMessage()
ToolDef
+partial tool definition
buildTool
+fill default behaviors
Tools
+readonly Tool[]
ToolUseContext
+options.tools
+abortController
+messages
+options.refreshTools()
+getAppState()
+setAppState()
ToolResult
+data
+newMessages
+contextModifier
+mcpMeta
runToolUse
+single tool_use execution
runTools
+batch orchestration
StreamingToolExecutor
+streaming orchestration
query
+collect tool_use
+yield tool_result
核心类型与例子
Tool(统一协议)- 代表"一个可被模型调用的工具对象"
- 例子:
BashTool、FileReadTool、AgentTool
ToolUseContext(运行现场)- 代表"工具调用时可读取和可回写的上下文"
- 例子:
options.tools、abortController、messages、refreshTools()
ToolResult(执行结果载体)- 代表"工具返回给主循环的结构化结果"
- 例子:
data、newMessages、contextModifier、mcpMeta
ToolDef/buildTool()(定义期构造器)- 代表"具体工具如何声明自己,以及如何被补齐默认行为"
- 例子:某个工具只实现
call()/inputSchema/checkPermissions(),其余由buildTool()填默认值
runToolUse()/runTools()/StreamingToolExecutor(执行编排层)- 代表"单工具执行、批量执行、流式执行"三种不同粒度的执行器
- 例子:
runToolUse()处理单个tool_use,runTools()处理批量批次,StreamingToolExecutor处理边流边执行
从职责视角看,工具大致可以分成 4 组
- 1. 基础执行工具 :直接完成"读、写、搜、跑"这些底层动作,是模型最常用的基本操作单元
- 例子:
BashTool、FileReadTool、FileEditTool、FileWriteTool、GlobTool、GrepTool、NotebookEditTool
- 例子:
- 2. 会话控制工具 :不直接处理业务数据,而是控制当前会话怎么继续、怎么切模式、怎么和用户交互
- 例子:
AskUserQuestionTool、EnterPlanModeTool、ExitPlanModeTool、TodoWriteTool、BriefTool、SkillTool
- 例子:
- 3. 扩展接入工具 :把 Claude Code 之外的能力接进来,让工具池不只局限于本地内建能力
- 例子:MCP tools、
ReadMcpResourceTool、ListMcpResourcesTool、LSPTool、WebFetchTool、WebSearchTool
- 例子:MCP tools、
- 4. 协作与任务工具 :把"执行一个动作"提升为"派发任务、调度 agent、管理协作状态"
- 例子:
AgentTool、TaskCreateTool、TaskGetTool、TaskUpdateTool、TaskListTool、TaskStopTool
- 例子:
其中 SkillTool 归在"会话控制工具":它自己通常不直接完成读写搜跑,而是把模型的意图分发到 prompt-based command / skill,必要时再委托 agent,因此更像"当前会话里的能力分发器"。
7.3 核心时序
领域模型回答的是"有哪些对象、它们是什么关系";核心时序回答的是"当模型真的发出一个 tool_use 时,这些对象如何按顺序协作"。下面这张图只关注运行时调用链。
结果回流层 --- 工具结果如何进入 UI 和下一轮上下文
执行内核 --- 单个 tool_use 的完整调用链
编排层 --- 多个工具如何排队、并发、收结果
主循环接入层 --- 识别 tool_use 并触发执行
工具池组装层 --- 哪些工具对当前会话可见?
定义层 --- Tool 契约与具体实现
是:边流式边执行
否:回合末批量执行
1. Tool.ts 定义统一契约
📍 Tool.ts:158-300 / 321-336 / 362-792
ToolUseContext / Tool / ToolResult / Tools / buildTool()
规定输入 schema、权限、执行、渲染、结果映射
2. tools/* 导出具体工具
示例:BashTool / FileReadTool / AgentTool
通过 buildTool() 补齐默认行为
不同工具共享同一 Tool 接口
3. tools.ts 注册基础工具池
📍 tools.ts:193-367
getAllBaseTools() 列出 built-in tools
getTools() 按权限/模式过滤
assembleToolPool() 合并 built-in + MCP
4. REPL / Headless 组装运行期工具池
📍 useMergedTools.ts:20-44
📍 REPL.tsx:2404-2436
📍 main.tsx:1864-1891 / QueryEngine.ts:335-365
把 tools 放进 ToolUseContext.options.tools
并挂上 refreshTools() 供回合间刷新
5. query() 把 tools 发给模型
📍 query.ts:563-663
callModel() 传入 toolUseContext.options.tools
模型从完整工具池中选择要调用的 tool_use
6. 流式响应中收集 tool_use
📍 query.ts:826-845
assistant message 到达时提取 tool_use blocks
统一先 push 到 toolUseBlocks
若已创建 executor,则同一时刻 addTool() 立即启动
7. 当前回合是否启用 StreamingToolExecution?
📍 query.ts:561-568 / 1380-1383
config.gates.streamingToolExecution = true ?
true: 创建 StreamingToolExecutor
false: 本轮只收集,回合末统一 runTools()
8a. StreamingToolExecutor
📍 StreamingToolExecutor.ts:40-519
什么时候走:Streaming gate 开启
怎么执行:tool_use 一完整就 addTool()
何时收尾:本轮结束后 getRemainingResults()
特点:边收响应边执行工具
8b. runTools()
📍 toolOrchestration.ts:19-82
什么时候走:Streaming gate 未开启
怎么执行:本轮 assistant 全部收完后再统一执行
内部仍按 isConcurrencySafe 分批
特点:不是另一轮,而是"本轮末尾批量消费"
9. runToolUse()
📍 toolExecution.ts:337-490
findToolByName() 定位工具
inputSchema.safeParse() 解析输入
进入统一执行管线
10. 校验 / Hook / 权限 / tool.call()
📍 toolExecution.ts:599-1745
validateInput() → PreToolUse hooks
checkPermissions()/canUseTool()
tool.call() → mapToolResultToToolResultBlockParam()
11. addToolResult() 生成 tool_result 消息
📍 toolExecution.ts:1403-1474
createUserMessage() 写入 toolUseResult / mcpMeta
构造 user message 返回给 query()
12. query() 回吐工具结果
📍 query.ts:1384-1407
yield update.message 给当前 UI/SDK 流
并 normalizeMessagesForAPI() 存入 toolResults
13. refreshTools() 更新下一轮工具池
📍 query.ts:1660-1671
MCP 新连接的服务器在下一轮即可见
14. messages += assistant + toolResults
📍 query.ts:1715-1727
tool_result 作为 user message 进入下一轮上下文
模型据此决定后续回答或继续调用工具
7.4 难点和设计取舍
难点 1:工具形态很多、执行环节也很多,如何既统一抽象,又不把执行链写散?
- 问题 :这里其实有两层复杂度叠在一起:
- 工具形态差异大 :既有
BashTool、FileReadTool这种本地能力,也有AgentTool、SkillTool、MCP tools、LSP tools 这类远端/复合能力 - 单次调用环节多 :一个
tool_use真正落地前,还要经过 schema 解析、值级校验、PreToolUse hooks、权限决策、tool.call()、PostToolUse hooks
如果只解决第一层,就会得到一个"抽象很统一,但真正执行时四处分叉"的系统;如果只解决第二层,又会得到"执行链统一,但每种工具接入方式都不一样"的系统
- 工具形态差异大 :既有
- 方案------"统一 Tool 契约 + 统一执行内核"两层配合 :
- 第一层:
Tool.ts统一抽象 (📍Tool.ts:362-792)Tool接口把各种工具抽象成统一对象:执行时看call()、权限看checkPermissions()、模型侧协议看inputSchema、UI 展示看renderToolUseMessage()/renderToolResultMessage()ToolDef允许具体工具省略常用默认项;buildTool()统一补齐isEnabled()、isConcurrencySafe()、isReadOnly()、checkPermissions()等缺省行为Tools被定义成readonly Tool[](L697-701),主循环不关心工具的具体类型,只关心"当前会话可用的工具列表"findToolByName()(L348-359)把"按名称查找工具"的逻辑集中起来,支持 alias 兼容
- 第二层:
toolExecution.ts统一执行链 (📍toolExecution.ts:337-1745)runToolUse()先做findToolByName()和输入解析,建立统一入口checkPermissionsAndCallTool()把validateInput()、runPreToolUseHooks()、checkPermissions()/canUseTool()、tool.call()、runPostToolUseHooks()串成一条固定执行链toolHooks.ts专门承担 Hook 桥接,resolveHookPermissionDecision()负责把 hook 返回和权限系统对齐- 这样工具实现本身只需要关心自己的
call()/validateInput()/checkPermissions(),不必重复拼装整条执行流水线
- 第一层:
- 一句话提炼 :
Tool.ts解决的是"不同工具怎么说同一种语言 ",toolExecution.ts解决的是"同一种语言怎么走同一条执行流水线"
难点 2:为什么"这一轮能看到哪些工具"不是固定不变的?
- 问题 :最容易困惑的一点是:工具列表看起来像个静态数组,但实际上它是每一轮都可能变化的"可见工具快照" 。例如:
- 程序启动时,某个 MCP server 还没连上,所以这一轮模型看不到它的工具
- 过了一会儿 MCP 连上了,下一轮模型突然又能看到多了几个工具
- 用户切到 simple mode / coordinator mode,或者 deny rule 生效后,某些工具又会消失
所以真正的问题不是"系统里总共有多少工具",而是"当前这一轮,允许模型看到哪一批工具"
- 方案------把"工具全集"和"本轮可见列表"拆开理解 :
- 第一层:源码里的工具全集 (📍
tools.ts:193-251)getAllBaseTools()负责回答:代码里一共注册了哪些 built-in tools?- 这是"候选全集",还不是最终给模型看的列表
- 第二层:当前模式下可见的 built-in tools (📍
tools.ts:271-327)getTools()在候选全集上继续做过滤:simple mode、deny rules、REPL primitive hiding、isEnabled()- 这一步回答:当前模式下,内置工具里哪些还允许暴露?
- 第三层:把当前已连接的 MCP tools 合进来 (📍
tools.ts:345-367)assembleToolPool()把"当前可见的 built-in tools"和"当前已经连上的 MCP tools"合并、去重、排序- 这一步回答:当前运行时,完整工具池长什么样?
- 第四层:每一轮重新取最新快照 (📍
useMergedTools.ts:20-44,REPL.tsx:2404-2436,query.ts:1660-1671)REPL.tsx里的computeTools()每次都从最新的store.getState()重算工具池- 它还挂到了
refreshTools()上,所以每轮结束时query()都可以刷新一次 - 结果就是:"中途才连上的 MCP 工具"不会 retroactively 改掉上一轮,但会从下一轮开始对模型可见
- 第一层:源码里的工具全集 (📍
- 一句话记忆 :
getAllBaseTools()是"系统里可能有哪些工具",refreshTools()后的options.tools才是"这一轮真正给模型看的工具列表"
难点 3:模型一次可能返回多个 tool_use,如何兼顾并发性能和执行顺序?
- 问题:有些工具天然可并发(如 Read/Grep/Glob),有些工具必须独占(如 Edit、部分 Bash 命令)。如果全部串行,多个读操作会白白排队;如果全部并行,又可能把写操作、状态修改和顺序依赖打乱
- 方案------按
isConcurrencySafe()分层编排 :- 非流式路径 (📍
toolOrchestration.ts:19-82):partitionToolCalls()先按isConcurrencySafe()把连续的工具调用拆成批次- 并发安全批次走
runToolsConcurrently() - 非并发工具逐个走
runToolsSerially() - 每个批次的真正执行仍然统一委托给
runToolUse()
- 流式路径 (📍
StreamingToolExecutor.ts:40-519):- assistant 流式返回时,只要识别出完整
tool_useblock 就立刻addTool() - 执行器内部维护
queued / executing / completed / yielded状态,边执行边缓存结果 getCompletedResults()让 UI 在模型还没输出完时就能先看到已完成工具的结果;getRemainingResults()则在回合收尾时补齐剩余结果
- assistant 流式返回时,只要识别出完整
- 这两条路径共享同一个单工具执行内核,因此"并发模型不同,但执行语义一致"
- 怎么判断某个工具是不是
isConcurrencySafe?- 定义位置 :
Tool接口强制要求实现isConcurrencySafe(input)(📍Tool.ts:402) - 默认值 :如果具体工具没显式实现,
buildTool()的默认值是false(📍Tool.ts:759),也就是默认按"不安全"处理 - 实际判定时机 :
toolOrchestration.ts:96-105会先inputSchema.safeParse(toolUse.input),解析成功后再调用tool.isConcurrencySafe(parsedInput.data)StreamingToolExecutor.ts:104-113也是同样逻辑- 如果 schema 解析失败,或者
isConcurrencySafe()自己抛错,框架会保守地当成false
- 常见判定模式 :
- 纯读/纯查工具 通常返回
true,如FileReadTool(📍FileReadTool.ts:373-375)、GlobTool(📍GlobTool.ts:76-78)、GrepTool(📍GrepTool.ts:183-185)、WebFetchTool(📍WebFetchTool.ts:95-97) - Shell 类工具 不按名字硬编码,而是按输入判断:
BashTool(📍BashTool.tsx:434-436)和PowerShellTool(📍PowerShellTool.tsx:285-287)都写成"只有当这个命令被判定为 read-only 时,才视为 concurrency-safe" - MCP tools 则看服务端注解:MCP client 构造工具对象时,直接用
tool.annotations?.readOnlyHint ?? false作为isConcurrencySafe()(📍services/mcp/client.ts:1795-1797)
- 纯读/纯查工具 通常返回
- 工程上的经验法则 :如果一个工具会写文件、修改状态、启动需要独占的外部进程、或结果依赖严格顺序,就应返回
false;只有"并行运行不会互相污染状态"的工具,才适合返回true
- 定义位置 :
- 非流式路径 (📍
难点 4:工具结果既要立刻显示给用户,又要成为下一轮模型的输入,如何双通道回流?
- 问题 :工具执行完成后,系统同时面临两个需求:
- 当前 UI / SDK 流要尽快看到结果
- 下一轮模型调用时,必须把这次
tool_result带回上下文,否则模型不知道工具执行了什么
如果只解决其一,就会出现"界面能看到结果,但模型失忆"或"模型能继续推理,但 UI 不实时"的问题
- 方案------先封装为
user/tool_result,再一份结果走两条路径 :toolExecution.ts:1403-1474的addToolResult()把工具输出封装成createUserMessage({...})- 这条 user message 里既包含 API 协议需要的
tool_resultblock,也带着内部字段toolUseResult、mcpMeta、sourceToolAssistantUUID
query.ts:1384-1407一边yield update.message把结果实时送回当前 UI/SDK 流,一边用normalizeMessagesForAPI()把它压进toolResults- 在
query.ts:1715-1727,下一轮 state 直接做messages += assistant + toolResults,于是工具结果作为 user message 进入下一轮模型上下文 - 对 SDK 消费方,
QueryEngine.ts:675-787+queryHelpers.ts:203-218又把这条消息归一化成外部事件,形成第三层"对外回放"
难点 5:Tool 能直接读文件、改文件、跑命令,系统怎么保证"能不能调、调到什么程度、在哪些场景下必须收紧"都可控?
-
先抓住 3 个问题:这一块其实就是在回答 3 件事:
- 这个工具能不能出现在本轮工具列表里?
- 就算出现了,这一次调用能不能真的执行?
- 如果当前是 auto mode / background agent 这种高风险场景,规则要不要更保守?
-
代码里的做法可以简化成 4 道关:
-
第 1 关:可见性关------先控制"模型看得到什么" (📍
tools.ts:253-352)filterToolsByDenyRules()会先按 deny rules 过滤工具。- 如果 deny 命中的是某个 MCP server 前缀(如
mcp__server),那这个 server 的整组工具会在展示给模型之前就被移除。 getTools()还会继续叠加 simple mode、REPL primitive hiding、isEnabled()等过滤。assembleToolPool()最后才把 built-in + 当前已连接的 MCP tools 组成本轮快照。- 所以第一层安全控制不是"先暴露,再拒绝",而是"很多工具根本不会出现在这一轮的 schema 里"。
-
第 2 关:执行关------就算模型发出了
tool_use,也不能直接进tool.call()(📍toolExecution.ts:614-1103,toolHooks.ts:332-405)checkPermissionsAndCallTool()的顺序是:inputSchema.safeParse()→validateInput()→runPreToolUseHooks()→ 权限决策 →tool.call()。- 其中权限决策会通过
resolveHookPermissionDecision()把 hook 返回、规则判断、canUseTool()交互审批统一收口。 - 只要中间任何一步返回
deny、ask或stop,本次调用就停在这里,结果以错误型tool_result回给模型,而不会继续真正执行。
-
第 3 关:内容关------同一个工具,输入不同,权限也可以不同 (📍
Tool.ts:123-148,Tool.ts:762-766,utils/permissions/permissions.ts:1113-1155)ToolPermissionContext统一保存当前权限上下文:mode、alwaysAllowRules、alwaysDenyRules、alwaysAskRules、shouldAvoidPermissionPrompts等。- 每个 Tool 还可以实现自己的
checkPermissions()。 - 这意味着系统判断的不是"是不是 BashTool",而是"这次 Bash 的具体命令能不能跑"。
- 例如只读命令和写命令,虽然都走 BashTool,但权限结果可以不同。
-
第 4 关:模式关------进入 auto / 无 UI 场景时,系统会主动收紧 (📍
permissionSetup.ts:510-552,REPL.tsx:3068-3075,Tool.ts:132-135)- 进入 auto mode 时,
stripDangerousPermissionsForAutoMode()会把可能绕过 classifier 的危险 allow 规则剥掉,避免"原来手工模式下可放行的规则"直接带进自动模式。 - 如果当前环境不适合弹权限框(如某些 background agent 场景),
shouldAvoidPermissionPrompts会让系统倾向于保守拒绝,而不是静默放行。 awaitAutomatedChecksBeforeDialog则表示:先等 classifier / hooks 这类自动检查跑完,再决定要不要真的进入交互确认。
- 进入 auto mode 时,
-
-
可以把整条链路记成一句话 :
先决定模型看不看得见工具,再决定这次调用能不能执行,最后再根据当前模式把规则收紧。
八、服务层分析
8.1 职责与边界
封装外部依赖------MCP 连接管理、Plugin 发现与集成、Skill 加载与注册、Agent 定义与调度,以及模型 API / OAuth / 遥测 / LSP / 上下文压缩等平台能力。
边界 :服务层负责接入和管理外部能力,不直接接管 UI,也不直接替代 query() 的轮次编排;一旦能力被投影成 Tool[]、Command[]、AgentDefinition[] 或状态快照,执行语义仍由核心层、工具层和状态层共同决定。
8.2 关键领域模型
服务层的 4 个核心子系统
服务层覆盖面很广(模型 API、OAuth、遥测、LSP、上下文压缩等),但最有架构价值、也最容易让人困惑的是以下 4 个子系统之间的关系。它们不是孤立的,而是形成一条**"Plugin 提供能力 → MCP 暴露接口 → Skill/Agent 对接模型"**的集成链路。
manifest.mcpServers 声明
skillsPath 目录提供
agentsPath 目录提供
连接后注入 tools/commands/resources
注册后合入 commands 列表
frontmatter 可引用/内联 MCP
frontmatter 可预加载 skills
1
1
1
*
*
*
<<能力载体>>
Plugin
+name / manifest / path
+mcpServers: Record<string, McpServerConfig>
+commandsPath / agentsPath / skillsPath
+hooksConfig: HooksSettings
+source: marketplace / builtin / session
+scope: user / project / local / managed
<<外部工具提供者>>
MCPServer
+tools: Tool[]
+resources: ServerResource[]
+name / scope / transport(stdio|sse|ws|http)
+commands(prompts) : : Command[]
+connect() / cleanup()
<<模型可调用的指令>>
Skill
+name / description / whenToUse
+allowedTools / model / hooks
+source: user / project / plugin / mcp / bundled
+getPromptForCommand(args)
<<独立推理实体>>
Agent
+agentType / whenToUse
+tools / skills / mcpServers
+model / permissionMode / maxTurns
+source: built-in / user / project / plugin
+getSystemPrompt()
<<会话级运行态>>
AppState
+mcp.clients: MCPServerConnection[]
+mcp.mcpTools / mcpCommands / mcpResources
+pluginErrors: PluginError[]
Plugin------能力载体
Plugin 代表"一个可安装的扩展包",生命周期为 安装(settings-first)→ 缓存 → 加载 → 能力提取 → 集成。来源:marketplace 安装(plugin@marketplace)、内置插件(@builtin)、--plugin-dir 临时加载。作用域优先级 managed > local > project > user。
一个 Plugin 可以同时提供以下任意组合的能力:
| 能力类型 | Plugin 中的声明 | 系统中的消费方 |
|---|---|---|
| MCP Servers | manifest.mcpServers / .mcp.json / .mcpb |
getPluginMcpServers() → 合入 MCP config |
| Skills | skillsPath 目录下 .md 文件 |
loadPluginCommands() → 合入 commands 列表 |
| Agents | agentsPath 目录下 .md 文件 |
loadPluginAgents() → 合入 agent 定义 |
| Hooks | hooksConfig / hooks.json |
合入 session hooks |
| Output Styles | outputStylesPath 目录 |
合入输出样式 |
| LSP Servers | manifest.lspServers |
合入 LSP 管理器 |
Plugin MCP server 使用 plugin:{pluginName}:{serverName} key 命名空间,天然不与手动配置冲突;dedupPluginMcpServers() 做内容级去重,避免同一底层进程被连接两次。
MCPServer------外部工具提供者
代表"一个 MCP 协议的服务端连接",配置来源 6 种(enterprise / user / project / local / plugin / claude.ai),合并核心函数为 getClaudeCodeMcpConfigs()(📍 mcp/config.ts:1071):
6 种配置来源
enterprise
managed-mcp.json
user
~/.claude.json
project
.claude.json
local
settings.local.json
plugin
getPluginMcpServers()
claude.ai
fetchClaudeAIMcpConfigs()
合并 + 去重 + 策略过滤
优先级:plugin < user < project < local
enterprise 存在时独占
最终可连接的 MCP 服务器列表
关键设计决策:Enterprise 独占 (managed-mcp.json 存在则忽略所有其他来源);Project 需审批 (防止恶意仓库的 .claude.json 自动连接);Plugin-only 锁定(企业策略可屏蔽 user/project/local 来源)。
连接后,MCP server 暴露的三类能力进入系统的路径不同:
| MCP 能力 | 系统中的对应物 | 集成方式 |
|---|---|---|
| tools | Tool[] → AppState.mcp.mcpTools |
fetchToolsForClient() 转换为内部 Tool 对象 |
| resources | ServerResource[] → AppState.mcp.mcpResources |
通过 ReadMcpResourceTool / ListMcpResourcesTool 暴露 |
| prompts | Command[] → AppState.mcp.mcpCommands |
转换为 skill(斜杠命令),可被 SkillTool 调用 |
Skill------模型可调用的指令
代表"一条 /xxx 斜杠命令",统一表示为 Command 类型,来源 5 种:.claude/skills/*.md 文件、bundled 内置(/init, /compact)、plugin 目录、MCP server prompts、企业托管。
文件系统的 Skill 是 Markdown + YAML frontmatter 格式:
yaml
---
description: "描述"
when_to_use: "模型何时自动调用"
allowed-tools: [BashTool, FileReadTool]
model: sonnet # 可覆盖模型
context: fork # fork 在独立上下文中执行
agent: explore # 委托给指定 agent 执行
hooks:
PreToolUse: [...]
---
Skill 的 prompt 内容(Markdown)
两种触发方式:用户手动 (REPL 输入 /skill-name)和模型自动 (通过 SkillTool),执行路径统一经 findCommand() 查找。when_to_use 字段使模型能自动调用,无需用户手动触发。如果 skill 指定了 agent 字段,则委托给 runAgent() 在独立子对话中执行。
Agent------独立推理实体
代表"一个拥有独立 system prompt、tools 子集、permission mode 的子对话"。AgentDefinition 联合类型有 3 种子类型:
| 类型 | 来源 | 特点 |
|---|---|---|
| BuiltInAgentDefinition | 代码内置 | getSystemPrompt() 动态生成(Explore / Plan / GeneralPurpose 等) |
| CustomAgentDefinition | .claude/agents/*.md |
用户/项目/企业定义,frontmatter 配置 |
| PluginAgentDefinition | Plugin agentsPath |
带 plugin 元数据 |
优先级链:built-in → plugin → user → project → flag → managed,getActiveAgentsFromList() 用 Map.set() 实现后者覆盖前者,即 managed(企业)> 所有其他来源。
Agent 可在 frontmatter 中声明 MCP servers(增量 模式,不替换父上下文连接)------string 类型按名引用已有配置共享连接,{ name: config } 类型创建 Agent 专属连接并在结束时 cleanup。
8.3 核心时序
领域模型回答的是"有哪些对象、它们是什么关系";核心时序回答的是"从程序启动到模型真正使用这些能力,运行时按什么顺序协作"。
运行时阶段 --- 模型使用能力
连接阶段 --- useEffect 触发,并发连接
启动阶段 --- 延迟初始化,不阻塞首屏
1. 程序启动
CLI entry → REPL 挂载
首屏渲染完成
2. Plugin 加载
📍 pluginLoader.ts
loadAllPlugins() 从本地缓存读取
不走网络、不阻塞启动
3. MCP 配置合并
📍 mcp/config.ts:1071
getClaudeCodeMcpConfigs()
6 来源 → 去重 → 策略过滤
4. Skill 加载
📍 loadSkillsDir.ts + bundledSkills.ts
文件系统 + bundled + plugin → getCommands()
5. Agent 加载
📍 loadAgentsDir.ts
built-in + plugin + custom → getActiveAgentsFromList()
6. MCP 连接初始化
📍 useManageMCPConnections.ts
useEffect → initializeServersAsPending()
所有 server 先标记为 pending
7. 并发连接 MCP Servers
📍 client.ts connectToServer()
Phase 1: Claude Code configs(快)
Phase 2: claude.ai configs(可能慢)
8. Fetch 能力
fetchToolsForClient() → mcpTools
fetchResourcesForClient() → mcpResources
fetchCommandsForClient() → mcpCommands
9. 批量写入 AppState
📍 useManageMCPConnections.ts:216
16ms 时间窗口批量 flush
tools/commands/resources 一次性合入
10. 工具池组装
📍 tools.ts assembleToolPool()
built-in tools + AppState.mcp.mcpTools
每轮 refreshTools() 取最新快照
11a. Skill 调用
用户 /xxx 或 模型 SkillTool
→ findCommand() 查找
→ prompt 注入 / 委托 Agent
11b. Agent 调用
模型 AgentTool
→ initializeAgentMcpServers()
→ 构建独立 context → query()
12. MCP list_changed
Server 端工具变更通知
→ 自动 re-fetch
→ 下一轮模型可见新工具
为什么 MCP 连接不阻塞启动?
MCP 配置合并和连接都发生在
useEffect中(📍useManageMCPConnections.ts:772/858),在首屏渲染后异步执行。Plugin 加载使用loadAllPluginsCacheOnly()读本地缓存,不走网络。claude.ai 的 MCP 配置和 Claude Code 本地配置分两个 Phase 并行加载。这意味着用户可以立即开始交互,MCP 工具会在后续轮次中逐步可用。
8.4 难点和设计取舍
难点 1:MCP 配置来自 6 个来源,如何合并去重而不产生"两个连接打到同一个 server"的问题?
- 问题 :MCP server 的配置可以来自 enterprise、user、project、local、plugin、claude.ai 共 6 个来源。它们的 key 命名规则完全不同:
- 手动配置用短名(如
slack) - Plugin 用
plugin:{pluginName}:{serverName}命名空间 - claude.ai 用
claude.ai {DisplayName}前缀 - 三者 key 永远不会冲突------但底层可能启动的是同一个进程或同一个 URL
- 如果不去重,就会出现两个连接同时连到同一个 MCP server,浪费资源且产生工具重复
- 手动配置用短名(如
- 方案------"key 不碰撞 + 内容级签名去重"两层配合 :
- 第一层:key 命名空间隔离 (📍
config.ts:219)- Plugin servers 统一加
plugin:前缀,claude.ai 加claude.ai前缀,手动配置保持原名 Object.assign()合并时按优先级plugin < user < project < local覆盖,key 相同则高优先级胜出
- Plugin servers 统一加
- 第二层:
getMcpServerSignature()内容级去重 (📍config.ts:202)- 对每个 server 配置计算签名:stdio server 用
stdio:${JSON.stringify([command, ...args])},远端 server 用url:${unwrapCcrProxyUrl(url)} dedupPluginMcpServers()对比 plugin server 和手动 server 的签名:如果相同,抑制 plugin 侧dedupClaudeAiMcpServers()对比 claude.ai connector 和手动 server 的签名:如果相同,抑制 claude.ai 侧- 特别处理:CCR proxy URL 会用
unwrapCcrProxyUrl()还原为原始 vendor URL 再比对,避免代理改写后签名不匹配
- 对每个 server 配置计算签名:stdio server 用
- 第三层:只有 enabled 的 server 才是去重目标
- 如果用户手动 disable 了一个 server,它不应阻止同签名的 plugin server 连接------否则两边都不跑
- 被抑制的 server 会生成
mcp-server-suppressed-duplicate错误,在/pluginUI 中可见(但不进 error log)
- 第一层:key 命名空间隔离 (📍
- 一句话提炼 :key 隔离保证不误覆盖,签名去重保证不双连接,disabled 例外保证不两头空
难点 2:MCP 连接是异步的,工具列表在会话中间会变化,如何既不阻塞启动又保证一致性?
-
问题 :MCP server 的连接、OAuth 授权、工具 fetch 都是异步操作,耗时从毫秒到数十秒不等。系统面临两个矛盾需求:
- 用户不希望等所有 MCP 连上才能开始交互(启动速度)
- 模型需要看到稳定的工具列表,不能一轮看到 5 个工具、下一轮突然变成 15 个(一致性)
- 如果阻塞启动等全部连好,用户体验差;如果不等,模型看到的工具列表就是"快照",可能过时
-
方案------"先 pending 后渐进 + 每轮快照 + 批量 flush" :
- 先 pending 后渐进 (📍
useManageMCPConnections.ts:772-848)initializeServersAsPending()先把所有配好的 server 标记为pending,让 UI 立即知道"有这些 server"- 然后异步并发连接,每个 server 连上后独立 callback
- 16ms 批量 flush (📍
useManageMCPConnections.ts:207-291)- 每次
onConnectionAttempt回调不直接setAppState,而是 push 到pendingUpdatesRef队列 - 16ms(一帧)定时器统一 flush:一次
setAppState合入所有 pending 的 client/tools/commands/resources - 避免 10 个 server 连接导致 10 次 React re-render
- 每次
- 每轮快照 (📍 第七章
7.4 难点和设计取舍中已详细解释)refreshTools()在每轮结束时从AppState取最新工具池- "这一轮看到 5 个工具"不会 retroactively 改变,但下一轮就能看到新连上的 MCP 工具
- list_changed 通知驱动 (📍
useManageMCPConnections.ts:730-762)- MCP 协议的
notifications/tools/list_changed事件触发自动 re-fetch - 服务端热更新工具后,不需要重启 Claude Code,下一轮自动可见
- MCP 协议的
- 先 pending 后渐进 (📍
-
一句话提炼 :不阻塞启动但保证每一轮工具列表是一致快照,新工具下一轮自动可见
难点 3:Plugin 同时提供 MCP server / Skill / Agent / Hook,如何避免循环依赖和加载顺序问题?
- 问题 :Plugin 是"能力容器",一个 Plugin 可以同时贡献 MCP servers、skills、agents、hooks。这导致:
- MCP config 合并需要读 plugin 的
mcpServers→ 依赖 plugin 加载 - Skill 加载需要读 plugin 的
skillsPath→ 依赖 plugin 加载 - Agent 加载需要读 plugin 的
agentsPath→ 依赖 plugin 加载 - MCP skill 又需要从 MCP server 的 prompts 中解析 → 依赖 MCP 连接
- 这些依赖如果串行,启动会很慢;如果并行,又要处理好"谁先谁后"
- MCP config 合并需要读 plugin 的
- 方案------"Cache-only 加载 + 并行无依赖分支 + 延迟注入" :
- Plugin 只读缓存 (📍
pluginLoader.ts)loadAllPluginsCacheOnly()从本地文件系统缓存读取 plugin 信息,不触发网络请求- MCP config 合并、skill 加载、agent 加载都可以在 plugin 缓存就绪后并行启动
- MCP Skill 的循环依赖打破 (📍
mcpSkillBuilders.ts)- 问题链:
client.ts→mcpSkills→loadSkillsDir.ts→commands.ts→client.ts - 解决:
mcpSkillBuilders.ts作为无依赖的"写一次"注册表,loadSkillsDir.ts在模块初始化时注册 builder,client.ts运行时取用 - 注册时机:
loadSkillsDir.ts通过commands.ts的静态 import 在启动时 eager evaluate,远早于任何 MCP server 连接
- 问题链:
- Agent 加载并行 (📍
loadAgentsDir.ts:347-354)loadPluginAgents()和initializeAgentMemorySnapshots()通过Promise.all()并行执行- 两者无数据依赖,独立完成
- MCP Skill 延迟注入
- MCP prompts 转 skill 发生在
fetchCommandsForClient()连接成功之后,结果写入AppState.mcp.mcpCommands SkillTool.getAllCommands()在运行时把getCommands()和AppState.mcp.mcpCommands合并- 即使 MCP skill 后于本地 skill 就绪,也不影响系统启动
- MCP prompts 转 skill 发生在
- Plugin 只读缓存 (📍
- 一句话提炼 :Plugin 缓存消除网络依赖,注册表打破编译期循环,延迟注入消除运行时顺序约束
难点 4:Agent 是独立推理循环,但需要与父上下文共享 MCP 连接、工具池、权限规则,如何既隔离又共享?
-
问题 :Agent 通过
runAgent()创建独立子对话,有自己的 system prompt、messages、tools。但它不是一个完全独立的进程:- Agent 可以引用父上下文已有的 MCP server(共享连接,避免重复连接)
- Agent 也可以声明自己的 MCP server(独立连接,Agent 结束时清理)
- Agent 的权限模式可以与父不同(如 Agent 是
plan模式,父是default模式) - Agent 注册的 session hooks 必须在 Agent 结束时清除,否则污染父上下文
- 异步 Agent 不能弹权限框、不能阻塞父对话、但可能需要写 AppState
- 这些"部分隔离、部分共享"的需求如果处理不好,轻则资源泄露,重则权限越界
-
方案------"
createSubagentContext()精确控制每个维度的隔离/共享" :- MCP 连接:引用共享 + 内联隔离 (📍
runAgent.ts:95-218)initializeAgentMcpServers()区分两种 spec:string类型按名查找已有配置,共享 memoized 连接;{ name: config }类型创建新连接newlyCreatedClients单独跟踪,finally中只 cleanup 这些,引用连接不动- Policy-only 场景:如果 MCP 被锁定为 plugin-only,仅 admin-trusted 来源的 Agent 可以使用 frontmatter MCP
- 工具池:Agent 独立组装 (📍
runAgent.ts:500-664)resolveAgentTools()根据 Agent 的tools/disallowedToolsfrontmatter 过滤工具池- Agent MCP 工具通过
uniqBy([...resolvedTools, ...agentMcpTools], 'name')合入,同名去重 useExactTools模式(fork 子 Agent)直接继承父工具池,不过滤------为了 prompt cache 命中
- 权限:层级覆盖 + 安全兜底 (📍
runAgent.ts:412-478)- Agent 可定义
permissionMode,但bypassPermissions/acceptEdits/auto父模式不可被降级 - 异步 Agent 设置
shouldAvoidPermissionPrompts = true------不能弹框就保守拒绝 - 异步但可展示的 Agent(bubble mode)设置
awaitAutomatedChecksBeforeDialog = true------先等自动检查 allowedTools提供时替换所有 session-level allow rules,防止父权限泄露
- Agent 可定义
- Hooks:Agent 级注册 + 清理 (📍
runAgent.ts:557-575/816-821)registerFrontmatterHooks()以agentId为 key 注册 session hooks- Agent 的 Stop hooks 被转换为
SubagentStop(因为子 Agent 触发的是 SubagentStop 事件) finally中clearSessionHooks(rootSetAppState, agentId)确保不残留
- 资源清理:
finally块的 9 项清理 (📍runAgent.ts:816-858)- MCP 连接、session hooks、prompt cache tracking、file state cache、messages 数组、Perfetto trace、transcript subdir、orphan todos、background bash tasks------逐一释放
- 设计思路是"Agent 可以拿到父上下文的引用,但不能在结束后留下任何痕迹"
- MCP 连接:引用共享 + 内联隔离 (📍
-
一句话提炼 :共享的(MCP 引用、AppState 读)不新建,独立的(MCP 内联、hooks、messages)不残留
难点 5:同名 Skill / Agent 可以来自多个来源,如何决定"谁赢"且保持可预测?
- 问题 :用户可以在
~/.claude/skills/放一个my-skill.md,同时某个 Plugin 也提供了同名 skill,MCP server 也可能暴露同名 prompt。Agent 同理------built-in 的Explore可以被 project 里的同名 Agent 覆盖。如果覆盖规则不清晰、不可预测,用户会困惑"为什么我的 skill 不生效" - 方案------"固定优先级链 + 同名覆盖 + 命名空间隔离" :
- Agent 优先级链 (📍
loadAgentsDir.ts:193-221)getActiveAgentsFromList()按固定顺序遍历:built-in → plugin → user → project → flag → managed- 用
Map.set(agentType, agent)实现后者覆盖前者 - 即
managed(企业)>project>user>plugin>built-in
- Skill 命名空间
- Plugin skill 用
{pluginName}:{skillName}命名空间,不与用户 skill 冲突 - MCP skill 用
mcp__{serverName}__{promptName}格式 SkillTool.getAllCommands()用uniqBy([...localCommands, ...mcpSkills], 'name')合并,local 优先
- Plugin skill 用
- Agent 内 Skill 引用解析 (📍
runAgent.ts:945-973)- Agent frontmatter 写
skills: [my-skill]时,resolveSkillName()按三步查找:- 精确匹配(name、userFacingName、aliases)
- 加 plugin 前缀匹配(
pluginName:my-skill) - 后缀匹配(任何以
:my-skill结尾的命令)
- 这保证了 plugin Agent 引用自己的 plugin skill 时不需要写全限定名
- Agent frontmatter 写
- Agent 优先级链 (📍
- 一句话提炼 :Agent 按固定链覆盖,Skill 按命名空间隔离,跨 plugin 引用自动解析
九、基础设施层分析
9.1 职责与边界
基础设施层承载跨层复用的底层能力:配置与 settings 合并、权限与策略、hooks、Git / Shell、CLAUDE.md / memory 发现、常量、schema、类型定义、本地原生绑定等。
边界:本层提供能力但不拥有业务编排主权;它不直接决定一次 query 如何推进,而是为上层提供稳定、可组合、可验证的低层原语。
9.2 关键领域模型
可以把基础设施层看成 6 组通用原语:
utils/settings/*:多来源配置合并、校验、变更检测、策略落地utils/permissions/*+types/permissions.ts:权限模式、allow/deny 规则、审批决策utils/hooks/*+types/hooks.ts:Hook 事件、执行桥接与结果解释utils/git.ts/utils/Shell.ts/utils/fileRead.ts:与本地仓库和进程交互的底层封装utils/claudemd.ts/memdir/*:CLAUDE.md、rules、memory 文件体系constants/*/schemas/*/types/*/native-ts/*:协议、常量、校验和原生能力边界
9.3 核心时序
进程启动 / 会话 setup
加载 settings / constants / schemas
按信任边界应用安全环境变量
读取 Git / CLAUDE.md / memory / permissions 基础信息
query / tool execution / hooks 运行期复用底层原语
日志、遥测、状态持久化、缓存失效
基础设施层的时序不是一条独立业务主线,而是在启动、上下文构建、工具执行、状态同步、恢复重建这些关键节点被反复调用。
9.4 难点和设计取舍
难点 1:启动时必须尽早读取配置和初始化网络,但项目目录本身在信任建立前又是不可信的,如何同时满足性能和安全?
- 问题 :很多底层能力都希望越早初始化越好:配置系统、网络、代理、OAuth、遥测预热都会影响启动体验;但项目目录里的
.claude/settings.json、环境变量和 include 文件又可能来自不可信仓库。如果在信任建立前就完整应用这些配置,系统就会把"启动快"变成"启动时就可能被恶意工作区劫持"。 - 解决方案------把环境与配置应用拆成"信任前安全子集"和"信任后完整集"两阶段 :
- 信任前只应用安全白名单内的环境变量和可信来源配置,保证 CLI 能完成必要初始化
showSetupScreens()把工作区信任确认放在真正进入高风险能力之前- 信任建立后再应用完整项目配置,并初始化遥测、MCP 审批、外部 include 检查等高风险路径
- 这样底层初始化既没有完全后移,也没有牺牲信任边界
难点 2:权限、hooks、Shell/Git/文件系统这些能力横跨所有上层模块,如何避免每个功能各写一套防护和副作用约束?
- 问题 :如果命令、工具、服务、UI 都各自实现一遍权限判断、hook 调用、shell 封装、路径处理和 settings 校验,就会很快出现:
- 不同路径对同一个权限规则理解不一致
- hook 执行时机分散,难以形成统一语义
- 文件系统 / shell / git 副作用缺少统一约束
- 上层模块把本该复用的底层逻辑重新拼了一遍
- 解决方案------把这些横切能力收敛成基础设施原语,让上层只"调用结论"而不是"重复实现" :
utils/settings/*负责多源配置合并、校验、策略与变更检测utils/permissions/*统一权限模式、allow/deny 规则和审批决策utils/hooks/*统一 hook 事件桥接与执行结果解释utils/Shell.ts、utils/git.ts、utils/claudemd.ts这类封装负责把本地副作用控制在稳定边界内- 上层因此可以把精力放在编排和产品逻辑,而不是重新实现底层护栏
十、专题:Command 体系
10.1 职责与边界
统一注册多来源斜杠命令 → 按可用性过滤组装当前命令集 → 按类型分流执行(prompt/local/local-jsx)→ 结果决定是否继续进入模型。
边界 :Command 体系负责 slash command 的统一注册、过滤和分发,但不替代工具协议,也不直接决定模型主循环何时结束;真正的模型调用仍回到 query() 主线。
10.2 关键领域模型
什么是 Command?
Command不是单一结构,而是一个三选一联合类型 (📍types/command.ts:175-206):PromptCommand | LocalCommand | LocalJSXCommand。三者共享name、description、aliases、isEnabled()、userInvocable等基础字段,但执行方式完全不同。为什么 Command 的主入口是
commands.ts,而类型定义却在types/command.ts?因为两者承担的是不同角色:
types/command.ts负责声明"命令是什么";commands.ts负责回答"当前有哪些命令、它们从哪来、哪些现在可见、怎样按名字找到它们"。前者是类型层,后者是注册表 + 加载器 + 查找器。
Command 也有一套类似 Tool 的统一抽象,只是抽象对象不同
- Tool 抽象的是"模型可调用的能力单元" ;Command 抽象的是"slash 命令单元"
- 在
types/command.ts里,Command 主要抽象了 5 个点:- 通用元信息 :
CommandBase统一了name、description、aliases、availability、isEnabled()、userInvocable、loadedFrom - 执行形态 :用
type把命令分成prompt/local/local-jsx - 执行入口 :
prompt走getPromptForCommand(args, context)local/local-jsx走load().call(...)
- 执行上下文 :统一复用
ToolUseContext,而local-jsx再扩展成LocalJSXCommandContext - 结果契约 :
LocalCommandResult、onDone()、以及ProcessUserInputBaseResult.shouldQuery/nextInput等字段,统一决定"命令执行完之后,继续进模型还是停在本地"
- 通用元信息 :
register/load/filter
expose current command set
delegate
execute
CommandBase
+name
+description
+aliases
+userInvocable
+loadedFrom
+availability
+isEnabled()
PromptCommand
+type = prompt
+allowedTools
+model
+hooks
+context = inline|fork
+getPromptForCommand(args, context)
LocalCommand
+type = local
+supportsNonInteractive
+load()
LocalJSXCommand
+type = local-jsx
+immediate
+load()
<<union>>
Command
commands_ts
+getCommands(cwd)
+findCommand(name, commands)
+getCommand(name, commands)
+isBridgeSafeCommand(cmd)
REPL
+useMergedCommands()
processUserInput
+detect slash input
processSlashCommand
+dispatch by command.type
从职责视角看,Command 大致可以分成 4 组
- 1. 会话控制命令 :控制当前 REPL 会话状态或界面模式
- 例子:
/clear、/theme、/vim、/statusline、/exit
- 例子:
- 2. 上下文整理命令 :直接作用于会话历史、上下文或展示信息
- 例子:
/compact、/rewind、/memory、/cost、/usage
- 例子:
- 3. 扩展接入命令 :接入外部能力、插件、MCP、workflow、IDE 或远程能力
- 例子:
/mcp、/plugin、/skills、/ide、/remote-setup
- 例子:
- 4. 协作与任务命令 :把命令提升为任务、agent、review 或工作流编排入口
- 例子:
/agents、/tasks、/review、/plan、/session
- 例子:
从执行语义看,命令又可以分成 3 类
prompt命令 :把命令展开成一段 prompt / skill content,再继续进入主循环- 例子:skills、plugin skills、bundled skills、部分
/plan风格命令
- 例子:skills、plugin skills、bundled skills、部分
local命令 :在本地直接执行,返回文本 / compact / skip- 例子:
/cost、/clear、/files
- 例子:
local-jsx命令 :本地执行,但结果不是简单文本,而是一个 Ink 组件- 例子:
/config、/permissions、/agents
- 例子:
10.3 核心时序
Command 的领域模型回答"命令有哪些类型、从哪来";核心时序回答"当用户真的输入 /xxx 时,注册表、REPL、输入处理器、命令执行器怎样串起来"。
结果回流层
命令分发层
输入分流层
REPL 接入层
注册与加载层
否,但像 /foo
否,但更像普通文本/文件路径
是
true
false
1. types/command.ts 定义命令联合类型
📍 types/command.ts:16-206
PromptCommand / LocalCommand / LocalJSXCommand
2. commands.ts 组装命令总表
📍 commands.ts:258-346
COMMANDS() 聚合 built-in commands
feature/env 条件命令在这里并入
3. loadAllCommands(cwd)
📍 commands.ts:449-469
并行加载 skillDirCommands / pluginCommands / workflowCommands
再与 built-in COMMANDS() 合并
4. getCommands(cwd)
📍 commands.ts:476-517
meetsAvailabilityRequirement() + isCommandEnabled()
再插入 dynamic skills
5. main.tsx 预启动 getCommands()
📍 main.tsx:1919-2029
setup() 并行 kick commandsPromise
6. REPL 合并本地/插件/MCP 命令
📍 REPL.tsx:681-835
localCommands + plugins.commands + mcp.commands
useMergedCommands() 去重后得到当前命令集
7. processUserInput()
📍 processUserInput.ts:85-176
统一处理文本、bash、slash command、图片、attachments
8. processUserInputBase()
📍 processUserInput.ts:281-520
若 input 以 / 开头且未 skipSlashCommands
则转入 processSlashCommand()
9. processSlashCommand()
📍 processSlashCommand.tsx:309-524
parseSlashCommand() 解析命令名 + args
hasCommand()/findCommand() 校验是否存在
10. 命令存在吗?
📍 processSlashCommand.tsx:332-380
11. 按 command.type 分流
📍 processSlashCommand.tsx:525-760
12a. prompt 命令
📍 processSlashCommand.tsx:827-920
getPromptForCommand() 生成 skill content
注册 skill hooks / attachments / command_permissions
返回 shouldQuery = true
12b. local 命令
📍 processSlashCommand.tsx:657-721
load().call(args, context)
返回 text / compact / skip
shouldQuery = false
12c. local-jsx 命令
📍 processSlashCommand.tsx:551-655
load().call(onDone, context, args)
通过 setToolJSX() 挂载 Ink UI
通常 shouldQuery = false
Unknown skill / Unknown command
返回错误消息,停止 query
回退为普通 prompt
shouldQuery = true
13. processUserInput 返回 ProcessUserInputBaseResult
📍 processUserInput.ts:64-83
messages / shouldQuery / allowedTools / model / resultText
14. shouldQuery?
📍 REPL.tsx:2661-2730
📍 QueryEngine.ts:410-556
15a. 继续进入 query()
prompt 命令把 metadata + skill content + attachments
拼成 user/meta messages,继续主循环
15b. 直接把命令输出回到会话
local/local-jsx 命令通常停止在本地
stdout/stderr 或 UI 结果直接展示
10.4 难点和设计取舍
难点 1:命令来源很多、执行形态也很多,如何统一抽象,而不是把命令体系写散?
- 问题 :Command 不只是
src/commands/*下的 built-in 命令;还包括:- skills 目录命令
- plugin commands / plugin skills
- bundled skills
- workflow commands
- MCP commands
- 动态技能(dynamic skills)
同时,它们又不是一种执行模式,而是prompt/local/local-jsx三种形态。如果每种来源、每种形态都自己定义注册方式和执行接口,REPL、主循环、SkillTool 就都得分别适配
- 方案------先抽象
Command联合类型,再把"注册"和"分发"收口 :- 类型层统一抽象 (📍
types/command.ts:16-206):CommandBase统一公共元信息PromptCommand抽象"展开成 prompt 后继续 query"LocalCommand抽象"本地执行并返回 text / compact / skip"LocalJSXCommand抽象"本地执行并挂载 JSX UI"
- 注册层统一收口 (📍
commands.ts:258-517):COMMANDS()保存 built-in 命令全集loadAllCommands()汇总 skills / plugins / workflows / bundled skillsgetCommands()负责产出"当前 cwd 下真正可用的命令列表"
- 执行层统一分发 (📍
processSlashCommand.tsx:525-760):- 不管命令来自哪里,最终都按
command.type统一走prompt/local/local-jsx三条分支
- 不管命令来自哪里,最终都按
- 所以原来"命令来源很多"和"同样是
/command,为什么执行方式不同"其实是同一个问题:Command 体系如何先做统一抽象,再做统一分发
- 类型层统一抽象 (📍
难点 2:命令明明主要是给用户的,模型为什么也能"调用 command"?边界怎么划?
- 问题 :大多数人直觉上会把 command 理解成"用户输入
/xxx触发的 REPL 命令"。但代码里又确实存在一部分 command,会被 SkillTool 暴露给模型。如果不解释清楚,很容易误以为"模型能像调用 Tool 一样直接调用任意 command" - 方案------模型不会看到全部 command,只会看到一小部分可模型调用的 prompt commands :
getSkillToolCommands()(📍commands.ts:563-580)只筛选:cmd.type === 'prompt'!cmd.disableModelInvocationcmd.source !== 'builtin'- 且满足 skills/plugin/bundled 等来源条件
getMcpSkillCommands()(📍commands.ts:547-559)再补上可模型调用的 MCP prompt commandsSkillTool.ts最终不是直接"运行一个任意 command 对象",而是调用processPromptSlashCommand()(📍tools/SkillTool/SkillTool.ts:635-641,processSlashCommand.tsx:817-825),也就是复用 prompt command 的那条执行链- 所以更准确地说:模型不能像 Tool 那样直接调用整个 Command 系统;它只能通过 SkillTool 间接调用"被挑出来的 prompt command 子集"
- 这几个例子最能说明边界 :
- 用户可调、模型不可调 :
batch(📍skills/bundled/batch.ts:101-110)userInvocable: true,但disableModelInvocation: truedebug(📍skills/bundled/debug.ts:13-24)也是同样模式skillify(📍skills/bundled/skillify.ts:163-177)同样要求用户显式触发
- 用户不可调、模型可调 :
keybindings-help(📍skills/bundled/keybindings.ts:293-299)userInvocable: false,更像"模型专用 skill"processSlashCommand.tsx:811-815对这类命令的 loading metadata 也专门用 "The X skill is running" 格式,而不是/name
- 用户可调、模型也可调 :
verify(📍skills/bundled/verify.ts:17-29)remember(📍skills/bundled/remember.ts:64-80)simplify(📍skills/bundled/simplify.ts:56-68)
这类都是 prompt skill,没有disableModelInvocation: true
- 只给用户、本地执行,不会暴露给模型 :
/config(📍commands/config/index.ts:3-9)是local-jsx/theme(📍commands/theme/index.ts:3-8)是local-jsx/session(📍commands/session/index.ts:4-14)也是local-jsx
它们本地挂 UI,不会进入 SkillTool 的可调用集合
- 用户可调、模型不可调 :
难点 3:remote / bridge / headless 模式下,哪些命令还能安全工作?
- 问题:很多命令依赖本地终端、Ink UI、文件系统或 IDE 状态。它们在 mobile / remote control / headless 场景并不一定成立。如果不做模式裁剪,远程客户端可能一上来就触发本地弹窗或终端专属 UI
- 方案------在命令层明确声明安全边界 :
REMOTE_SAFE_COMMANDS(📍commands.ts:619-637)定义 remote mode 下可保留的命令BRIDGE_SAFE_COMMANDS(📍commands.ts:651-676)定义哪些 slash command 可以从 mobile/web bridge 安全执行processUserInputBase()(📍processUserInput.ts:422-453)在 bridge 输入路径先做isBridgeSafeCommand()判定,不安全则直接返回本地错误提示,不把原始/config之类继续传给模型
十一、专题:Context 体系
11.1 职责与边界
采集多来源上下文(记忆文件、git 状态、日期等)→ 统一整理为两份 map → 分别注入 system prompt 和消息流 → 长对话时分级压缩治理体积。
边界:Context 体系负责"给模型补什么输入"和"长对话如何维持可用上下文",但不负责 query 轮次控制本身,也不承担会话级共享运行态管理。
11.2 关键领域模型
什么是 Context?
这里的 Context 不是一个单独的 class,也不是一条消息对象,而是**"在正式消息流之外,补充进当前对话的上下文数据"**。在当前实现里,它最终收敛成两份 memoized 的 key-value map:
userContext: { [k: string]: string }systemContext: { [k: string]: string }
这也是 context.ts 的核心抽象:把来源复杂的上下文(memory、git、日期、cache breaker)先统一整理,再由 query 层选择正确的注入位置。
read memory files
cache CLAUDE.md / add-dir
fetch contexts
inject contexts
prefetch
assemble prompt parts
context.ts
+getGitStatus()
+getUserContext()
+getSystemContext()
+getSystemPromptInjection()
+setSystemPromptInjection()
claudemd.ts
+getMemoryFiles()
+filterInjectedMemoryFiles()
+getClaudeMds()
+clearMemoryFileCaches()
+resetGetMemoryFilesCache()
bootstrap/state.ts
+getAdditionalDirectoriesForClaudeMd()
+setCachedClaudeMdContent()
queryContext.ts
+fetchSystemPromptParts()
api.ts
+prependUserContext()
+appendSystemContext()
query.ts
+query()
main.tsx
+prefetchContexts()
REPL / QueryEngine / AgentTool
+buildPrompt()
+callQuery()
从职责视角看,Context 大致可以分成 4 组
- 1. 上下文来源采集器
getGitStatus()采 git branch / status / recent commitsgetMemoryFiles()扫描 CLAUDE.md、rules、memory entrypointgetLocalISODate()注入当天日期
- 2. Memory 发现与整理层
utils/claudemd.ts负责文件发现、优先级顺序、@include、rules frontmatter、conditional rules、AutoMem / TeamMem 入口
- 3. 上下文组装与缓存层
getUserContext()/getSystemContext()做最终 key-value map 组装memoize()让同一会话内重复读取命中缓存setCachedClaudeMdContent()把 CLAUDE.md 内容缓存给 auto-mode classifier 等下游
- 4. 上下文注入与消费层
fetchSystemPromptParts()统一获取 prompt partsprependUserContext()把 userContext 前插为 meta user messageappendSystemContext()把 systemContext 追加到 system promptquery.ts、REPL.tsx、QueryEngine.ts、runAgent.ts、compact.ts统一消费
11.3 核心时序
Context 的主线不是"用户显式触发一个命令",而是在会话启动、每轮 query 组装、compact/agent 等复用路径里,被动参与 system prompt 和消息流的构造。
运行时消费层
注入层
Prompt 组装层
启动预取层
上下文构建层
来源层
1. getGitStatus()
📍 context.ts:36-111
读取 branch / default branch / git status / recent commits
2. getMemoryFiles()
📍 utils/claudemd.ts:790-1075
发现 Managed / User / Project / Local / AutoMem / TeamMem
3. getClaudeMds()
📍 utils/claudemd.ts:1153-1195
把 memory files 格式化成可注入文本
4. getLocalISODate()
📍 context.ts:7,186
补当天日期
5. getSystemPromptInjection()
📍 context.ts:23-34,130-147
可选 cache breaker 注入
6. getUserContext()
📍 context.ts:155-189
组合 claudeMd + currentDate
并写入 cachedClaudeMdContent
7. getSystemContext()
📍 context.ts:116-150
组合 gitStatus + cacheBreaker
8. main.tsx 预取 Context
📍 main.tsx:364-405,1977-1983
启动阶段提前 void getSystemContext()/getUserContext()
9. trust / remote / bare 条件
systemContext 预取受 trust / remote 条件影响
userContext 预取更早启动
10. fetchSystemPromptParts()
📍 utils/queryContext.ts:44-74
并行拿 defaultSystemPrompt + userContext + systemContext
11. buildEffectiveSystemPrompt()
📍 utils/systemPrompt.ts:41-123
处理 agent/custom/coordinator/append prompt
12. appendSystemContext()
📍 utils/api.ts:437-447
把 systemContext 追加到 systemPrompt 尾部
13. prependUserContext()
📍 utils/api.ts:449-474
把 userContext 前插成 system-reminder user message
14. query()
📍 query.ts:449-450,659-661
callModel 前拼 fullSystemPrompt + prepended messages
15. REPL / QueryEngine / AgentTool / compact 复用
📍 REPL.tsx:2535,2772,4942
📍 QueryEngine.ts / runAgent.ts / compact.ts
都复用同一套 userContext/systemContext
11.4 难点和设计取舍
难点 1:上下文来源很多,为什么还要分成 userContext 和 systemContext 两路?
- 问题 :Context 并不只是一份字符串。它的来源至少包括:
- git 状态
- CLAUDE.md / CLAUDE.local.md
.claude/rules/*.md- AutoMem / TeamMem
- 当前日期
- system prompt injection
如果每个调用方都自己读 git、自己扫 memory、自己决定往 system prompt 还是 messages 里塞,主循环、agent、compact、debug 路径就会各自拼一套上下文
- 方案------先统一收口,再走两条明确的注入通道 :
- 第一步:收口来源
context.ts不让调用方直接面对 git、CLAUDE.md、日期、cache breaker 这些分散来源,而是先统一收口成两份对象:getUserContext()(📍context.ts:155-189)getSystemContext()(📍context.ts:116-150)
- 第二步:按最终落点注入
prependUserContext()(📍utils/api.ts:449-474)把userContext变成一条<system-reminder>包裹的 meta user message,前插到 messages 最前面appendSystemContext()(📍utils/api.ts:437-447)把systemContext序列化成key: value文本,追加到 system prompt 尾部
- 这两个函数分别在什么场景调用?
appendSystemContext():用于已经拿到一份systemPrompt,准备真正发起模型请求之前 。当前主调用点在query.ts:449-450,所以只要走query()的路径------REPL 主线程、QueryEngine/headless、sub-agent、forked agent------最终都会在这里把systemContext追加进去prependUserContext():用于已经拿到一组 messages,准备让模型看到"用户侧补充上下文"时 。主调用点同样在query.ts:659-660;除此之外,components/agents/generateAgent.ts:139-142这个不走query()的 agent 生成路径,也会单独前插userContext
- 所以这里的"分流"不是说来源被拆散,而是说:同一套上下文来源先统一进入
context.ts,再按最终注入位置分成"消息通道"和"system prompt 通道"两路 - 为什么要这样分?因为当前代码明确把:
claudeMd、currentDate放进userContextgitStatus、cacheBreaker放进systemContext
这样下游只要拿到两份 map,就能稳定复用,不需要自己判断"这段上下文该塞到哪里"
- 第一步:收口来源
难点 2:CLAUDE.md / memory 不是一份文件,而是一整套分层发现机制,如何保证读取顺序和作用域稳定?
- 问题 :当前代码里的 memory 来源不是单一
CLAUDE.md,而是一个分层体系:- Managed memory
- User memory
- Project memory
- Local memory
.claude/rules/*.md@include引入文件- conditional rules(frontmatter
paths) - AutoMem / TeamMem 入口
再加上 worktree、--add-dir、external includes、exclude patterns,读取顺序和去重逻辑都不是一眼能看出来的
- 方案------顺序、去重、作用域都在
claudemd.ts里被显式编码了 :- 顺序是显式固定的 (📍
utils/claudemd.ts:1-16,803-1007):- Managed
- User
- Project
- Local
- 额外目录(
--add-dir,若 env 开启) - AutoMem / TeamMem 入口
- 目录遍历顺序也是固定的 :
- 从当前目录一路向上收集目录(📍
utils/claudemd.ts:849-857) - 再
dirs.reverse()从 root 往 CWD 方向处理(📍878) - 离当前工作目录越近的文件越晚注入,也就拥有更高优先级
- 从当前目录一路向上收集目录(📍
- 去重与稳定性靠
processedPaths+ realpath 处理 :processMemoryFile()用processedPaths避免重复加载(📍618-648)safeResolvePath()+normalizePathForComparison()处理 symlink / 路径大小写差异(📍639-648)- nested worktree 场景下,
skipProject会跳过主仓库里重复的 checked-in memory(📍859-885)
- 作用域靠 frontmatter
paths明确约束 :processConditionedMdRules()只保留 glob 命中的 conditional rules(📍1354-1397)- Project rules 用
.claude所在目录做 baseDir;Managed/User rules 用原始 CWD 做 baseDir(📍1375-1385)
- 最终格式化并不打乱顺序 :
getClaudeMds()(📍1153-1195)只是按getMemoryFiles()产出的稳定顺序,把每个MemoryFileInfo拼成最终注入文本
- 所以这里的"稳定"不是模糊意义上的"尽量不变",而是:加载顺序、路径归一化、去重方式、glob 作用域都在代码里有明确规则
- 顺序是显式固定的 (📍
难点 3:上下文既要尽早预热,又要能在清缓存、切模式、注入变化后失效重建,缓存边界怎么处理?
- 问题 :Context 构建既涉及磁盘 I/O(CLAUDE.md 扫描)、又涉及子进程(git status),如果每轮都重新做会很重;但如果一直缓存,又会遇到:
/clear caches后需要重建- system prompt injection 变化后需要重建
- memory 文件变化、compact 后需要重建
- auto-mode classifier 还需要一份 CLAUDE.md 缓存副本
- 方案------所谓"不同层",指的是 4 个缓存边界,各自缓存的对象不同 :
- 第 1 层:原始采集结果缓存
getGitStatus()是 memoized,缓存 git 快照字符串(📍context.ts:36-111)- 失效条件 :
clearSessionCaches()会清掉它(📍commands/clear/caches.ts:47-55)。这条路径被/clear、--resume、--continue等会话级清缓存流程复用
- 第 2 层:会话级上下文对象缓存
getUserContext()、getSystemContext()也是 memoized,缓存的是两份 key-value map(📍context.ts:116-189)setSystemPromptInjection()一旦改值,会立刻清掉这两层 cache(📍context.ts:29-34)- 失效条件 :
- 会话级清缓存:
clearSessionCaches()(📍commands/clear/caches.ts:52-54) - system prompt injection 变化:
setSystemPromptInjection()(📍context.ts:29-34) getUserContext()还会在 compact 后单独清掉,因为 compact 后需要重建 memory/date 相关上下文(📍commands/compact/compact.ts:63,117,203,services/compact/postCompactCleanup.ts:59-60)
- 会话级清缓存:
- 第 3 层:memory 文件发现缓存
getMemoryFiles()自己单独 memoize,缓存的是MemoryFileInfo[](📍utils/claudemd.ts:790-1075)clearMemoryFileCaches()/resetGetMemoryFilesCache()负责这层的失效(📍1119-1129)- 失效条件 :
/clear、--resume、--continue:resetGetMemoryFilesCache('session_start')(📍commands/clear/caches.ts:80-85)- compact:
resetGetMemoryFilesCache('compact')(📍services/compact/postCompactCleanup.ts:59-60) - worktree 进入/退出、setup 切换 cwd、session restore:
clearMemoryFileCaches()(📍setup.ts:278-280,EnterWorktreeTool.ts:99-101,ExitWorktreeTool.ts:142-144,sessionRestore.ts:361-387) - settings/team memory 写回本地:
clearMemoryFileCaches()(📍services/settingsSync/index.ts:573-575,services/teamMemorySync/index.ts:852-854) /memory面板打开前主动刷新:clearMemoryFileCaches()(📍commands/memory/memory.tsx:84-87)
- 第 4 层:下游只读副本缓存
setCachedClaudeMdContent()把最终生成好的claudeMd文本写进 bootstrap state,供 classifier 等只读消费者复用(📍bootstrap/state.ts:1207-1213)- 失效条件 :它没有单独的
.clear()调用,而是在每次getUserContext()重算时被 覆盖写入 (可能写入新的claudeMd,也可能写入null),所以它的刷新跟随第 2 层一起发生
- 补充:prompt cache 动态边界不是上面 4 层里的一个"缓存对象",而是一条 cache scope 分界线
SYSTEM_PROMPT_DYNAMIC_BOUNDARY(📍constants/prompts.ts:572-575)把 system prompt 拆成"可全局缓存的静态前缀"和"会话相关的动态后缀"utils/api.ts:362-378会按这个边界切分静态/动态 blocks
- 所以这里的"不同层"指的是:4 层真实缓存对象 + 1 条 prompt cache 分界线
- 第 1 层:原始采集结果缓存
难点 4:为什么 Context 体系既出现在主循环里,也出现在 compact、agent、session memory、analyzeContext 里?
- 问题 :Context 不是只给主线程
query()用的。当前代码里,很多"需要重建系统提示词或重算上下文"的路径都要复用同一套结果。如果每个模块自己拼,会很快出现"主循环看到的上下文"和"compact / agent 看到的上下文"不一致 - 方案------把获取与注入拆成共享 helper,供多条链路复用 :
fetchSystemPromptParts()(📍utils/queryContext.ts:44-74)统一返回defaultSystemPrompt + userContext + systemContextquery.ts(📍449-450,659-661)在真正调用模型前做最终注入REPL.tsx、QueryEngine.ts、runAgent.ts、compact.ts、sessionMemory.ts、analyzeContext.ts都复用同样的上下文构建函数- 结果就是:Context 体系不是单个文件的局部逻辑,而是一套跨主循环、压缩、agent、分析工具的共享基础设施
难点 5:对话越长 token 越多,如何避免超限又不丢失关键上下文?
- 问题 :如果只看
context.ts,容易以为 Context 体系只负责"把 git/CLAUDE.md 塞进 prompt"。但从运行时看,真正撑住长任务的还包括 query 里的 分级上下文压缩机制。否则几十轮工具调用、长 Bash 输出、大文件读取结果,很快就会把会话历史塞满,最终触发 413 或上下文退化 - 方案------把长上下文治理做成一条分级压缩管线,而不是一次性摘要 (📍
query.ts:379-468,1094-1120):- 第 1 层 ·
toolResultBudget(📍379-394)- 先控制单条
tool_result的体积 - 适合处理"不是消息太多,而是某一条结果太大"的场景
- 先控制单条
- 第 2 层 ·
snip(📍396-410)- 先做最轻量的局部裁剪
- 直接释放最早的非关键消息,不调 LLM
- 第 3 层 ·
microcompact(📍412-426)- 对旧轮次
tool_result做更细粒度的压缩 - 重点是压内容冗余,但尽量保留消息结构
- 对旧轮次
- 第 4 层 ·
contextCollapse(📍428-447)- 不是直接抹掉历史,而是先投影一个"折叠后的上下文视图"
- 代码注释已经写明它运行在 autocompact 之前,目的是:如果 collapse 后已经降到阈值以下,就不必进入全量摘要,尽量保留细粒度上下文
- 第 5 层 ·
autocompact(📍453-468)- 真正的全局摘要压缩
- 只有前面几层不够时,才进入这一层
- 恢复兜底 ·
collapse drain+reactive compact(📍1094-1120)- 如果真实 API 调用已经报 413 或 media-size-error,再走恢复性压缩
- 这不是日常路径,而是"主动压缩没拦住"时的自救链路
- 第 1 层 ·
- 为什么必须分成这 5 层,而不是直接只做
autocompact?- 只做
autocompact不行:它最重、最贵,而且一旦触发就是整段历史摘要,信息损失最大 toolResultBudget必须单独存在:因为很多超限问题来自"单条结果太大",而不是"历史太长"snip必须早于摘要:它是最轻量的释放手段,纯内存裁剪,不需要调模型microcompact不能被snip替代 :snip是删整条消息;microcompact压的是消息内部冗余,目标是"保留结构,只减内容"contextCollapse不能被autocompact替代:它是中间路线------先折叠视图,尽量保留更细粒度的上下文;代码注释已经明确写了,它就是为了在进入全量摘要前再争取一次"保留细节"的机会autocompact放在最后:当前面几层都不够时,才用全局摘要接管- 所以这 5 层的关系不是"功能重复",而是:从低成本、低损失,逐步升级到高成本、高压缩
- 只做
- 为什么这也属于 Context 体系?
- 因为它处理的不是工具执行逻辑,而是:当前回合到底把哪一份对话历史、以什么粒度、以什么压缩形态继续送进模型
- 换句话说,
context.ts管的是"上下文从哪来、怎么注入";query.ts这条压缩管线管的是"上下文太长时,怎么继续维持可用"
- 一句话提炼 :
- Claude Code 的 Context 管理不是"接近上限时做一次摘要",而是:
- 先局部控体积
- 再轻量裁剪
- 再细粒度压缩
- 再折叠视图
- 最后才全局摘要
- 失败后还有恢复性压缩
- 这也是为什么长对话的上下文管理,应该放在 Context 章节里统一理解,而不是只看 Query 循环的局部实现
- Claude Code 的 Context 管理不是"接近上限时做一次摘要",而是:
十二、专题:状态体系
12.1 职责与边界
定义会话级共享状态模型 → 通用 store 提供读写订阅 → React 适配层接入 UI → 变更后统一收口副作用(权限同步、持久化、缓存清理)。
它和 Context 看起来很像,区别到底是什么?
两者都服务于"当前会话",但关注点不同:
- Context (第十一章)主要回答:这一轮发请求前,还要额外补哪些信息给模型?
- 典型内容:
CLAUDE.md、rules、git status、日期、system prompt injection- 典型动作:收集、缓存、拼装、注入 prompt
- AppStateStore (本章)主要回答:当前会话现在处于什么运行状态?
- 典型内容:权限模式、任务列表、MCP 连接、通知队列、bridge 状态、plan mode 状态
- 典型动作:被 UI、Tool、bridge、权限流持续读写
所以可以把它们记成一句话:
- Context = 喂给模型的输入材料
- AppStateStore = 会话运行时的共享状态
边界 :状态体系负责"会话当前处于什么状态",而不是"这一轮模型应看到什么上下文";前者由 AppState 管理,后者由 Context 体系负责。
12.2 关键领域模型
状态生命周期视角
生命周期 典型内容 主要载体 恢复方式 turn-scoped 当前轮工具执行中间态、流式事件 局部变量 / executor 内部状态 当前轮结束即丢弃 session-scoped AppState、MCP 连接、通知、plan modeAppStateStore会话内持续共享 persisted / resumable transcript、session metadata、部分设置 文件 / metadata / 持久化层 --resume/--continue重建derived / cache Git status、CLAUDE.md、memory file cache memoized helpers / bootstrap cache 清缓存后重算
什么是 AppState?
AppState是当前实现里"整场会话共享状态"的完整结构(📍src/state/AppStateStore.ts:89-452)。它既包含 UI 需要的状态,也包含 Tool 运行时、权限、任务、MCP、bridge、worker 协调、plan mode、speculation 等运行期状态。什么是 AppStateStore?
AppStateStore不是复杂类,而只是Store<AppState>的别名(📍src/state/AppStateStore.ts:454)。真正的 store 能力来自src/state/store.ts:10-33:读当前状态、用函数式 updater 生成新状态、向订阅者广播变化。为什么
AppStateStore.ts和AppState.tsx是分开的?因为两者承担的角色不同:
AppStateStore.ts负责"状态是什么、默认值是什么 ";AppState.tsx负责"在 React 里怎样创建和消费这份状态 "。这样非 React 代码可以直接依赖状态类型和默认态,而不把 React 一起带进来(📍src/state/AppState.tsx:21-27的 re-export 也说明这里仍在迁移中)。为什么
onChangeAppState.ts单独存在?
因为当前实现把 setState() 尽量保持为"纯状态替换",而把权限模式同步、设置持久化、认证缓存清理等副作用统一放到 diff-based 的 onChangeAppState() 里(📍 src/state/onChangeAppState.ts:43-171),让多个调用路径共享同一个收口点。
provide initial shape
create live store
notify on state diff
expose hooks/store
read/write same state
compare AppState fields
AppStateStore.ts
+AppState
+AppStateStore = Store
+CompletionBoundary
+SpeculationState
+FooterItem
+getDefaultAppState()
store.ts
+createStore(initialState, onChange)
+getState()
+setState(updater)
+subscribe(listener)
AppState.tsx
+AppStateProvider
+useAppState(selector)
+useSetAppState()
+useAppStateStore()
onChangeAppState.ts
+externalMetadataToAppState()
+onChangeAppState()
ToolUseContext / tool runtime
+getAppState()
+setAppState()
PromptInput / Permission UI / Bridge hooks
+render-time selectors
+event-time store reads
核心类型与例子
AppState(会话级状态快照)- 代表"当前会话所有共享状态的总和"
- 例子:
toolPermissionContext、tasks、mcp.tools、initialMessage、speculation
AppStateStore(最小 store 抽象)- 代表"谁持有这份状态,以及怎样读 / 写 / 订阅它"
- 例子:
getState()、setState()、subscribe()
ToolPermissionContext(权限与工具模式上下文)- 代表"当前会话对工具调用的权限规则快照"
- 例子:
mode、allow/deny/ask rules、prePlanMode
initialMessage/pendingPlanVerification(plan mode 向主循环注入的状态)- 代表"plan mode 退出后,下一轮 query 应该以什么输入和验证状态继续"
mcp/replContext/workerSandboxPermissions(工具运行态)- 代表"工具池、工具会话、权限请求队列"等不适合放进局部组件状态的运行时数据
从职责视角看,AppState 大致可以分成 6 组
- 1. 基础会话与界面状态 (📍
src/state/AppStateStore.ts:90-109)- 例子:
settings、verbose、mainLoopModel、expandedView、footerSelection
- 例子:
- 2. 远程会话与 bridge 状态 (📍
src/state/AppStateStore.ts:111-157)- 例子:
remoteConnectionStatus、replBridgeConnected、replBridgeSessionUrl
- 例子:
- 3. 任务、MCP、plugin、通知等全局运行态 (📍
src/state/AppStateStore.ts:159-231)- 例子:
tasks、agentNameRegistry、mcp.tools、plugins、notifications
- 例子:
- 4. 具体工具的会话级 UI / Runtime 状态 (📍
src/state/AppStateStore.ts:232-322)- 例子:
tungstenActiveSession、bagelUrl、computerUseMcpState、replContext.registeredTools
- 例子:
- 5. 多 agent / worker 协调状态 (📍
src/state/AppStateStore.ts:323-384)- 例子:
teamContext、inbox.messages、workerSandboxPermissions
- 例子:
- 6. 推测执行、plan mode、权限延续与 ultraplan 状态 (📍
src/state/AppStateStore.ts:385-452)- 例子:
speculation、initialMessage、pendingPlanVerification、denialTracking、isUltraplanMode
- 例子:
12.3 核心时序
Tool 章节的核心时序回答"一个 tool_use 怎样跑完";AppStateStore 的核心时序回答的是:默认态如何生成、运行时谁在读写、状态变化后副作用怎样收口,以及外部元数据怎样再反向写回状态。
副作用层 --- 外部同步与持久化
变更层 --- 状态替换与副作用收口
消费层 --- React 组件与工具运行时如何读写
创建层 --- React 与通用 store 接线
定义层 --- 状态形状与默认值
反向写回层 --- 外部 metadata 回灌 AppState
11. externalMetadataToAppState()
📍 onChangeAppState.ts:24-40
把 permission_mode / is_ultraplan_mode 映射回 AppState
1. AppStateStore.ts 定义领域模型
📍 AppStateStore.ts:41-454
CompletionBoundary / SpeculationState / AppState / AppStateStore
2. getDefaultAppState()
📍 AppStateStore.ts:456-569
先计算 initialMode,再组装默认 settings / toolPermissionContext / mcp / tasks / promptSuggestion 等
3. App.tsx 顶层接入
📍 components/App.tsx:5-6,29
把 onChangeAppState 传给 AppStateProvider
4. AppStateProvider 创建 store
📍 AppState.tsx:37-57
createStore(initialState ?? getDefaultAppState(), onChangeAppState)
5. createStore()
📍 state/store.ts:10-33
提供 getState / setState / subscribe
6a. useAppState(selector)
📍 AppState.tsx:142-163
render 期订阅状态切片
6b. useSetAppState()
📍 AppState.tsx:170-172
函数式更新共享状态
6c. useAppStateStore()
📍 AppState.tsx:177-179
事件/异步回调里按需 getState()
6d. ToolUseContext.getAppState()/setAppState()
📍 Tool.ts:182-183
非 React 工具执行路径读写同一份状态
7. setState(updater)
📍 state/store.ts:20-26
prev -> next;若变更则写回 state
8. onChangeAppState()
📍 onChangeAppState.ts:43-171
比较 oldState / newState 并触发副作用
9. 通知订阅者
📍 state/store.ts:25-26
触发 useSyncExternalStore 消费者更新
10a. 权限模式同步
📍 onChangeAppState.ts:65-92
notifySessionMetadataChanged()
notifyPermissionModeChanged()
10b. 设置持久化
📍 onChangeAppState.ts:94-152
mainLoopModel / expandedView / verbose / tungstenPanelVisible
10c. settings 变化的衍生副作用
📍 onChangeAppState.ts:154-170
清 auth cache;必要时重放 env
代表性读写模式
- Render 期订阅 :权限相关组件通常用
useAppState(s => s.toolPermissionContext)订阅权限上下文,例如 Bash/File/ExitPlanMode 权限 UI(代表性模式见src/components/permissions/*) - 事件期直接读取 :工具 UI 和 bridge 回调常先拿
useAppStateStore(),再在真正执行事件时store.getState(),例如src/tools/BashTool/UI.tsx:44-50、src/hooks/useReplBridge.tsx一类异步连接逻辑 - React 内写状态 :权限流、通知流和 plan mode 退出流通常通过
useSetAppState()做函数式更新,例如恢复toolPermissionContext、设置initialMessage、推进通知队列 - 非 React 工具运行时写状态 :
toolExecution.ts、McpAuthTool.ts等运行期代码通过ToolUseContext.setAppState()更新mcp.clients/mcp.tools/mcp.commands等状态,因此 Tool 体系和 UI 看到的是同一份会话快照
12.4 难点和设计取舍
难点 1:状态字段很多,而且混合了 UI、任务、工具、权限、bridge、plan mode,如何不把"会话状态"写成一堆分散的局部状态?
- 问题 :
AppState覆盖的不是单一页面,而是整场会话的运行态:既有 prompt footer 这样的 UI 状态,也有mcp.tools、replContext.registeredTools、worker 权限请求、initialMessage、pendingPlanVerification这种只有运行时才会出现的状态。如果这些状态按功能各自散落到组件或工具内部,就很难保证多个系统看到的是同一份真值 - 方案------用
AppState作为统一的会话级领域模型,再按职责分块 :AppStateStore.ts:89-452先把整份状态集中声明成一个类型- 再通过字段分层,把"基础 UI""远程 bridge""任务 / MCP / plugin""工具运行态""worker 协调""plan / speculation"拆成几个逻辑域
- 所以这里的重点不是"把所有东西都塞进一个对象",而是:用一个统一的会话状态模型承载多个跨层系统的共享事实
难点 2:为什么有的代码用 useAppState(),有的用 useAppStateStore().getState(),有的又走 ToolUseContext.getAppState()?
- 这不是三套状态系统,而是同一个
AppStateStore的三种访问姿势 ,对应三种运行时场景:- React 渲染期 (自动订阅刷新):
useAppState(selector) - 事件 / 异步回调 (临时读一次最新值):
useAppStateStore().getState() - 非 React 工具运行时 (不能调 React hook):
ToolUseContext.getAppState()
- React 渲染期 (自动订阅刷新):
- 完整的设计理由和代码定位见 §12.2 中的"为什么
useAppState()/useAppStateStore()/ToolUseContext.getAppState()有三种读法?"。
难点 3:权限模式、plan mode、ultraplan 等状态会被很多路径修改,怎样避免外部同步只在少数调用点发生?
- 问题 :真正难的不是"改状态"本身,而是"改完之后别忘了触发后续动作"。例如
toolPermissionContext.mode会被很多路径修改:快捷键、命令处理、bridge control request、plan mode 退出、权限对话框都可能改它。如果每条路径都要自己记得"再顺手同步外部 metadata / SDK / bridge",实现很快就会漏。 - 方案------把职责拆成两步 :
- 任何调用点只负责改状态
- 状态真正改完后,由
onChangeAppState()统一决定要触发哪些副作用
- 对应代码是这样协作的 :
createStore()在写入next state后,会统一调用onChange?.({ newState, oldState })(📍state/store.ts:20-26)onChangeAppState.ts:65-92再统一比较toolPermissionContext.mode前后是否变化- 如果变了,就集中触发
notifySessionMetadataChanged()和notifyPermissionModeChanged()
- 这样设计的好处 :
- "谁能改状态"可以很多
- 但"改完要不要同步外部系统"只有一个地方负责
- 一句话理解 :
onChangeAppState()就像一个总闸口。前面很多地方都能改状态,但副作用统一从这里出。
十三、当前架构问题和演进
13.1 当前架构代价
query.ts过于中心化:压缩、恢复、模型调用、工具编排、状态推进都在这里汇合,阅读和改动成本都高。AppState聚合面偏宽:统一状态带来一致性,也带来了字段跨度大、生命周期不够显式的问题。- 服务层边界偏宽:Plugin、MCP、Skill、Agent、OAuth、LSP、遥测都落在同一层,概念跨度较大。
- 部分章节天然横切 :
Context、AppState、Command都不是纯单层对象,这也是必须拆专题文档的原因。
13.2 变更热点分析
以下分析基于代码耦合度和变更频率,标识出"改一处容易牵动哪里"的热点模块:
| 热点模块 | 典型变更场景 | 牵动范围 | 耦合根因 |
|---|---|---|---|
query.ts |
新增恢复策略、压缩算法调整、turn 控制逻辑 | query 是所有调用方的共享内核,任何路径都经过 | 压缩/恢复/工具编排/状态推进全部混在一个 while(true) 里 |
AppStateStore.ts |
新增运行态字段、调整权限模式、新增协作状态 | 所有 UI 组件、工具运行时、bridge hooks、agent 协调 | 单一对象承载 6 个职责域,字段生命周期不显式 |
toolExecution.ts |
新增 hook 类型、调整权限判定、新增工具拦截点 | 所有工具的执行路径 | 校验 / hook / 权限 / call() 串成固定管线,中间插入环节成本高 |
commands.ts + processSlashCommand.tsx |
新增命令来源(如 marketplace skill)、调整模式裁剪 | REPL 输入路径、bridge 安全判定、SkillTool 的可调用集合 | 来源发现、模式过滤、执行分发、安全裁剪散落在 3 个文件里 |
claudemd.ts |
新增 memory 层级、调整加载顺序、新增 conditional rule | 所有需要注入 CLAUDE.md 的路径(query、agent、compact) | 分层发现、去重、glob 作用域、缓存失效逻辑集中在一个文件 |
mcp/config.ts |
新增 MCP 配置来源、调整去重策略、新增 enterprise 独占规则 | 所有需要 MCP 配置的路径(启动连接、agent MCP、plugin MCP) | 6 种来源的合并/去重/策略过滤集中在单一合并函数 |
13.3 能力扩展的架构阻碍
以下是当前架构对几类可预见能力扩展的硬性阻碍及其技术根因:
-
扩展 1:多模型并行编排(模型路由 / MoE / cascading)
- 阻碍 :
query()假设"一轮只有一个模型调用"(单次deps.callModel()+ 单个 SSE stream)。多模型并行要求同一轮内发起多个并发 API 调用,当前messages/turnCount/budgetTracker都是单调用假设。 - 需要改什么 :
query.ts的循环结构需要从"串行采样→执行→回流"改成"可并发采样的管线";StreamingToolExecutor需要支持多个并发 stream consumer;messages数组需要支持多来源有序合并。 - 不变量约束:§2.2 不变量 2(tool_result 必须配对)仍然成立------每个模型的 tool_use 必须和它自己的 tool_result 配对,不能交叉。
- 阻碍 :
-
扩展 2:分布式 Agent(跨进程 / 跨机器的 agent 协作)
- 阻碍 :
AppStateStore是进程内内存对象,ToolUseContext直接引用AppStateStore实例。跨进程 agent 无法共享同一份AppState,当前没有序列化 / 同步协议。 - 需要改什么 :AppState 需要可序列化的同步协议;
ToolUseContext需要 indirection 层,让 agent 能通过 IPC / 网络访问"逻辑上的同一份状态";agent 结果回流也要从内存 push 变成协议发送。 - 不变量约束:§2.2 不变量 4(副作用统一经过 onChangeAppState)仍然成立------只是触发机制从内存 diff 变成消息驱动。
- 阻碍 :
-
扩展 3:长时运行会话(小时级 / 天级持续会话)
- 阻碍:当前 5 层压缩管线(§11.4 难点 5)假设"历史可以分级压缩然后丢弃原始数据"。但长时会话里,某些早期决策必须能精确回溯,而不只是保留摘要。
- 需要改什么 :Context 管理要引入"受保护历史段"概念------某些 messages 标记为不可压缩 / 不可 snip;压缩管线要识别这些标记;
contextCollapse的投影视图也要支持从持久化存储恢复原始段。 - 不变量约束:§2.2 不变量 3(能力变化按轮生效)仍然成立------长时会话不改变轮次快照模型。
-
扩展 4:第三方工具市场(动态安装 / 沙箱执行 / 版本管理)
- 阻碍 :Plugin 加载假设"本地缓存是最终权威"(
loadAllPluginsCacheOnly())。动态安装需要实时 fetch、校验签名、沙箱执行;版本管理又要求同一个 plugin 多版本共存。 - 需要改什么 :
pluginLoader.ts需要从"读缓存"改成"远程 fetch + 本地校验 + 沙箱隔离"的加载管线;MCP 配置合并要支持同一 plugin 的多版本 key 隔离;工具池组装也要支持同名工具的版本路由。 - 不变量约束:§2.2 不变量 8(工具暴露先不可见再不可执行)和不变量 9(扩展不能绕过主循环)仍然成立------第三方工具仍必须通过 Tool 抽象接入。
- 阻碍 :Plugin 加载假设"本地缓存是最终权威"(
十四、与传统工程相比,Claude Code 代表的 Agent 开发有什么异同
14.1 相同点:底层仍然是工程,不是"魔法"
如果把 Claude Code 去掉"模型"这一层,它依然是一套很典型的复杂工程系统:有分层、有边界、有状态、有生命周期,也有错误恢复和权限控制。
-
都需要稳定的分层与接口
- 传统工程里,我们会把 controller / service / repository 或 port / adapter 分开。
- Claude Code 里对应的是入口层、
query()编排层、Tool 层、服务层、基础设施层。 - §7.2 里的
Tool统一协议,本质上就是一个能力边界:上层不关心BashTool、AgentTool、MCP tool 的内部实现,只关心统一的 schema、权限检查和结果回流。
-
都需要明确的状态模型
- 传统系统会区分 request-scoped、session-scoped、persisted state、cache。
- Claude Code 在 §12.2 已经把这件事做得非常明确:turn-scoped、session-scoped、persisted / resumable、derived / cache 四层生命周期。
- 这说明 agent 系统不是"想到哪做到哪",而是和普通工程一样,必须先定义状态边界,再讨论行为。
-
都需要编排与失败恢复
- 传统后端会有请求管线、工作流引擎、补偿逻辑、重试策略。
- Claude Code 的
query()主循环(§5.3)承担的是同类职责:上下文拼装、模型调用、工具执行、结果回灌、错误恢复、继续下一轮。 prompt-too-long、max-output-tokens、media-size-error的恢复路径,本质上就是 agent 版的弹性设计。
-
都需要权限与安全边界
- 传统工程强调认证、鉴权、最小权限。
- Claude Code 同样如此,只是对象从"用户访问 API"扩展到了"模型调用工具"。
checkPermissions()、运行模式裁剪、按轮刷新工具快照,都是工程化的安全边界,而不是 prompt 层面的软约束。
14.2 不同点:Agent 开发是在工程系统里接入了一个"不确定规划器"
Claude Code 和传统工程最大的不同,不是多了几个工具,而是系统核心多了一个概率性的、可自主规划下一步动作的执行者------模型。这会把很多原来不是一等公民的问题,变成架构核心。
-
接口不再只有代码接口,还包括 prompt 接口
- 传统工程的接口主要是函数签名、HTTP schema、消息协议。
- Claude Code 里,
Tool.inputSchema仍然重要,但还多了Skill的when_to_use、allowed-tools、agent、system prompt 组装方式(§8.2、§11.2)。 - 也就是说,prompt、frontmatter、工具描述文字,不再只是文档,而是可执行接口的一部分。
-
主流程不再是单次请求,而是多轮闭环
- 传统请求大多是
input -> handler -> result。 - Claude Code 的核心是
messages -> model -> tool_use -> tool_result -> messages的闭环(§3.2、§7.3)。 - 这里的关键不是"调用了工具",而是工具结果会重新成为下一轮推理的输入,因此架构必须围绕闭环一致性设计。
- 传统请求大多是
-
上下文预算成为一等运行时资源
- 传统工程当然也有缓存、吞吐、内存限制,但业务逻辑通常不直接围绕"上下文窗口"展开。
- Claude Code 则必须把上下文预算写进主循环:
toolResultBudget -> snip -> microcompact -> contextCollapse -> autocompact(§5.3)。 - 这意味着"信息保留什么、压掉什么、何时摘要化"会直接改变系统行为,而不只是性能。
-
流式、并发与中断控制更靠近业务语义
query()是AsyncGenerator,StreamingToolExecutor允许边出 token 边跑工具(§5.3、§7.3)。- 在传统工程里,流式输出常是传输层优化;在 agent 系统里,它直接影响用户反馈、工具启动时机、回合推进和中断恢复。
14.3 用文中的几个案例做对比
| 文中案例 | 传统工程里的近似对象 | Agent 开发新增的问题 |
|---|---|---|
query() 主循环(§5.3) |
应用服务 / 工作流引擎 / Saga 编排器 | 执行者变成模型,输出不确定;必须处理 tool_use 配对、上下文压缩、按轮继续 |
Tool + ToolUseContext(§7.2) |
端口接口 + 运行时依赖注入 | 工具不只要"能执行",还要能被模型理解、被权限系统裁剪、被 UI 流式展示 |
Skill / Agent frontmatter(§8.2) |
配置驱动任务模板 / 子流程定义 | Markdown 与 prompt 进入执行面;模型选择何时调用、在哪个上下文里调用,会影响行为正确性 |
userContext / systemContext(§11.2) |
配置加载器 / 请求增强器 / 知识注入层 | 注入位置、优先级、缓存失效、压缩策略都会影响推理结果,不只是影响便利性 |
AppState + onChangeAppState()(§12.2) |
全局状态仓库 + 领域事件 / 副作用收口 | 同一份状态要同时服务 UI、工具运行时、agent 协调和外部 metadata,同步一致性要求更高 |
这些例子说明:Agent 开发不是替代传统工程,而是在传统工程骨架上,把"模型推理闭环"嵌进去。
14.4 哪些能力是可迁移的
如果只把 Claude Code 看成"会写 prompt 的系统",会低估它最有价值的部分。真正可迁移的,恰恰是这份文档里反复出现的工程能力:
-
边界建模能力可迁移
- 把
Tool、Skill、Agent、Context、AppState分开,本质上是在做职责划分、依赖反转和抽象收口。 - 这些能力同样适用于普通后端、前端状态管理、插件系统和工作流平台。
- 把
-
状态机与生命周期设计能力可迁移
- §12.2 对 turn/session/persisted/cache 的划分,不只适用于 agent。
- 任何复杂交互系统、协作系统、长任务系统,都需要这种生命周期分层能力。
-
异步编排能力可迁移
- §5.3 里"启动和消费分离"的设计模式,本质上是高质量的异步流水线设计。
- 它可以直接迁移到 SSE 服务、任务编排、批处理流水线、IDE 插件、远程执行框架。
-
契约优先能力可迁移
inputSchema、权限检查、结果映射、统一回流,都是 contract-first 思维。- 只是 Claude Code 把这件事从 API 扩展到了"模型可调用能力"。
-
故障恢复与降级设计能力可迁移
- prompt-too-long、token 超限、权限拒绝、连接刷新这些恢复路径,看起来像 agent 特有问题,但底层能力其实是通用的:限流、降级、重试、补偿、兜底。
-
安全收口能力可迁移
- Claude Code 没有把安全寄托在模型"自觉",而是落实为 tool visibility、permission check、mode gating、统一副作用出口。
- 这和传统工程里做 capability control、sandbox、least privilege 是同一种能力,只是作用对象变了。
一句话总结:Claude Code 代表的 agent 开发,新增的是对"不确定规划器"的工程化约束;而真正决定系统上限的,仍然是传统工程里那些最硬的能力------抽象、状态、编排、契约、安全与恢复。
因此,对工程师来说,最值得投入的并不是某个 prompt 技巧,而是把这些能力迁移到 agent 场景中的方法论。
14.5 哪些能力不适合直接迁移
同样重要的是:并不是 Claude Code 里所有"看起来有效"的东西都能原样搬到别的工程里。
-
特定 prompt 文案和措辞技巧
- Claude Code 的 system prompt、tool 描述、skill 文案,是和当前工具名、权限模型、运行方式一起调出来的。
- 脱离这套上下文,原文照搬通常只会得到"像它,但不像它"的效果。
-
围绕当前产品形态形成的交互约定
- 比如 REPL 流式输出、local JSX、权限弹窗、plan mode、命令卡片,这些都强依赖终端 TUI 产品形态。
- 换成 IDE、Web、CI 或纯 API 服务后,很多交互约定都要重做,不能直接复制。
-
针对当前模型/API 调过的经验参数
- 例如上下文压缩顺序、token 恢复阈值、skill prompt 的预算分配、流式执行节奏,这些都和当前模型能力、API 行为、成本约束强相关。
- 方法论可以迁移,但具体阈值和策略必须重新验证。
-
项目内知识本体本身
CLAUDE.md、managed skills、项目 rules、agent frontmatter 里写的很多内容,本质上是当前组织和仓库的知识沉淀。- 可迁移的是"如何组织这些知识",不是知识内容本身。
-
依赖当前架构假设的细节实现
- 比如按轮刷新工具快照、
AppState的组织方式、SkillTool 与 command 体系的耦合点,都建立在 Claude Code 现有分层上。 - 如果目标系统的状态边界和执行闭环不同,这些实现细节就应该重设计,而不是照抄。
- 比如按轮刷新工具快照、
所以更准确的说法是:可迁移的是抽象方法、边界意识和工程套路;不可直接迁移的是 prompt 成品、产品交互和绑定当前架构的具体参数。