Claude Code 核心架构和源码解析

最近研读了 Claude Code 源码,顺便剖析了目前最前沿编程 Agent 的架构设计与核心实现。最大的体会是:Agent 开发并未脱离传统软件工程的范畴。剥开 AI 的功能外衣,其底层非功能性的设计理念与传统软件开发大同小异。

源码来源:https://www.xuanyuancode.com/learn-claude-code

一、系统总览:边界、分层与运行方式

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.tssrc/main.tsxsrc/replLauncher.tsx 进程启动、命令路由、环境初始化、会话 setup 单轮 query 细节、工具执行语义
核心编排层 src/query.tssrc/query/src/QueryEngine.ts 输入分流、主循环、上下文拼装、结果推进下一轮 具体工具实现、具体外部服务连接
界面层 src/screens/src/components/src/ink/src/keybindings/src/vim/ REPL、Ink 渲染、键盘交互、Diff/Dialog 呈现 模型决策、工具协议定义
工具层 src/Tool.tssrc/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

编排主权
  1. query() 是模型调用唯一收口点 。所有对话推进------REPL、SDK、子 Agent、forked agent、agentic hook------必须经过 query() / queryLoop()。旁路直接调 API 绕开主循环属于违规。
  2. tool_result 必须配对且进入下一轮消息流 。每个 tool_use 有且仅有一个 tool_result;中断时由 StreamingToolExecutor 生成合成的 error result 补齐配对。违反此不变量会导致 API 协议错误或模型推理失忆。
  3. 能力变化按轮生效,mid-turn 不可变 。工具池、命令集、MCP 连接状态在当前轮开始时快照固定;refreshTools() 的结果只在下一轮可见。这保证主循环和工具执行器在单轮内看到一致的 options.tools
状态与副作用
  1. AppState 是会话级共享状态的单一事实源 。UI、工具运行时、bridge、权限流、agent 协调都读写同一个 AppStateStore;不允许另建平行的会话级状态对象。
  2. 副作用统一经过 onChangeAppState() 收口 。权限模式同步、设置持久化、认证缓存清理等副作用不在调用点触发,而在 diff-based onChangeAppState() 中集中触发。调用点只负责 setState()
  3. 权限只收紧不放宽 。进入 auto mode / background agent 后,stripDangerousPermissionsForAutoMode() 剥离危险 allow 规则;shouldAvoidPermissionPrompts 让无 UI 场景倾向保守拒绝。子 Agent 不能继承比父更宽松的权限。
安全边界
  1. 信任建立前不应用高风险配置 。项目级 .claude/settings.json 的完整环境变量(包括 ANTHROPIC_BASE_URLHTTP_PROXY 等)在 showSetupScreens() 通过后才生效;信任前只应用 SAFE_ENV_VARS 白名单。
  2. 工具暴露遵循"先不可见,再不可执行"原则。deny rules 和 mode 过滤在工具组装阶段就把不合规工具从 schema 中移除(第一道关),而不是暴露后再在执行时拦截。参见 §7.4 难点 5 的"四道关"模型。
  3. 扩展不能绕过主循环、权限与状态收口。MCP server、Plugin、Skill、Agent 的能力必须通过 Tool / Command / AppState 抽象接入系统;不允许扩展直接调模型 API 或直接修改 messages 数组。
生命周期与资源
  1. 静态依赖方向不可反转。基础设施层(utils/)和服务层(services/)不反向依赖 UI 层或主循环;运行时回调 / 回流不改变编译期依赖方向。
  2. MCP 连接创建与清理必须在同一作用域配对 。Agent 内联 MCP 连接在 finally 块中 cleanup;引用型连接不跟随 Agent 清理。违反此不变量会导致连接泄漏或父上下文工具丢失。
  3. Context 缓存失效从底层往上层传播clearMemoryFileCaches()(第 3 层)先清,getUserContext() / getSystemContext() 的 memoized 结果(第 2 层)随之失效,getGitStatus()(第 1 层)独立清。跳层清缓存会导致读到过期的组合结果。
协议与一致性
  1. userContext 走消息通道,systemContext 走 system prompt 通道 。两路注入的落点由 context.ts 统一决定,下游 query() / compact() / runAgent() 不自行判断上下文该塞到哪里。
  2. Stop hooks 的执行序列严格串行:PostSampling(fire-and-forget)→ Stop hooks(blockingError / preventContinuation)→ Teammate hooks → Token budget check。顺序不可调换,否则 blocking error 和 preventContinuation 的语义会被打破。
  3. Agent 结束后不留痕runAgent()finally 块逐一清理 MCP 连接、session hooks、prompt cache tracking、messages 数组、orphan todos、background bash tasks。Agent 可以持有父上下文引用,但不能在结束后残留副作用。
  4. 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-path
  • main.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 mcpclaude authclaude 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.jsdaemon/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() at init.ts:74):只应用来自可信来源~/.claude/settings.json 用户配置、--settings 命令行参数、企业托管配置)的所有环境变量 + 来自项目级配置.claude/settings.json.claude/settings.local.json)中仅在 SAFE_ENV_VARS 白名单里的变量。白名单包含 Claude Code 自身的功能开关(如 ANTHROPIC_MODELCLAUDE_CODE_USE_BEDROCKBASH_DEFAULT_TIMEOUT_MSOTEL_* 系列 headers/protocol 等约 80 个),这些变量不会造成安全风险
    • 信任后applyConfigEnvironmentVariables() at interactiveHelpers.tsx:184):信任对话框通过后,应用项目配置中的所有 环境变量,包括危险变量如 ANTHROPIC_BASE_URL(可重定向 API 请求)、HTTP_PROXY/HTTPS_PROXY(可劫持网络)、NODE_TLS_REJECT_UNAUTHORIZED(可关闭 TLS 验证)、LD_PRELOAD/PATH(可注入恶意代码)等
    • showSetupScreens() (📍 interactiveHelpers.tsx:104)具体做了什么:
      1. Onboarding --- 首次使用时展示主题选择和引导流程(L111-123)
      2. TrustDialog --- 检查当前工作区是否已被信任,未信任则弹出对话框让用户确认(L131-140),这是安全边界
      3. 信任后触发一系列动作:重置 GrowthBook(L149-150)、预取系统上下文(L153)、检查 MCP 服务器审批handleMcpjsonServerApprovals L160)、检查 CLAUDE.md 外部 include(L164-170)
      4. 应用完整环境变量(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/getplugin install/list/marketplaceauth login/logout/statusdoctorconfigupdateskill 等),它们都需要配置、网络、日志等基础初始化,但业务逻辑完全不同
  • 子命令如何触发 :全部在 run() 函数(main.tsx:3892-4684)中通过 Commander 注册。例如 claude mcp serve 对应 program.command('mcp').command('serve').action(async () => { ... })(L3894-3909)。用户执行 claude mcp serve 时,Commander 解析 argv 匹配到该子命令,先执行 preAction hook(通用初始化),再执行该子命令自己的 .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 中的 useManageMCPConnections hook):不在启动流程中连接 ,而是在 REPL React 组件挂载后,通过 React useEffect 异步触发。先调 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 文件变更检测器等,全部是火后不管模式


五、核心编排层分析

5.1 职责与边界

每轮对话的核心循环:上下文压缩 → 流式调用模型 → 并行执行工具 → 收集附件 → 拼接结果进入下一轮,直到模型结束或异常退出。

边界 :本章只讨论 query() 及其周边编排如何驱动一次对话前进;Command / Context / AppState 的内部机制分别放到后文第 10-12 章专题中展开。

5.2 关键领域模型

核心编排层的关键对象是一轮 query 如何被组织

  • query() / queryLoop():模型调用、工具执行、恢复重试的统一主循环
  • State :当前轮及跨轮推进所需的可变状态集合,如 messagestoolUseContextturnCounttransition
  • ToolUseContext:主循环与工具层之间的运行时桥梁
  • budgetTracker / autoCompactTracking:上下文预算与压缩治理对象
  • StreamingToolExecutor / runTools():工具执行编排器,分别对应流式与批量两条执行路径

这层不是"定义全部能力"的地方,而是把命令、上下文、状态、工具和服务能力按正确顺序串成一个可持续推进的对话回合

5.3 核心时序

query() 从哪里被调用?

query() 是所有对话逻辑的统一入口,有 6 个调用方:

  1. REPL 交互模式screens/REPL.tsx:2793):用户在终端输入 → onQuery()onQueryImpl()for await (const event of query(...))onQueryEvent(event) 逐事件驱动 UI 更新
  2. SDK / Headless 模式QueryEngine.ts:675):submitMessage()for await (const message of query(...)) → 通过回调输出 SDK 事件
  3. 子 Agenttools/AgentTool/runAgent.ts:748):AgentTool 为子 agent 创建隔离的消息数组和 systemPrompt,调 query() 运行独立的多轮对话
  4. Fork Agentutils/forkedAgent.ts:545):runForkedAgent() 为后台任务(compact、session_memory、auto-dream 等)fork 出独立 query 循环
  5. Agent Hookutils/hooks/execAgentHook.ts:167):agentic 类型的 hook 通过 query() 获得多轮推理能力
  6. 后台会话任务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_resultis_error: true),保证 API 协议的 tool_use/tool_result 配对不被破坏

难点 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,完全不等待结果

难点 3:max_output_tokens 截断------模型写到一半被切断怎么办?

  • 问题 :模型输出超过 max_tokens 限制时,API 返回 stop_reason: max_output_tokens,输出被截断在任意位置(可能在代码中间)。用户看到的是不完整的回答

  • 方案------三级恢复 (📍 query.ts:1185-1256):

    1. 升级重试 (L1199-1221):如果使用的是默认的 8k 上限,直接把 maxOutputTokensOverride 提升到 64k,用同一条消息 重试------不生成任何 meta 消息,用户无感。仅触发一次(maxOutputTokensOverride === undefined 守卫)
    2. 注入续写提示 (L1223-1251):如果 64k 也不够,注入一条 meta 消息 "Output token limit hit. Resume directly --- no apology, no recap..." 要求模型从截断处继续。最多重试 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT
    3. 放弃恢复(L1254-1256):3 次都不够,surface 被扣留的错误消息,退出循环
    • 关键实现细节:流式期间通过 isWithheldMaxOutputTokens() 扣留错误(L820),不 yield 给 UI,直到恢复逻辑决定是否可以自动修复。这避免了 SDK 消费方(如 Desktop 应用)看到中间错误就终止会话
  • 升级重试会消耗更多 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 testeslint .python -m pytest ------ 执行一个 shell 命令,根据退出码决定行为
    • prompt(LLM 提示):给 Claude 发一段文本让它审查上一轮回答的质量 ------ 相当于让 LLM 做 self-review
    • agent(Agentic 验证器):启动一个多轮的子 agent 来验证代码正确性 ------ 比 prompt 更强大,可以读文件、运行测试
    • http(HTTP 回调):向外部服务发 HTTP 请求 ------ 用于与 CI/CD 或自定义服务集成
  • 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" },对话立即终止。打个比方:监考官直接叫停考试
  • 完整的 "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 → ..." 的无限循环


六、界面层分析

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 组件里
  • 一句话理解UI 是外壳,不是能力本体;能显示成卡片就显示,不能显示就退回文本/事件流。

七、工具层分析

7.1 职责与边界

统一工具契约定义 → 按权限和连接状态组装工具池 → 流式/批量编排执行 → 校验、权限、Hook 拦截 → 结果回流至 UI 和下一轮上下文。

边界:工具层定义"模型如何看到并调用能力",以及"能力如何被统一执行";但工具来源的发现、扩展接入和外部连接管理属于服务层。

7.2 关键领域模型

什么是 Tool?

Tool 不是某个具体工具的类名,而是一份统一协议 (📍 Tool.ts:362-695):只要对象满足 nameinputSchemacall()checkPermissions()mapToolResultToToolResultBlockParam() 等方法,它就能被主循环当成"一个可调用工具"处理。BashToolFileReadToolAgentTool、MCP tools 虽然行为完全不同,但在 query() 看来它们都只是 Tool[] 里的一个元素。

什么是 ToolUseContext?

ToolUseContext(📍 Tool.ts:158-300)是"工具执行期的运行时现场"。它不仅携带 options.tools(当前工具池),还带着 abortControllermessagesgetAppState()/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 (统一协议)
    • 代表"一个可被模型调用的工具对象"
    • 例子:BashToolFileReadToolAgentTool
  • ToolUseContext (运行现场)
    • 代表"工具调用时可读取和可回写的上下文"
    • 例子:options.toolsabortControllermessagesrefreshTools()
  • ToolResult (执行结果载体)
    • 代表"工具返回给主循环的结构化结果"
    • 例子:datanewMessagescontextModifiermcpMeta
  • ToolDef / buildTool() (定义期构造器)
    • 代表"具体工具如何声明自己,以及如何被补齐默认行为"
    • 例子:某个工具只实现 call() / inputSchema / checkPermissions(),其余由 buildTool() 填默认值
  • runToolUse() / runTools() / StreamingToolExecutor (执行编排层)
    • 代表"单工具执行、批量执行、流式执行"三种不同粒度的执行器
    • 例子:runToolUse() 处理单个 tool_userunTools() 处理批量批次,StreamingToolExecutor 处理边流边执行

从职责视角看,工具大致可以分成 4 组

  • 1. 基础执行工具 :直接完成"读、写、搜、跑"这些底层动作,是模型最常用的基本操作单元
    • 例子:BashToolFileReadToolFileEditToolFileWriteToolGlobToolGrepToolNotebookEditTool
  • 2. 会话控制工具 :不直接处理业务数据,而是控制当前会话怎么继续、怎么切模式、怎么和用户交互
    • 例子:AskUserQuestionToolEnterPlanModeToolExitPlanModeToolTodoWriteToolBriefToolSkillTool
  • 3. 扩展接入工具 :把 Claude Code 之外的能力接进来,让工具池不只局限于本地内建能力
    • 例子:MCP tools、ReadMcpResourceToolListMcpResourcesToolLSPToolWebFetchToolWebSearchTool
  • 4. 协作与任务工具 :把"执行一个动作"提升为"派发任务、调度 agent、管理协作状态"
    • 例子:AgentToolTaskCreateToolTaskGetToolTaskUpdateToolTaskListToolTaskStopTool

其中 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:工具形态很多、执行环节也很多,如何既统一抽象,又不把执行链写散?

  • 问题 :这里其实有两层复杂度叠在一起:
    1. 工具形态差异大 :既有 BashToolFileReadTool 这种本地能力,也有 AgentToolSkillTool、MCP tools、LSP tools 这类远端/复合能力
    2. 单次调用环节多 :一个 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-44REPL.tsx:2404-2436query.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_use block 就立刻 addTool()
      • 执行器内部维护 queued / executing / completed / yielded 状态,边执行边缓存结果
      • getCompletedResults() 让 UI 在模型还没输出完时就能先看到已完成工具的结果;getRemainingResults() 则在回合收尾时补齐剩余结果
    • 这两条路径共享同一个单工具执行内核,因此"并发模型不同,但执行语义一致"
    • 怎么判断某个工具是不是 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:工具结果既要立刻显示给用户,又要成为下一轮模型的输入,如何双通道回流?

  • 问题 :工具执行完成后,系统同时面临两个需求:
    1. 当前 UI / SDK 流要尽快看到结果
    2. 下一轮模型调用时,必须把这次 tool_result 带回上下文,否则模型不知道工具执行了什么
      如果只解决其一,就会出现"界面能看到结果,但模型失忆"或"模型能继续推理,但 UI 不实时"的问题
  • 方案------先封装为 user/tool_result,再一份结果走两条路径
    • toolExecution.ts:1403-1474addToolResult() 把工具输出封装成 createUserMessage({...})
    • 这条 user message 里既包含 API 协议需要的 tool_result block,也带着内部字段 toolUseResultmcpMetasourceToolAssistantUUID
  • 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 件事:

    1. 这个工具能不能出现在本轮工具列表里?
    2. 就算出现了,这一次调用能不能真的执行?
    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-1103toolHooks.ts:332-405

      • checkPermissionsAndCallTool() 的顺序是:inputSchema.safeParse()validateInput()runPreToolUseHooks() → 权限决策 → tool.call()
      • 其中权限决策会通过 resolveHookPermissionDecision() 把 hook 返回、规则判断、canUseTool() 交互审批统一收口。
      • 只要中间任何一步返回 denyaskstop,本次调用就停在这里,结果以错误型 tool_result 回给模型,而不会继续真正执行。
    • 第 3 关:内容关------同一个工具,输入不同,权限也可以不同 (📍 Tool.ts:123-148Tool.ts:762-766utils/permissions/permissions.ts:1113-1155

      • ToolPermissionContext 统一保存当前权限上下文:modealwaysAllowRulesalwaysDenyRulesalwaysAskRulesshouldAvoidPermissionPrompts 等。
      • 每个 Tool 还可以实现自己的 checkPermissions()
      • 这意味着系统判断的不是"是不是 BashTool",而是"这次 Bash 的具体命令能不能跑"。
      • 例如只读命令和写命令,虽然都走 BashTool,但权限结果可以不同。
    • 第 4 关:模式关------进入 auto / 无 UI 场景时,系统会主动收紧 (📍 permissionSetup.ts:510-552REPL.tsx:3068-3075Tool.ts:132-135

      • 进入 auto mode 时,stripDangerousPermissionsForAutoMode() 会把可能绕过 classifier 的危险 allow 规则剥掉,避免"原来手工模式下可放行的规则"直接带进自动模式。
      • 如果当前环境不适合弹权限框(如某些 background agent 场景),shouldAvoidPermissionPrompts 会让系统倾向于保守拒绝,而不是静默放行。
      • awaitAutomatedChecksBeforeDialog 则表示:先等 classifier / hooks 这类自动检查跑完,再决定要不要真的进入交互确认。
  • 可以把整条链路记成一句话
    先决定模型看不看得见工具,再决定这次调用能不能执行,最后再根据当前模式把规则收紧。



八、服务层分析

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 → managedgetActiveAgentsFromList()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.aiclaude.ai {DisplayName} 前缀
    • 三者 key 永远不会冲突------但底层可能启动的是同一个进程或同一个 URL
    • 如果不去重,就会出现两个连接同时连到同一个 MCP server,浪费资源且产生工具重复
  • 方案------"key 不碰撞 + 内容级签名去重"两层配合
    • 第一层:key 命名空间隔离 (📍 config.ts:219
      • Plugin servers 统一加 plugin: 前缀,claude.aiclaude.ai 前缀,手动配置保持原名
      • Object.assign() 合并时按优先级 plugin < user < project < local 覆盖,key 相同则高优先级胜出
    • 第二层: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 再比对,避免代理改写后签名不匹配
    • 第三层:只有 enabled 的 server 才是去重目标
      • 如果用户手动 disable 了一个 server,它不应阻止同签名的 plugin server 连接------否则两边都不跑
      • 被抑制的 server 会生成 mcp-server-suppressed-duplicate 错误,在 /plugin UI 中可见(但不进 error log)
  • 一句话提炼key 隔离保证不误覆盖,签名去重保证不双连接,disabled 例外保证不两头空

难点 2:MCP 连接是异步的,工具列表在会话中间会变化,如何既不阻塞启动又保证一致性?

  • 问题 :MCP server 的连接、OAuth 授权、工具 fetch 都是异步操作,耗时从毫秒到数十秒不等。系统面临两个矛盾需求:

    1. 用户不希望等所有 MCP 连上才能开始交互(启动速度)
    2. 模型需要看到稳定的工具列表,不能一轮看到 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,下一轮自动可见
  • 一句话提炼不阻塞启动但保证每一轮工具列表是一致快照,新工具下一轮自动可见

难点 3:Plugin 同时提供 MCP server / Skill / Agent / Hook,如何避免循环依赖和加载顺序问题?

  • 问题 :Plugin 是"能力容器",一个 Plugin 可以同时贡献 MCP servers、skills、agents、hooks。这导致:
    1. MCP config 合并需要读 plugin 的 mcpServers → 依赖 plugin 加载
    2. Skill 加载需要读 plugin 的 skillsPath → 依赖 plugin 加载
    3. Agent 加载需要读 plugin 的 agentsPath → 依赖 plugin 加载
    4. MCP skill 又需要从 MCP server 的 prompts 中解析 → 依赖 MCP 连接
    5. 这些依赖如果串行,启动会很慢;如果并行,又要处理好"谁先谁后"
  • 方案------"Cache-only 加载 + 并行无依赖分支 + 延迟注入"
    • Plugin 只读缓存 (📍 pluginLoader.ts
      • loadAllPluginsCacheOnly() 从本地文件系统缓存读取 plugin 信息,不触发网络请求
      • MCP config 合并、skill 加载、agent 加载都可以在 plugin 缓存就绪后并行启动
    • MCP Skill 的循环依赖打破 (📍 mcpSkillBuilders.ts
      • 问题链:client.tsmcpSkillsloadSkillsDir.tscommands.tsclient.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 就绪,也不影响系统启动
  • 一句话提炼Plugin 缓存消除网络依赖,注册表打破编译期循环,延迟注入消除运行时顺序约束

难点 4:Agent 是独立推理循环,但需要与父上下文共享 MCP 连接、工具池、权限规则,如何既隔离又共享?

  • 问题 :Agent 通过 runAgent() 创建独立子对话,有自己的 system prompt、messages、tools。但它不是一个完全独立的进程:

    1. Agent 可以引用父上下文已有的 MCP server(共享连接,避免重复连接)
    2. Agent 也可以声明自己的 MCP server(独立连接,Agent 结束时清理)
    3. Agent 的权限模式可以与父不同(如 Agent 是 plan 模式,父是 default 模式)
    4. Agent 注册的 session hooks 必须在 Agent 结束时清除,否则污染父上下文
    5. 异步 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 / disallowedTools frontmatter 过滤工具池
      • 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,防止父权限泄露
    • Hooks:Agent 级注册 + 清理 (📍 runAgent.ts:557-575 / 816-821
      • registerFrontmatterHooks()agentId 为 key 注册 session hooks
      • Agent 的 Stop hooks 被转换为 SubagentStop(因为子 Agent 触发的是 SubagentStop 事件)
      • finallyclearSessionHooks(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 引用、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 优先
    • Agent 内 Skill 引用解析 (📍 runAgent.ts:945-973
      • Agent frontmatter 写 skills: [my-skill] 时,resolveSkillName() 按三步查找:
        1. 精确匹配(name、userFacingName、aliases)
        2. 加 plugin 前缀匹配(pluginName:my-skill
        3. 后缀匹配(任何以 :my-skill 结尾的命令)
      • 这保证了 plugin Agent 引用自己的 plugin skill 时不需要写全限定名
  • 一句话提炼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.tsutils/git.tsutils/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。三者共享 namedescriptionaliasesisEnabled()userInvocable 等基础字段,但执行方式完全不同。

为什么 Command 的主入口是 commands.ts,而类型定义却在 types/command.ts

因为两者承担的是不同角色:types/command.ts 负责声明"命令是什么";commands.ts 负责回答"当前有哪些命令、它们从哪来、哪些现在可见、怎样按名字找到它们"。前者是类型层,后者是注册表 + 加载器 + 查找器。

Command 也有一套类似 Tool 的统一抽象,只是抽象对象不同

  • Tool 抽象的是"模型可调用的能力单元"Command 抽象的是"slash 命令单元"
  • types/command.ts 里,Command 主要抽象了 5 个点:
    • 通用元信息CommandBase 统一了 namedescriptionaliasesavailabilityisEnabled()userInvocableloadedFrom
    • 执行形态 :用 type 把命令分成 prompt / local / local-jsx
    • 执行入口
      • promptgetPromptForCommand(args, context)
      • local / local-jsxload().call(...)
    • 执行上下文 :统一复用 ToolUseContext,而 local-jsx 再扩展成 LocalJSXCommandContext
    • 结果契约LocalCommandResultonDone()、以及 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 风格命令
  • 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 skills
      • getCommands() 负责产出"当前 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.disableModelInvocation
      • cmd.source !== 'builtin'
      • 且满足 skills/plugin/bundled 等来源条件
    • getMcpSkillCommands()(📍 commands.ts:547-559)再补上可模型调用的 MCP prompt commands
    • SkillTool.ts 最终不是直接"运行一个任意 command 对象",而是调用 processPromptSlashCommand()(📍 tools/SkillTool/SkillTool.ts:635-641processSlashCommand.tsx:817-825),也就是复用 prompt command 的那条执行链
    • 所以更准确地说:模型不能像 Tool 那样直接调用整个 Command 系统;它只能通过 SkillTool 间接调用"被挑出来的 prompt command 子集"
  • 这几个例子最能说明边界
    • 用户可调、模型不可调
      • batch(📍 skills/bundled/batch.ts:101-110userInvocable: true,但 disableModelInvocation: true
      • debug(📍 skills/bundled/debug.ts:13-24)也是同样模式
      • skillify(📍 skills/bundled/skillify.ts:163-177)同样要求用户显式触发
    • 用户不可调、模型可调
      • keybindings-help(📍 skills/bundled/keybindings.ts:293-299userInvocable: 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 commits
    • getMemoryFiles() 扫描 CLAUDE.md、rules、memory entrypoint
    • getLocalISODate() 注入当天日期
  • 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 parts
    • prependUserContext() 把 userContext 前插为 meta user message
    • appendSystemContext() 把 systemContext 追加到 system prompt
    • query.tsREPL.tsxQueryEngine.tsrunAgent.tscompact.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:上下文来源很多,为什么还要分成 userContextsystemContext 两路?

  • 问题 :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 通道"两路
    • 为什么要这样分?因为当前代码明确把:
      • claudeMdcurrentDate 放进 userContext
      • gitStatuscacheBreaker 放进 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):
      1. Managed
      2. User
      3. Project
      4. Local
      5. 额外目录(--add-dir,若 env 开启)
      6. 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,203services/compact/postCompactCleanup.ts:59-60
    • 第 3 层:memory 文件发现缓存
      • getMemoryFiles() 自己单独 memoize,缓存的是 MemoryFileInfo[](📍 utils/claudemd.ts:790-1075
      • clearMemoryFileCaches() / resetGetMemoryFilesCache() 负责这层的失效(📍 1119-1129
      • 失效条件
        • /clear--resume--continueresetGetMemoryFilesCache('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-280EnterWorktreeTool.ts:99-101ExitWorktreeTool.ts:142-144sessionRestore.ts:361-387
        • settings/team memory 写回本地:clearMemoryFileCaches()(📍 services/settingsSync/index.ts:573-575services/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 分界线

难点 4:为什么 Context 体系既出现在主循环里,也出现在 compact、agent、session memory、analyzeContext 里?

  • 问题 :Context 不是只给主线程 query() 用的。当前代码里,很多"需要重建系统提示词或重算上下文"的路径都要复用同一套结果。如果每个模块自己拼,会很快出现"主循环看到的上下文"和"compact / agent 看到的上下文"不一致
  • 方案------把获取与注入拆成共享 helper,供多条链路复用
    • fetchSystemPromptParts()(📍 utils/queryContext.ts:44-74)统一返回 defaultSystemPrompt + userContext + systemContext
    • query.ts(📍 449-450,659-661)在真正调用模型前做最终注入
    • REPL.tsxQueryEngine.tsrunAgent.tscompact.tssessionMemory.tsanalyzeContext.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,再走恢复性压缩
      • 这不是日常路径,而是"主动压缩没拦住"时的自救链路
  • 为什么必须分成这 5 层,而不是直接只做 autocompact
    • 只做 autocompact 不行:它最重、最贵,而且一旦触发就是整段历史摘要,信息损失最大
    • toolResultBudget 必须单独存在:因为很多超限问题来自"单条结果太大",而不是"历史太长"
    • snip 必须早于摘要:它是最轻量的释放手段,纯内存裁剪,不需要调模型
    • microcompact 不能被 snip 替代snip 是删整条消息;microcompact 压的是消息内部冗余,目标是"保留结构,只减内容"
    • contextCollapse 不能被 autocompact 替代:它是中间路线------先折叠视图,尽量保留更细粒度的上下文;代码注释已经明确写了,它就是为了在进入全量摘要前再争取一次"保留细节"的机会
    • autocompact 放在最后:当前面几层都不够时,才用全局摘要接管
    • 所以这 5 层的关系不是"功能重复",而是:从低成本、低损失,逐步升级到高成本、高压缩
  • 为什么这也属于 Context 体系?
    • 因为它处理的不是工具执行逻辑,而是:当前回合到底把哪一份对话历史、以什么粒度、以什么压缩形态继续送进模型
    • 换句话说,context.ts 管的是"上下文从哪来、怎么注入";query.ts 这条压缩管线管的是"上下文太长时,怎么继续维持可用"
  • 一句话提炼
    • Claude Code 的 Context 管理不是"接近上限时做一次摘要",而是:
      • 先局部控体积
      • 再轻量裁剪
      • 再细粒度压缩
      • 再折叠视图
      • 最后才全局摘要
      • 失败后还有恢复性压缩
    • 这也是为什么长对话的上下文管理,应该放在 Context 章节里统一理解,而不是只看 Query 循环的局部实现

十二、专题:状态体系

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 mode AppStateStore 会话内持续共享
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.tsAppState.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 (会话级状态快照)
    • 代表"当前会话所有共享状态的总和"
    • 例子:toolPermissionContexttasksmcp.toolsinitialMessagespeculation
  • 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
    • 例子:settingsverbosemainLoopModelexpandedViewfooterSelection
  • 2. 远程会话与 bridge 状态 (📍 src/state/AppStateStore.ts:111-157
    • 例子:remoteConnectionStatusreplBridgeConnectedreplBridgeSessionUrl
  • 3. 任务、MCP、plugin、通知等全局运行态 (📍 src/state/AppStateStore.ts:159-231
    • 例子:tasksagentNameRegistrymcp.toolspluginsnotifications
  • 4. 具体工具的会话级 UI / Runtime 状态 (📍 src/state/AppStateStore.ts:232-322
    • 例子:tungstenActiveSessionbagelUrlcomputerUseMcpStatereplContext.registeredTools
  • 5. 多 agent / worker 协调状态 (📍 src/state/AppStateStore.ts:323-384
    • 例子:teamContextinbox.messagesworkerSandboxPermissions
  • 6. 推测执行、plan mode、权限延续与 ultraplan 状态 (📍 src/state/AppStateStore.ts:385-452
    • 例子:speculationinitialMessagependingPlanVerificationdenialTrackingisUltraplanMode

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-50src/hooks/useReplBridge.tsx 一类异步连接逻辑
  • React 内写状态 :权限流、通知流和 plan mode 退出流通常通过 useSetAppState() 做函数式更新,例如恢复 toolPermissionContext、设置 initialMessage、推进通知队列
  • 非 React 工具运行时写状态toolExecution.tsMcpAuthTool.ts 等运行期代码通过 ToolUseContext.setAppState() 更新 mcp.clients / mcp.tools / mcp.commands 等状态,因此 Tool 体系和 UI 看到的是同一份会话快照

12.4 难点和设计取舍

难点 1:状态字段很多,而且混合了 UI、任务、工具、权限、bridge、plan mode,如何不把"会话状态"写成一堆分散的局部状态?

  • 问题AppState 覆盖的不是单一页面,而是整场会话的运行态:既有 prompt footer 这样的 UI 状态,也有 mcp.toolsreplContext.registeredTools、worker 权限请求、initialMessagependingPlanVerification 这种只有运行时才会出现的状态。如果这些状态按功能各自散落到组件或工具内部,就很难保证多个系统看到的是同一份真值
  • 方案------用 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()
  • 完整的设计理由和代码定位见 §12.2 中的"为什么 useAppState() / useAppStateStore() / ToolUseContext.getAppState() 有三种读法?"。

难点 3:权限模式、plan mode、ultraplan 等状态会被很多路径修改,怎样避免外部同步只在少数调用点发生?

  • 问题 :真正难的不是"改状态"本身,而是"改完之后别忘了触发后续动作"。例如 toolPermissionContext.mode 会被很多路径修改:快捷键、命令处理、bridge control request、plan mode 退出、权限对话框都可能改它。如果每条路径都要自己记得"再顺手同步外部 metadata / SDK / bridge",实现很快就会漏。
  • 方案------把职责拆成两步
    1. 任何调用点只负责改状态
    2. 状态真正改完后,由 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、遥测都落在同一层,概念跨度较大。
  • 部分章节天然横切ContextAppStateCommand 都不是纯单层对象,这也是必须拆专题文档的原因。

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 抽象接入。

十四、与传统工程相比,Claude Code 代表的 Agent 开发有什么异同

14.1 相同点:底层仍然是工程,不是"魔法"

如果把 Claude Code 去掉"模型"这一层,它依然是一套很典型的复杂工程系统:有分层、有边界、有状态、有生命周期,也有错误恢复和权限控制。

  1. 都需要稳定的分层与接口

    • 传统工程里,我们会把 controller / service / repository 或 port / adapter 分开。
    • Claude Code 里对应的是入口层、query() 编排层、Tool 层、服务层、基础设施层。
    • §7.2 里的 Tool 统一协议,本质上就是一个能力边界:上层不关心 BashToolAgentTool、MCP tool 的内部实现,只关心统一的 schema、权限检查和结果回流。
  2. 都需要明确的状态模型

    • 传统系统会区分 request-scoped、session-scoped、persisted state、cache。
    • Claude Code 在 §12.2 已经把这件事做得非常明确:turn-scoped、session-scoped、persisted / resumable、derived / cache 四层生命周期。
    • 这说明 agent 系统不是"想到哪做到哪",而是和普通工程一样,必须先定义状态边界,再讨论行为。
  3. 都需要编排与失败恢复

    • 传统后端会有请求管线、工作流引擎、补偿逻辑、重试策略。
    • Claude Code 的 query() 主循环(§5.3)承担的是同类职责:上下文拼装、模型调用、工具执行、结果回灌、错误恢复、继续下一轮。
    • prompt-too-longmax-output-tokensmedia-size-error 的恢复路径,本质上就是 agent 版的弹性设计。
  4. 都需要权限与安全边界

    • 传统工程强调认证、鉴权、最小权限。
    • Claude Code 同样如此,只是对象从"用户访问 API"扩展到了"模型调用工具"。checkPermissions()、运行模式裁剪、按轮刷新工具快照,都是工程化的安全边界,而不是 prompt 层面的软约束。

14.2 不同点:Agent 开发是在工程系统里接入了一个"不确定规划器"

Claude Code 和传统工程最大的不同,不是多了几个工具,而是系统核心多了一个概率性的、可自主规划下一步动作的执行者------模型。这会把很多原来不是一等公民的问题,变成架构核心。

  1. 接口不再只有代码接口,还包括 prompt 接口

    • 传统工程的接口主要是函数签名、HTTP schema、消息协议。
    • Claude Code 里,Tool.inputSchema 仍然重要,但还多了 Skillwhen_to_useallowed-toolsagent、system prompt 组装方式(§8.2、§11.2)。
    • 也就是说,prompt、frontmatter、工具描述文字,不再只是文档,而是可执行接口的一部分。
  2. 主流程不再是单次请求,而是多轮闭环

    • 传统请求大多是 input -> handler -> result
    • Claude Code 的核心是 messages -> model -> tool_use -> tool_result -> messages 的闭环(§3.2、§7.3)。
    • 这里的关键不是"调用了工具",而是工具结果会重新成为下一轮推理的输入,因此架构必须围绕闭环一致性设计。
  3. 上下文预算成为一等运行时资源

    • 传统工程当然也有缓存、吞吐、内存限制,但业务逻辑通常不直接围绕"上下文窗口"展开。
    • Claude Code 则必须把上下文预算写进主循环:toolResultBudget -> snip -> microcompact -> contextCollapse -> autocompact(§5.3)。
    • 这意味着"信息保留什么、压掉什么、何时摘要化"会直接改变系统行为,而不只是性能。
  4. 流式、并发与中断控制更靠近业务语义

    • query()AsyncGeneratorStreamingToolExecutor 允许边出 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 的系统",会低估它最有价值的部分。真正可迁移的,恰恰是这份文档里反复出现的工程能力:

  1. 边界建模能力可迁移

    • ToolSkillAgentContextAppState 分开,本质上是在做职责划分、依赖反转和抽象收口。
    • 这些能力同样适用于普通后端、前端状态管理、插件系统和工作流平台。
  2. 状态机与生命周期设计能力可迁移

    • §12.2 对 turn/session/persisted/cache 的划分,不只适用于 agent。
    • 任何复杂交互系统、协作系统、长任务系统,都需要这种生命周期分层能力。
  3. 异步编排能力可迁移

    • §5.3 里"启动和消费分离"的设计模式,本质上是高质量的异步流水线设计。
    • 它可以直接迁移到 SSE 服务、任务编排、批处理流水线、IDE 插件、远程执行框架。
  4. 契约优先能力可迁移

    • inputSchema、权限检查、结果映射、统一回流,都是 contract-first 思维。
    • 只是 Claude Code 把这件事从 API 扩展到了"模型可调用能力"。
  5. 故障恢复与降级设计能力可迁移

    • prompt-too-long、token 超限、权限拒绝、连接刷新这些恢复路径,看起来像 agent 特有问题,但底层能力其实是通用的:限流、降级、重试、补偿、兜底。
  6. 安全收口能力可迁移

    • Claude Code 没有把安全寄托在模型"自觉",而是落实为 tool visibility、permission check、mode gating、统一副作用出口。
    • 这和传统工程里做 capability control、sandbox、least privilege 是同一种能力,只是作用对象变了。

一句话总结:Claude Code 代表的 agent 开发,新增的是对"不确定规划器"的工程化约束;而真正决定系统上限的,仍然是传统工程里那些最硬的能力------抽象、状态、编排、契约、安全与恢复。

因此,对工程师来说,最值得投入的并不是某个 prompt 技巧,而是把这些能力迁移到 agent 场景中的方法论。

14.5 哪些能力不适合直接迁移

同样重要的是:并不是 Claude Code 里所有"看起来有效"的东西都能原样搬到别的工程里。

  1. 特定 prompt 文案和措辞技巧

    • Claude Code 的 system prompt、tool 描述、skill 文案,是和当前工具名、权限模型、运行方式一起调出来的。
    • 脱离这套上下文,原文照搬通常只会得到"像它,但不像它"的效果。
  2. 围绕当前产品形态形成的交互约定

    • 比如 REPL 流式输出、local JSX、权限弹窗、plan mode、命令卡片,这些都强依赖终端 TUI 产品形态。
    • 换成 IDE、Web、CI 或纯 API 服务后,很多交互约定都要重做,不能直接复制。
  3. 针对当前模型/API 调过的经验参数

    • 例如上下文压缩顺序、token 恢复阈值、skill prompt 的预算分配、流式执行节奏,这些都和当前模型能力、API 行为、成本约束强相关。
    • 方法论可以迁移,但具体阈值和策略必须重新验证。
  4. 项目内知识本体本身

    • CLAUDE.md、managed skills、项目 rules、agent frontmatter 里写的很多内容,本质上是当前组织和仓库的知识沉淀。
    • 可迁移的是"如何组织这些知识",不是知识内容本身。
  5. 依赖当前架构假设的细节实现

    • 比如按轮刷新工具快照、AppState 的组织方式、SkillTool 与 command 体系的耦合点,都建立在 Claude Code 现有分层上。
    • 如果目标系统的状态边界和执行闭环不同,这些实现细节就应该重设计,而不是照抄。

所以更准确的说法是:可迁移的是抽象方法、边界意识和工程套路;不可直接迁移的是 prompt 成品、产品交互和绑定当前架构的具体参数。

相关推荐
AI服务老曹1 小时前
源码级赋能:基于 Spring Boot 的 AI 视频管理平台二次开发指南与架构解耦实践
人工智能·spring boot·音视频
mit6.8241 小时前
记线下黑客松有感
人工智能
Jay-r2 小时前
AI、机器人、量子计算:大脑、身体与超级算力的三重奏
人工智能·机器人·量子计算·ai助手
砍材农夫2 小时前
spring-ai 第十tool调用
java·人工智能·spring
AIminminHu2 小时前
OpenGL渲染与几何内核那点事-项目实践理论补充(二-1-(5):最原始的“命令行”——从 printf 到实时控制台)
llm·agent·关键词
SteveSenna2 小时前
aubo i5+pika realsense+ACT训练完整流程
人工智能·学习·算法·机器人
张小泡泡2 小时前
Graph Retrieval-Augmented Generation: A Survey
论文阅读·人工智能·rag·graphrag
2401_832298102 小时前
OpenClaw×HappyHorse 深度融合:AI 视频自动化量产,重构内容生产范式
人工智能·安全
Allen_LVyingbo2 小时前
《狄拉克符号法50讲》习题与解析(上)
开发语言·人工智能·python·数学建模·量子计算