文章总结: 该书系统解析ClaudeCode代理系统的设计思想与工程实现,核心将其视为终端内的长期运行Agent运行时而非简单CLI工具。重点阐述会话状态机模型、工具池能力边界、分层启动架构及任务协作机制,强调在权限约束下实现可持续执行与多代理协作的工程方法论。 综合评分: 85 文章分类: 安全开发,解决方案,技术标准,安全工具,安全建设

codex codex
齐鲁师院网络安全社团
2026年4月5日 16:53 广东
阅读主线很简单:先建立心智模型,再进入会话内核与工具执行,随后讨论多代理协作、扩展系统与远程控制,最后回到工程方法论。
- 第一部分回答:Claude Code 到底是什么,它怎样从终端入口进入一个长期运行的代理系统。
- 第二部分回答:一次对话怎样被推进成状态变化、工具调用和权限决策。
- 第三部分回答:系统怎样委派工作、组织团队,并把短期上下文升级为长期记忆。
- 第四部分回答:系统怎样接入外部能力,并把本地代理延展到远端。
- 第五部分回答:复杂代理系统怎样观测、灰度、恢复,并最终沉淀为可迁移的方法论。
- 附录回答:怎样阅读恢复源码,以及怎样用 team-agent 工作流推进复杂任务。
1. User 2. | 3. v 4. CLI entry -> init ->QueryEngine 5. | 6. +--> context assembly 7. +--> tool pool / permissions 8. +-->AppState/Ink UI 9. +--> tasks / agents / team 10. +--> memory / MCP / remote 11. --> analytics / cleanup / feature gates
本章问题
这本书要解决两个现实问题:
- 为什么一个“看起来像 CLI 的工具”,需要被当作一个长期运行的代理系统来设计与实现?
- 如果你要做一个类似系统,哪些设计是“实现细节”,哪些是可迁移的方法论?
源码锚点
RESTORE_NOTES.mdsrc/entrypoints/cli.tsxsrc/main.tsxsrc/QueryEngine.tssrc/Tool.ts
源码图解:全书总图
1. User 2. | 3. v 4. CLI entry ----> init ---->QueryEngine 5. || 6. |+--> system prompt assembly 7. |+--> tool execution / permissions 8. |+-->AppState/Ink UI 9. |+--> tasks / agents / team 10. |+--> memory / MCP / remote 11. | 12. +--------------> analytics / cleanup / feature gates
先建立模型
把 Claude Code 读成“命令行上的聊天 UI”,你会很快陷入文件夹迷宫:命令、工具、任务、远程、记忆、权限、UI,每一块都像一个产品。
更稳的模型是把它当作一个终端里的 Agent 运行时:
- 输入不是一次性字符串,而是一段会话中的一次 turn。
- 输出不是一段文本,而是一组可执行行动的结果、状态变化和可追溯记录。
- “能力”不是写死的功能列表,而是一套可组合、可裁剪的工具集合(Tool pool)。
- “安全”不是一个弹窗,而是一个贯穿执行链路的策略层(权限、hooks、隔离)。
从这个模型出发,源码里的很多“看似琐碎”的设计会变得合理:为什么入口要做 fast-path,为什么系统要缓存上下文,为什么任务要有 ID,为什么要把记忆写回文件系统,为什么需要 team/task list 协议。
再看实现
本仓库是从 cli.js.map 恢复得到的源码树。它更像“可阅读的证据集”,而不是一个可以直接重新构建的工程:
- 可执行入口以
cli.js为准(见RESTORE_NOTES.md)。 src/的价值在于呈现设计边界与工程取舍:哪里做了延迟加载、哪里做了容错、哪里把复杂度从入口推到内核。
- 我们在书中尽量用“源码锚点 + 行为解释 + 工程权衡”的方式写作,而不是做逐行注释。
你会频繁看到三类“工程化信号”:
- 启动与装配:
src/entrypoints/cli.tsx、src/main.tsx、src/entrypoints/init.ts。 - 会话与执行:
src/QueryEngine.ts、src/query.ts、src/services/tools/toolOrchestration.ts、src/services/tools/toolExecution.ts。 - 边界与可演化:权限与隔离、feature gate、遥测与恢复性。
编程思想
这本书的写作准则也尽量向 Claude Code 的工程哲学对齐:
- 不从功能菜单写起,而从“系统要保证什么不变量”写起。
- 不把复杂性压在一个入口或一个大类里,而把它分解成稳定抽象:会话、工具、任务、上下文、扩展点。
- 不把成功路径当全部。失败、拒绝、降级、恢复,都是系统的一部分。
如果你只记住一句话:Claude Code 的核心不是“能回答”,而是“能在约束下持续执行并协作”。
源码练习
- 读
RESTORE_NOTES.md,写下“可运行入口”和“可阅读入口”分别是什么,以及它们在你的阅读策略里扮演什么角色。 - 打开
src/entrypoints/cli.tsx,只看注释与分支条件,画出它对外暴露的“启动形态”有哪些(例如--version、daemon、bridge 等)。 - 打开
src/Tool.ts,用一句话写下你认为 Tool 的“最小必要字段”是什么,以及为什么它必须带 schema 和上下文。
小结
本书会先建立 Claude Code 的整体心智模型,再进入 QueryEngine、上下文与工具执行等核心机制,最后回到协作、扩展与工程演进。阅读目标不是掌握某个 API,而是获得构建 agentic CLI 的设计语言和判断标准。
这一部分先不急着谈工具细节,而是先建立全书的坐标系。我们要回答 Claude Code 到底是什么、它如何启动、系统在回答前如何理解现场,以及为什么一个会话对象必须被当作事务来设计。
本章问题
Claude Code 的本质到底是什么?
如果把它当作“能运行一些命令的聊天工具”,你很难解释下面这些现象:会话跨轮次保存状态、工具调用的并发与串行划分、任务与后台生命周期、记忆落盘、远程/bridge 模式、以及 team/task list 协作协议。
本章要建立一个能覆盖这些现象的统一模型。
源码锚点
src/entrypoints/cli.tsxsrc/main.tsxsrc/QueryEngine.tssrc/query.tssrc/Tool.tssrc/tools.ts
源码图解:终端中的 Agent 运行时
1. User request 2. | 3. v 4. conversation turn 5. | 6. v 7. QueryEngine 8. | 9. +--> context 10. +--> tools 11. +--> tasks 12. +--> UI 13. --> collaboration
先建立模型
把 Claude Code 看成“终端里的 Agent 运行时”,会得到一套更稳定的解释框架:
- 会话是长期状态机:一次 turn 不是一次函数调用,而是一次事务推进。它可能包含多次模型调用、多次工具执行、以及恢复/压缩等控制逻辑。
- Tool 是能力边界:系统把“能做什么”外部化为工具池,并用 schema、权限和上下文把能力约束成可治理的接口。
- 执行不是线性的:系统会把同一条用户意图拆成多个阶段,穿插观察(读)与行动(写),并在必要时中断、恢复或降级。
- 终端 UI 不是输出设备:交互界面承担状态可视化与人机协作(授权、跟踪、回放),因此 UI 与执行链路共享同一套状态。
这个模型的关键收益是:你不需要为每一个“功能点”单独建解释,而是沿着“会话推进”这条主线理解一切。
再看实现
把模型落到源码上,可以看到三个“骨架”如何拼起来:
- 入口分流与装配
src/entrypoints/cli.tsx把启动路径拆成多条 fast-path,并用动态 import 推迟昂贵模块的加载。
src/main.tsx负责把 CLI 参数、配置、上下文、工具池、远程/MCP 等装配成一个可运行的交互系统。
- 会话内核
src/QueryEngine.ts把“一个会话”封装成对象:消息、缓存、权限拒绝、预算与 turn 之间的状态都由它持有。
src/query.ts承载 query loop 的具体推进方式:消息规范化、上下文拼接、工具执行、压缩与恢复路径等。
- 能力与边界
src/Tool.ts定义 Tool 的类型边界(schema、上下文、权限、进度与结果等)。
src/tools.ts组装工具池,并在环境与 feature gate 下做裁剪,保证模型“看到的能力集合”与实际执行一致。
一个重要细节是:入口并不是“业务逻辑集中地”,而是“复杂性隔离层”。复杂度被刻意沉到 QueryEngine、tool orchestration、权限与任务系统中,入口负责让这些系统以不同形态被复用(交互、SDK、daemon、远程等)。
编程思想
从这段实现中可以提炼出三条可以迁移的工程原则:
- 把自然语言当作“调度指令”,而不是“字符串输入”。
- 你要设计的是一个能持续推进的执行系统:可中断、可恢复、可观测,而不是一次性返回结果。
- 把能力显式化,把边界类型化。
- Tool 池是“能力代数”,schema 与上下文是“可治理的接口”。这让你能做权限决策、并发控制、遥测统计与扩展接线。
- 入口做薄,系统做厚。
- 入口越薄,系统越可演化。不同产品形态(REPL、SDK、远程)可以复用同一套内核,而不是复制粘贴流程。
源码练习
- 在
src/main.tsx中找出“装配型 import”与“执行型 import”的分界点:哪些模块是为了构建上下文/能力集合,哪些模块是为了真正开始一次对话。 - 阅读
src/tools.ts的工具池构建逻辑,回答:系统是如何保证“模型可调用的工具集合”与“运行时允许的工具集合”一致的? - 以
src/QueryEngine.ts为入口,列出一个 turn 在概念上会经历的阶段(上下文准备、用户输入处理、query loop、工具执行、状态回写等),写成你自己的状态机草图。
小结
Claude Code 最值得学习的地方不是某个工具实现,而是它把“会回答的模型”工程化为“可执行、可约束、可协作的终端运行时”。后续章节会沿着这条主线,逐个剖开启动装配、上下文工程与会话内核。
本章问题
为什么一个功能复杂、模块众多的代理系统,入口却要极端强调“最小装载”和“快速路径”?
更具体地说:
- 为什么
--version要做到几乎零导入? - 为什么许多路径用动态 import,而不是静态 import?
- 为什么一些环境变量必须在模块加载时设置,而不能等 init 之后?
这些看似“性能优化”的细节,往往决定了系统能否长期演进。
源码锚点
src/entrypoints/cli.tsxsrc/main.tsxsrc/entrypoints/init.tssrc/setup.tssrc/utils/startupProfiler.ts
源码图解:启动分流图
1. argv 2. | 3. +-->--version --------------->printandexit 4. +--> remote-control / bridge -> bridge path 5. +--> daemon ------------------> supervisor path 6. +--> background session -----> bg handler 7. -->default-----------------> main.tsx -> init -> REPL
先建立模型
把启动看成一次“装配事务”而不是“main 函数开始执行”,更容易理解 Claude Code 的取舍。
一个 agentic CLI 的启动要满足两类互相冲突的目标:
- 低延迟:用户输入
claude的那一刻,希望尽快看到可交互界面或至少看到明确反馈。 - 高完整性:系统又必须加载配置、权限、上下文、工具池、遥测、远程能力等,缺一块就可能在执行中崩溃或失控。
因此启动链路通常会被设计成“分层加载”:
- 能早返回的路径尽量早返回(fast-path)。
- 只有在确实需要时才加载重模块(lazy import)。
- 一些影响全局行为的开关必须在模块初始化之前生效(module-load-time decisions)。
再看实现
src/entrypoints/cli.tsx 的注释写得很直白:它是 bootstrap entrypoint,负责在加载完整 CLI 之前先检查特殊 flags,并尽量用动态 import 来减少 module evaluation。
它体现了几类典型 fast-path:
--version/-v直接输出版本并返回,几乎零导入。
--dump-system-prompt只加载生成 prompt 的必要模块,输出后退出。
- 某些服务模式(例如 daemon worker、bridge/remote-control)在入口层就完成分流,避免把主 REPL/UI 的依赖引进来。
这里有一个值得注意的工程点:部分环境变量的读取被注释强调“必须在 import-time 做决定”,因为某些工具会在模块顶层捕获配置(例如是否禁用后台任务)。这类约束会直接影响你的模块组织方式。
src/main.tsx 在文件顶部安排了多个“必须最先发生的副作用”,目标不是逻辑正确性,而是启动总时延:
profileCheckpoint记录启动剖析节点。
startMdmRawRead()、
startKeychainPrefetch()让子进程/系统调用与后续 JS 模块加载并行,从而把启动链路从串行变并行。
这类设计的关键在于:你把“不可避免的慢操作”尽量前置并异步发射,把 CPU 密集的模块加载与 I/O 等待重叠起来。
src/entrypoints/init.ts 中,init() 负责启用配置、应用安全的环境变量、初始化一些基础设施,并注册清理与降级路径。
其中一个重要思想是把“信任之前可做的事”与“信任之后才可做的事”分开:
applySafeConfigEnvironmentVariables()在信任对话之前执行。
- 遥测初始化通过
initializeTelemetryAfterTrust()这类函数延迟到信任建立之后。
这不是 UI 细节,而是系统边界:你的启动链路需要知道哪些事情会引入隐私/安全风险,哪些可以先做来提高体验。
src/setup.ts 处理 Node 版本检查、UDS 消息服务器、teammate snapshot、终端备份恢复、worktree/tmux 前置逻辑等。这些都属于“交互运行环境”的准备工作:不直接回答用户,但决定系统能否稳定运行。
编程思想
这章可以提炼出三条“代理系统启动哲学”:
- 启动链路是架构的一部分,不是性能补丁。
- 入口分流、动态 import、并行 I/O、信任边界,都会反向塑造你的模块边界与依赖图。
- 把系统分成多种启动形态,避免“一条路径吃掉所有复杂度”。
- fast-path 不是偷懒,而是让不同用例复用同一套内核但不共享全部成本。
- 让关键决策尽可能早地生效,避免“加载后才发现配置不对”。
- 一旦某些工具在模块顶层捕获配置,你就必须在入口层完成对应的开关设置,否则会出现不可预测的行为差异。
源码练习
- 只读
src/entrypoints/cli.tsx的分支条件与注释,画一张“启动路径决策树”。要求标出哪些路径会加载src/main.tsx,哪些不会。 - 在
src/main.tsx顶部找到那些“必须最先发生的副作用”,解释每一个副作用想覆盖的启动延迟来自哪里(I/O、子进程、模块加载等)。 - 阅读
src/entrypoints/init.ts,列出 init 里哪些动作属于“为可靠性做的恢复/清理/降级设计”,并说明它们为什么必须出现在启动阶段而不是运行中再补。 - 阅读
src/setup.ts,找出它对“交互 vs 非交互”的分支处理逻辑,写下你认为这种分叉对一个代理系统意味着什么边界。
小结
Claude Code 用大量 fast-path、动态 import 与并行化早期 I/O 来控制启动成本,同时用 init/setup 明确“信任、权限、交互环境”的边界。它在告诉你:启动不是入口文件的职责,而是系统工程的第一战场。
本章问题
在代理系统里,“回答之前先理解现场”不是礼貌,而是工程约束。
本章要回答:
- Claude Code 在发起一次 query 之前,究竟会准备哪些上下文?
- 这些上下文为什么要缓存、截断、降级或跳过?
- 为什么上下文不只是一段文本,而是一套可控的装配机制?
源码锚点
src/context.tssrc/utils/claudemd.tssrc/memdir/memdir.tssrc/QueryEngine.tssrc/main.tsx
源码图解:上下文装配流水线
1. cwd 2. +--> git snapshot 3. +--> CLAUDE.md discovery 4. +--> memory index 5. +--> current date 6. +--> settings / mode 7. | 8. v 9. system prompt parts 10. | 11. v 12. turn-ready context
先建立模型
“上下文工程”在这里不是提示词技巧,而是让系统具备可执行性的前置条件。
一个终端里的代理系统要做事,必须至少回答三类问题:
- 我在哪?当前工作目录、项目结构、是否在 git 仓库里。
- 现场状态如何?代码是否有未提交修改、最近提交是什么、当前分支是什么。
- 我应该遵循什么约束?项目约定(CLAUDE.md)、长期记忆(memory)、以及日期/时间等外部事实。
如果把这些都交给模型“自己猜”,你会得到不稳定的行为:重复运行 git status、反复扫描目录、把规则写进对话但无法复用,甚至因为上下文太大导致 token 爆炸。
因此,一个工程化的系统会把上下文当作“装配产物”,具备这些属性:
- 可缓存:同一会话内重复使用,避免重复 I/O。
- 可截断:有上限,有警告,保证稳定的成本边界。
- 可降级:在远程、非交互、bare 模式下选择性跳过。
- 可追踪:能解释“为什么这次上下文里有/没有某项信息”。
再看实现
src/context.ts 里 getGitStatus 很明确地把 git 状态描述为“conversation start snapshot”,并且对输出长度做了上限控制(例如截断到固定字符数,超出提示用工具再查)。
它还体现了几个工程化选择:
- 不是每次都取 git 状态:在某些模式下会跳过(例如远程或禁用 git instructions)。
- 一次取多项信息并行化:分支、main 分支、status、log、用户名通过
Promise.all一起拿,减少总等待。 - 失败不崩:捕获异常后记录日志并返回
null,让系统继续运行。
这类“可失败的上下文”非常重要:你不希望在没有 git 的目录里,系统直接因为上下文构建失败而无法工作。
同一个文件里的 getUserContext 处理 CLAUDE.md 的加载策略:
- 有硬关闭开关(环境变量)。
--bare下会跳过自动发现目录遍历,但会尊重用户显式指定的附加目录(“跳过你没让我做的,不跳过你让我做的”)。
- 会把读取到的 CLAUDE.md 内容缓存到全局状态里,给后续分类/权限等路径使用,避免依赖环。
此外,它把 currentDate 注入上下文,让模型拥有一个稳定的“今天”定义。这在涉及日志、计划、期限判断时很关键,因为你不希望模型用训练语料去猜日期。
src/memdir/memdir.ts 不是在做“把更多文本塞进上下文”,而是在定义一套可执行的记忆协议:
- 目录存在性由系统保证(
ensureMemoryDirExists),并把“目录已存在”写进提示,避免模型浪费步骤去mkdir或ls。 - entrypoint(例如
MEMORY.md)有行数与字节上限,超过会截断并附加警告,防止索引变成巨大上下文。 - 记忆按类型组织,明确哪些内容不应该保存(例如可从当前项目状态推导出的信息)。
这在工程上等价于:你把“长期记忆”从一种抽象能力,变成了文件系统上的协议与约束。
在 src/QueryEngine.ts 中,上下文并不是临时字符串拼接,而是通过 fetchSystemPromptParts 拿到 defaultSystemPrompt、userContext、systemContext 等结构化片段,然后组合成最终的 systemPrompt。
更细一点,它还处理了“自定义 system prompt + memory override”的特殊场景:当 SDK caller 提供自定义 prompt 且设置了 memory path override 时,会注入 memory mechanics prompt,让自定义 prompt 仍能使用记忆协议。
这背后是一个通用原则:上下文是能力的一部分,你不能让“换一种调用方式”导致系统行为断裂。
编程思想
从上下文工程这块,可以抽出四条可迁移的设计思想:
- 上下文要工程化,不要祈祷模型自觉。
- 把环境、约束、记忆、日期等信息当作系统输入,受缓存、上限、降级与容错控制。
- 用“快照”而不是“实时”解决一致性问题。
git status这种信息在一次 turn 内可能变化,但系统选择以会话起点快照为准,避免每轮上下文波动导致行为不可复现。
- 把“省步骤”写进提示,把“省成本”写进代码。
- 例如明确告诉模型目录已存在,同时在代码里确保目录真的存在。
- 上下文不是堆砌信息,而是定义协议。
- memory 的例子很典型:它不是“更多内容”,而是“如何写、写到哪、怎么组织、什么时候读”的协议化描述。
源码练习
- 阅读
src/context.ts的getGitStatus,找出它为了“稳定成本”做了哪些限制(并行化、截断、跳过、容错),并解释每个限制对应的失败模式是什么。 - 阅读
src/context.ts的getUserContext,解释--bare的语义:它不是“禁用所有上下文”,而是“禁用自动发现”。你会如何在自己的系统里实现类似语义? - 阅读
src/memdir/memdir.ts的truncateEntrypointContent与ensureMemoryDirExists,用一句话概括“为什么 prompt 里要写目录已存在”,以及“为什么代码里必须保证它真的存在”。 - 在
src/QueryEngine.ts中找到 system prompt 的组合逻辑,列出:默认 prompt、自定义 prompt、append prompt、memory mechanics prompt 之间的优先级与组合顺序。
小结
Claude Code 把上下文当作可控的装配产物:可缓存、可截断、可降级、可容错,并且把记忆写入协议化的文件系统结构中。这样系统才能从“会说”稳定地走向“会做”。
本章问题
很多人写 agent 系统的第一版,会把它写成一个函数:
- 拼 prompt
- 调一次模型
- 看到工具调用就执行
- 把结果再喂回模型
- 结束
这种实现能跑,但难以长期维护:无法稳定恢复、无法追踪成本、权限链路容易漏、上下文缓存难以一致、不同入口(REPL、SDK、后台)会出现行为分叉。
Claude Code 把这一切收敛到一个问题:怎样把一次会话的多轮 turn,组织成可控的事务推进?
源码锚点
src/QueryEngine.tssrc/query.tssrc/utils/queryContext.tssrc/memdir/memdir.tssrc/utils/processUserInput/processUserInput.ts
源码图解:一次 turn 的事务推进
1. submitMessage() 2. | 3. +--> processUserInput 4. +--> fetchSystemPromptParts 5. +--> query loop 6. | 7. +--> model response 8. +--> tool calls 9. +--> context update 10. --> usage / denial / persistence
先建立模型
把 QueryEngine 当作“会话对象”更准确:
- 一个 QueryEngine 对应一个 conversation。
- 每次
submitMessage()是一次 turn。 - turn 内部可能多次调用模型与工具,且需要维护跨阶段的不变量:
- 消息列表如何演化(追加、压缩、回放、插入系统消息)。
- 工具调用如何被授权、执行、归档、并反哺后续推理。
- 成本与预算如何累计与截断。
- 权限拒绝如何被记录并向 SDK 报告。
- 上下文与记忆如何被注入且保持一致。
这其实是在用“事务思维”写一个长期运行系统:不是追求一次调用成功,而是追求在多轮推进中保持系统可控。
再看实现
在 src/QueryEngine.ts 里,QueryEngine 构造函数把若干跨 turn 状态放进对象字段:
mutableMessages:会话消息存储。
readFileState:文件读取缓存(减少重复读取与 token 浪费)。
permissionDenials:权限拒绝记录(SDK 用)。
totalUsage:累计 usage 与成本相关数据。
discoveredSkillNames、
loadedNestedMemoryPaths:用于控制“发现/注入”类行为不重复、可追踪。
这意味着:系统把“会话资产”外部化了,而不是把所有东西临时堆在局部变量里。
submitMessage() 并不急着调用模型。它先做几件“会话级一致性”的工作:
- 决定主模型与 thinking 配置(有默认策略)。
- 通过
fetchSystemPromptParts拿到defaultSystemPrompt、userContext、systemContext等片段,再组合成最终systemPrompt。 - 处理“自定义 system prompt + memory override”的特殊接线:如果外部调用者用自定义 prompt,系统仍会按条件注入 memory mechanics prompt,保证能力不缺失。
- 如果存在 structured output 工具与 json schema,会注册结构化输出的 enforcement hook,让系统级约束进入执行链路。
这些步骤的共同点是:把“系统规则”与“调用入口差异”在 QueryEngine 里收敛,避免上层入口各写一套。
QueryEngine 会包装 canUseTool,在权限不允许时把拒绝信息记录到 permissionDenials,用于后续 SDK reporting。这是典型的“把边界事件当作一等数据”。
如果你把权限检查散落在各工具里,你会很难做到一致的拒绝记录、遥测、以及对外接口稳定性。
QueryEngine 构建 processUserInputContext,把 getAppState/setAppState/abortController/readFileState 等执行所需上下文打包,然后调用 processUserInput() 做输入规范化、slash command 处理、工具允许列表裁剪等预处理。
真正的 query loop 在 src/query.ts:它负责把消息规范化并拼接 user/system context,发起 API 请求,执行工具调用(通过 tool orchestration),处理压缩/恢复/预算等控制逻辑。
这是一种非常典型的分层:
- QueryEngine 负责会话对象的边界与一致性。
- query.ts 负责一次 turn 内的状态推进细节。
例如 orphaned permission 的处理在 QueryEngine 中有 “only once per engine lifetime” 的标记与状态位。这类逻辑如果散落在 query loop 中,很容易在恢复或重试路径里被重复触发。
编程思想
QueryEngine 的价值不在于“把代码放进一个类”,而在于它体现的几条系统化思想:
- 用对象边界管理长期状态,用函数推进短期状态。
- 会话资产(消息、缓存、拒绝记录)归 QueryEngine;单次推进细节归 query loop。
- 把“装配”与“推进”拆开。
- 先装配上下文与规则,再开始执行。这让不同入口复用同一套执行逻辑。
- 把边界事件当作一等数据。
- 权限拒绝、结构化输出 enforcement、记忆注入去重,这些都是系统可靠性的重要信号。
- 让恢复与重试路径遵守同一套不变量。
- “只做一次”的动作要显式标记,否则恢复会把系统变成随机行为。
源码练习
- 以
src/QueryEngine.ts的submitMessage()为主线,写一份“turn 推进清单”:在调用query()之前,系统完成了哪些装配与一致性工作? - 在
src/QueryEngine.ts中找出systemPrompt的拼装顺序,回答:为什么 memory mechanics prompt 需要在“自定义 system prompt”场景下仍能被注入? - 追踪
canUseTool的包装逻辑,写下:系统在“拒绝”时记录了什么数据,为什么这些数据不应该由某个具体工具自行记录? - 在
src/query.ts的导入列表中,找出与“预算、压缩、恢复、工具执行”相关的模块,按你理解的执行顺序排列,并说明哪些属于控制平面,哪些属于数据平面。
小结
QueryEngine 让 Claude Code 从“一次性脚本式 agent”升级为“可控的会话事务系统”:上下文与规则先装配,权限与边界可观测,长期状态有归属,短期推进有专职。这是后续理解工具编排、任务系统与多代理协作的前置条件。
当心智模型建立以后,问题就从“它是什么”转向“它怎样运行”。这一部分讨论状态、工具池、工具编排与权限边界,核心目标是解释 Claude Code 怎样把一句自然语言指令推进成一串可控动作。
本章问题
Claude Code 既是 CLI,也是一个长时间运行的交互程序。问题是:在终端里,为什么还要引入 React/Ink 这类“UI 框架”的思路?更具体地说,它为什么需要一个中心化的 AppState,以及一个类似“外部 store + 订阅”的状态机制?
源码锚点
src/state/AppState.tsxsrc/state/AppStateStore.tssrc/state/store.tssrc/ink.tssrc/state/teammateViewHelpers.ts
源码图解:终端 UI 的状态投影
1. tools / tasks / input / remote events 2. | 3. v 4. AppStateStore 5. | 6. +----------+-----------+ 7. || 8. useAppState(selector) setState(updater) 9. || 10. +---------------------->Ink components
先建立模型
把 Claude Code 的 UI 先当成一条“可视化管线”,而不是“打印输出”。
- 输入不是“命令行参数”,而是持续到来的事件流:用户键入、模型流式输出、工具进度、权限对话、后台任务通知、远程连接状态变化。
- 输出不是“写 stdout”,而是把系统当前状态投影成一个终端界面:提示输入框、历史消息区、任务面板、权限弹窗、teammate transcript 等。
- UI 与执行不是两套系统:同一轮 turn 内,工具执行、权限决策、任务状态更新都会立刻影响 UI。
因此,UI 的核心需求变成了两件事:
- 让“状态”成为一等公民,并可被多个组件读取。
- 让 UI 更新的成本可控。终端渲染开销不小,不能每次状态变化都全量重渲。
再看实现
Claude Code 用了一个很朴素但工程上非常有效的结构:外部 store + 选择器订阅 + Object.is 比较。
createStore()是最小 store:
getState()、setState(updater)、subscribe(listener)。setState只在Object.is(next, prev)不相等时通知订阅者。这里的取舍是明确的:不提供 reducer/immer 等“豪华能力”,把复杂性放到上层的 state 结构与 updater 里。见src/state/store.ts。AppStateProvider只创建一次 store,并通过 context 提供。关键点是:Provider 本身不靠 props 触发重渲,消费者用
useSyncExternalStore订阅 slice。见src/state/AppState.tsx。useAppState(selector)强约束 selector:不要返回新对象,否则
Object.is总是变化导致组件持续重渲。这是典型的“用约束换性能”,也等于把性能规则写进 API。见src/state/AppState.tsx。AppStateStore.ts把
AppState的形状集中定义出来:工具权限上下文、任务 registry、MCP 连接状态、插件状态、通知队列、elicitation 队列、teammate transcript 选择态等,都汇聚到一个“运行时真相源”。这使得 UI 不需要跨组件拼状态,也更容易做“恢复”和“切换视图”。见src/state/AppStateStore.ts。ink.ts做了一个重要封装:所有渲染入口统一包一层
ThemeProvider。它把“设计系统依赖”从调用方移走,避免每个渲染点都要记得 mount 主题。见src/ink.ts。- 一些 UI 行为不是组件局部状态,而是显式写入
AppState。例如 teammate transcript 的进入/退出与 retain/evictAfter,涉及任务对象的生命周期管理,这是 UI 和任务系统的交叉点。见src/state/teammateViewHelpers.ts。
编程思想
- 把终端当作“可刷新视图”,不是“输出设备”。一旦系统里存在并发任务、流式消息、权限交互,“打印”就不够了,必须让 UI 能持续表达系统状态。
- 用最小 store 把状态治理成本降到最低。
createStore()没有魔法,反而利于跨环境复用(REPL、headless、SDK 入口)以及调试。 - 用 API 约束强制性能规则。
useAppState(selector)要求 selector 不要创建新对象,本质上是把“渲染性能”从**实践升级为硬约束。 - UI 生命周期要和任务生命周期对齐。teammate transcript 的 retain/evictAfter 说明:当任务可能结束、被杀死、被后台化时,UI 必须有明确的资源回收策略,否则“状态泄漏”会变成 UI 卡顿和内存膨胀。
- 主题与渲染“横切关注点”必须集中封装。
ink.ts的 withTheme 是典型的横切收口:避免每个调用点各自为政。
源码练习
- 阅读
src/state/store.ts,解释它为什么用Object.is而不是===。然后找一个NaN的例子说明差异。 - 阅读
src/state/AppState.tsx,写出一条团队约定:在什么情况下 selector 可以返回对象,在什么情况下必须拆成多个 selector? - 阅读
src/state/teammateViewHelpers.ts,解释retain和evictAfter分别解决什么问题。思考:如果没有PANEL_GRACE_MS会出现什么 UI 体验问题? - 阅读
src/ink.ts,把withTheme()的封装替换为“调用方自行包 ThemeProvider”,列出至少 2 个实际工程风险。
小结
Claude Code 在 UI 上的核心思想不是“用 React 写漂亮界面”,而是:当一个终端程序进入“长运行、多并发、多交互”的状态后,UI 本质就是状态投影。外部 store、切片订阅与严格约束,让它既能实时响应任务与工具的变化,也能把渲染成本压到可控范围。
本章问题
Claude Code 的“能力”不是写死在模型里,也不是靠提示词凭空变出来的,而是由一组 Tool 组成的指令集提供的。问题是:
- 为什么要把系统能力表达为“工具池”,而不是直接把功能写进主循环?
- 为什么每个 Tool 都要有 schema、权限语义、上下文依赖和进度/结果结构?
- 为什么工具的集合需要支持动态裁剪(feature gate、环境检测、权限上下文)?
源码锚点
src/tools.tssrc/Tool.tssrc/services/tools/toolExecution.tssrc/services/tools/toolOrchestration.tssrc/tools/AgentTool/AgentTool.tsx
源码图解:工具池形成过程
1. base tools 2. + feature gates 3. + env gates 4. + MCP tools 5. + skills / commands 6. + permission filters 7. | 8. v 9. visible tool pool 10. | 11. v 12. model-visible capability set
先建立模型
把 Tool 看成一种“能力代数”(capability algebra)。
- 系统的原子能力是 Tool:读文件、写文件、跑命令、调用 Web、启动子代理、列 MCP 资源等。
- 一个请求的完成不是“模型输出一段文字”,而是“模型选择一串能力并执行”。这串能力可以被编排、被审计、被拒绝、被回放。
- Tool 不仅是函数,还是一份契约:
- 输入契约:schema,定义什么是合法输入。
- 行为契约:副作用范围、并发安全、权限需求。
- 结果契约:输出结构、错误分类、进度事件。
当 Tool 成为契约,系统就能做三件关键事:
- 把能力边界从 prompt 中抽出来,让安全与工程约束落在运行时。
- 让工具执行过程可观测、可中断、可恢复。
- 让系统扩展(MCP、plugin、skills)不再改主循环,而是“增加工具项”。
再看实现
getAllBaseTools() 把所有可能的工具集中定义成一个列表,再通过 feature gate、环境变量、可用性判断去裁剪。这个列表是“系统能做什么”的唯一真相源。见 src/tools.ts。
这里有三个明显的工程动机:
- 死代码消除(DCE)。大量工具通过
feature()或process.env条件 require,保证外部构建可以剔除不适用模块。 - 循环依赖切断。比如 TeamCreateTool、SendMessageTool 用 lazy require,避免
tools.ts互相引用形成环。见src/tools.ts。 - 运行时“最小暴露”。即使系统内部实现了很多工具,也要根据环境与权限上下文决定是否暴露给模型。暴露面越小,安全与可控性越强。
ToolUseContext 把一次 turn 的“运行时环境”集中在一个对象里:工具列表、命令、MCP 客户端、debug/verbose、thinkingConfig、以及 getAppState/setAppState 等。工具不是在真空里执行,而是必须在同一上下文里协作。见 src/Tool.ts。
注意 ToolUseContext 里有一些“设计性字段”:
refreshTools?():工具集合可能在 turn 中途变化,例如 MCP 连接后新增工具。这意味着工具池不是静态常量,而是可以被“热更新”的能力集合。
setInProgressToolUseIDs、
setResponseLength:工具执行过程会影响 UI 与交互节奏,执行层要能把状态回传给 UI。handleElicitation?():MCP 工具触发 URL elicitation 时,执行层需要一个环境相关的处理器(REPL vs SDK),这是把交互差异封装为上下文能力。
执行层(见 src/services/tools/toolExecution.ts)承担了大量非业务责任:
- 错误分类与 telemetry 安全:例如
classifyToolError()处理 minified build 下的构造函数名不可用问题,用稳定字段替代。 - 权限与 hooks:工具调用前后需要跑 hooks、权限决策、被拒绝时生成可解释的反馈。
- 进度与 tracing:启动 span、结束 span、blocked-on-user 等状态要被记录,保证可观测性。
这说明:Tool 的价值不只是“做事”,还包括“让做事这件事可控”。
编程思想
- 能力必须显式化。把“会做什么”做成工具池,等于把系统能力从隐式 prompt 变成显式接口。
- 契约优先于实现。schema、并发语义、权限语义、结果结构这些都属于契约;没有契约,系统就无法安全地扩展、审计和恢复。
- 工具集合是可裁剪的产品面。feature gate 和环境检测不是产品开关那么简单,它决定模型“能看见什么能力”,这比“能调用什么能力”更重要。
- 执行层是系统的“法庭”。它负责判定是否允许执行、如何记录、如何解释、如何回滚状态影响,而不是让每个 Tool 自己处理这些横切问题。
- 扩展要走统一总线。MCP/skills/plugin 进入系统后,最终都要落成工具、命令、资源这几类结构,才能被主循环统一编排。
源码练习
- 阅读
src/tools.ts,列出 5 种“工具进入工具池”的路径(直接 import、feature gate require、env require、lazy require、通过 MCP 动态引入等),并说明每种路径解决的工程问题。 - 阅读
src/Tool.ts的ToolUseContext,从“跨 turn 的状态”和“turn 内的状态”两个角度给它分组,解释为什么要把它们放在同一个对象里。 - 阅读
src/services/tools/toolExecution.ts的classifyToolError(),解释它为什么要避免依赖error.constructor.name,以及这和“可观测性”有什么关系。 - 找到一个具体 Tool(例如
src/tools/FileReadTool/FileReadTool.ts或src/tools/BashTool/BashTool.tsx),只从它的 schema 与 description 推断其“边界”,写出你认为它必须被权限系统管控的原因。
小结
工具池把 Claude Code 变成一种“可编排能力系统”:模型负责选择与组织能力,运行时负责校验、授权、执行与记录。这个分工让系统既能扩展,又能控制;既能并发提速,又能保持一致性。
本章问题
当模型一次输出多个 tool call 时,系统如何执行它们?
- 什么时候应该并发执行,什么时候必须串行?
- “只读工具可以并发”这句话在工程上怎么落地?
- 并发执行下,如何保证上下文一致性,以及如何把进度与结果稳定地回流到对话?
源码锚点
src/services/tools/toolOrchestration.tssrc/services/tools/toolExecution.tssrc/Tool.tssrc/utils/generators.ts
源码图解:工具编排策略
1. tool_use blocks 2. | 3. partitionToolCalls() 4. | 5. +--> read-only batch ----> concurrent run 6. --> mutating batch -----> serial run 7. | 8. v 9. context modifiers
先建立模型
把一次 turn 里的工具调用看成一段“小型事务脚本”。
- 输入是一串
ToolUseBlock(模型提出的调用意图)。 - 输出是另一串 message(工具结果、错误、进度)以及一个“更新后的上下文”。
- 并发的难点不是“同时跑多个任务”,而是:并发会打乱结果顺序、引入共享状态竞争、造成不可预测的 UI/上下文变化。
因此,“并发”在 Claude Code 里不是默认策略,而是一种被严格限定的优化。
系统要解决的核心矛盾是:
- 读型工具越并发越快。
- 写型工具越并发越容易把系统带进不可恢复的状态。
再看实现
partitionToolCalls() 把工具调用分割成多个 batch,每个 batch 要么是:
- 单个非并发安全工具(串行)。
- 多个连续的并发安全工具(并发)。
并发安全的判定不靠“工具名硬编码”,而是靠 tool 的 isConcurrencySafe(input),并且在解析失败或抛异常时默认不安全。这是“保守正确”的策略:错判为不并发只是慢,错判为并发可能乱。见 src/services/tools/toolOrchestration.ts。
并发 batch 的执行流程有一个关键细节:它允许工具产生 contextModifier,但不立刻应用,而是先把 modifier 收集起来,等并发 batch 全部执行完,再按原始 block 顺序依次应用。见 runTools() 的 queuedContextModifiers 逻辑,位于 src/services/tools/toolOrchestration.ts。
这实际上做了一个重要的工程折中:
- 工具执行可以并发提速。
- 上下文演化仍然保持单线程、确定性顺序。
如果不这么做,哪怕工具本身“只读”,它的执行层也可能通过上下文回调影响 UI(例如 in-progress 标记、notification),这会产生不可预测的交互体验。
串行 batch 里,每个工具执行完成后,如果有 contextModifier,立刻更新 currentContext,然后继续下一步。见 runToolsSerially(),位于 src/services/tools/toolOrchestration.ts。
并发度由 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 控制,默认 10。见 getMaxToolUseConcurrency(),位于 src/services/tools/toolOrchestration.ts。
这意味着系统明确承认:
- 并发会消耗资源(CPU、IO、网络、MCP 连接、甚至模型注意力)。
- 并发并不总是收益最大,尤其当工具结果会影响后续决策时。
runToolUse() 来自执行层 toolExecution.ts,它负责权限、hooks、telemetry、tracing、错误分类与结果存储。编排层只负责:
- 分批(并发安全)。
- 调度(并发/串行)。
- 合并结果(contextModifier 的确定性应用)。
这种分层让系统可以独立优化并发策略,而不污染 Tool 的实现。
编程思想
- 并发的基本单位不是“任务”,而是“对一致性影响可控的批次”。先分批再并发,是一个非常可复用的并发范式。
- 把不确定性隔离出来。并发执行带来的不确定性被限制在“结果到达时间”,而上下文修改顺序保持确定性,这让调试与回放更可行。
- 保守判定是安全系统的默认态。
isConcurrencySafe的异常直接降级为串行,是“宁慢勿乱”的系统哲学。 - 并发上限要成为配置,而不是编译常量。系统部署环境不同(本地终端、远程容器、CI、弱网),并发**值也不同。
源码练习
- 阅读
src/services/tools/toolOrchestration.ts,用你自己的话解释queuedContextModifiers为什么要按toolUseID分组,并在 batch 结束后按原顺序应用。 - 设想一个工具 A、B 都被标记为并发安全,但它们都会触发
toolUseContext.setInProgressToolUseIDs。解释为什么这类“UI 状态更新”也需要确定性收口。 - 把
partitionToolCalls()改成“把所有并发安全工具都放到同一个 batch”,推演一个可能的坏结果(例如:读写混排、上下文被错误假设、用户体验变差)。 - 阅读
src/services/tools/toolExecution.ts,列出至少 3 个执行层负责但编排层不负责的横切能力,并说明分层的价值。
小结
Claude Code 的并发策略不是“尽可能并发”,而是“在不破坏上下文一致性的前提下并发”。它用分批、保守判定、确定性上下文演化三件事,把并发从风险源变成可控的性能优化。
本章问题
一个能跑 shell、能写文件、能连接远程与 MCP 的代理系统,最大风险不是“做不到”,而是“做过头”。问题是:
- Claude Code 如何把权限控制做成运行时策略层,而不是一堆零散弹窗?
- 权限决策如何同时考虑规则、模式(auto/plan)、hook、classifier、sandbox、工作目录等因素?
- 为什么权限系统要和工具执行紧耦合,而不是“工具内部自检”?
源码锚点
src/utils/permissions/permissionSetup.tssrc/utils/permissions/permissions.tssrc/hooks/useCanUseTool.tsxsrc/services/tools/toolExecution.tssrc/Tool.ts
源码图解:权限决策链
1. tool request 2. | 3. +--> mode 4. +--> rules 5. +--> hooks 6. +--> classifier / sandbox 7. | 8. +--> allow 9. +--> ask 10. --> deny
先建立模型
权限系统在 Claude Code 里不是“允许/拒绝”这么简单,它更像一个“决策管线”。
- 输入:一次 tool call(工具、输入、上下文、assistant message、toolUseID)。
- 输出:一个
PermissionDecision,并可能带有“更新后的 input”(例如经 sandbox/overlay 重写后的路径)与“决策原因”。 - 决策必须可解释:系统要能告诉用户为什么 ask/deny,来自哪条规则、哪个模式、哪个 hook 或 classifier。
更关键的是:权限不仅保护用户,也保护系统本身的可持续演进。
- 权限结果进入 telemetry,反过来影响产品的默认策略。
- auto mode、classifier、危险规则剔除等机制让系统能在更自动化的同时保住边界。
再看实现
ToolPermissionContext(见 src/Tool.ts)被挂在 AppState 里(见 src/state/AppStateStore.ts,虽然本章不展开),这意味着:
- 权限模式与规则变化需要驱动 UI(例如 /permissions 菜单、通知)。
- 权限不是静态配置,而是会随会话与用户操作变化的运行时状态。
permissionSetup.ts 负责把来自设置文件、环境、组织策略等来源的规则装载并应用到 ToolPermissionContext。同时,它还承担“把危险规则剔除”的工作,例如 auto mode 下对 Bash/PowerShell 的危险 allow 规则判定。见 src/utils/permissions/permissionSetup.ts 中的 isDangerousBashPermission、isDangerousPowerShellPermission 等函数。
这里的工程思想很直接:
- 不相信用户写的规则一定安全,系统要为自动化模式做“安全上界”。
- 危险规则的判断不是黑名单堆砌,而是围绕“能否绕过 classifier 做任意代码执行”来定义。
permissions.ts 提供了 hasPermissionsToUseTool(在 useCanUseTool 中被调用)以及规则解析、来源展示、ask/deny message 构建、sandbox manager 等。它把多来源规则(settings、session、cliArg 等)归并成同一套判定逻辑。见 src/utils/permissions/permissions.ts。
细节上,它还强调“解释性输出”:
createPermissionRequestMessage()会根据
decisionReason(rule/hook/classifier/mode 等)生成不同的解释文本。- 对复合命令(subcommandResults)会抽取需要批准的子操作,避免用户批准一个“看不懂的整体”。
useCanUseTool 是“决策结果落入交互”的桥。它做了几件关键事:
- 统一入口:无论 tool 是什么,调用前都走同一个
CanUseToolFn。 - 先尝试非交互决策:如果已有规则 allow/deny,就直接返回,不打断用户。
- 需要 ask 时,根据运行模式(interactive/coordinator/swarm worker)选择不同 handler,尽量减少对用户的无谓打扰。见
src/hooks/useCanUseTool.tsx。
这对应一个真实的产品约束:背景 agent、远程 worker 往往没有能力弹 UI,因此“权限决策”必须能够在不同运行形态下仍然成立。
toolExecution.ts 会把权限决策、hook 执行、tracing、telemetry 织在一起,形成一次 tool use 的完整生命周期。权限拒绝会触发:
- 正确的 UI message(解释拒绝原因)。
- 正确的 tracing span 结束方式(例如 blocked-on-user)。
- 正确的统计与日志(避免泄露敏感信息)。见
src/services/tools/toolExecution.ts。
编程思想
- 权限是运行时策略层,不是 UI 组件。UI 只是策略的一个呈现渠道,策略必须能在 headless/worker 场景运行。
- 决策必须可解释,否则自动化无法扩张。
decisionReason的体系是“可解释自动化”的底座。 - 自动化越强,越需要“安全上界”。auto mode 下的危险规则剔除不是体验优化,而是防止权限规则把系统的安全机制短路。
- 权限必须嵌入工具执行生命周期。把权限做成工具内部自检,会导致策略分裂、日志缺失、难以统一审计。
- 多来源规则需要统一归并,否则系统会变成“用户以为允许了,但实际上没允许”的灰色地带。
源码练习
- 阅读
src/utils/permissions/permissionSetup.ts,解释isDangerousBashPermission()把哪些规则视为危险,并说明这些规则如何可能绕过 classifier。 - 阅读
src/utils/permissions/permissions.ts,跟踪createPermissionRequestMessage()的分支,列出至少 5 种不同的decisionReason.type,并解释每种类型对应的系统来源。 - 阅读
src/hooks/useCanUseTool.tsx,解释它为什么要区分handleCoordinatorPermission、handleInteractivePermission、handleSwarmWorkerPermission。你会如何在 SDK/print 模式下复用这套决策? - 阅读
src/services/tools/toolExecution.ts,挑一个 telemetry 或 tracing 相关的逻辑点,解释“为什么权限决策必须被记录”,以及记录时如何避免泄露敏感输入。
小结
Claude Code 的权限系统不是“问用户要不要执行”,而是把工具执行变成一条可治理的管线:规则与模式定义边界,hook/classifier 提供自动化审查,执行层负责把决策落成可解释、可观测、可恢复的运行时行为。
本章问题
一旦 Claude Code 支持后台 agent、远程 agent、多 tool 执行和 teammate transcript,系统就会出现大量“进行中的工作”。问题是:
- 如何让“正在做什么”成为一等对象,而不是散落在日志与消息里?
- 为什么后台任务、前台任务、远程任务需要统一抽象?
- 任务对象如何与 UI 生命周期、输出持久化、清理策略绑定?
源码锚点
src/Task.tssrc/tasks.tssrc/tasks/types.tssrc/tasks/LocalAgentTask/LocalAgentTask.tsxsrc/state/teammateViewHelpers.ts
源码图解:任务生命周期
1. spawn 2. | 3. v 4. pending -> running -> completed 5. ||| 6. |+--> failed | 7. | --> killed | 8. | 9. outputFile + offsets + notifications + cleanup
先建立模型
把 Task 看成系统的“工作单元”,它比一次 tool call 更粗粒度。
- tool call 是一个动作,通常短、小、可在一轮内完成。
- task 是一个过程,可能跨多个动作,持续更久,并且需要 UI 持续呈现。
在 agentic CLI 里,任务模型至少要解决四件事:
- 身份:每个任务需要稳定 ID,便于引用、追踪和持久化。
- 生命周期:pending/running/completed/failed/killed,且要能定义“终态”用于回收。
- 输出:长任务的输出不应只靠内存消息列表,必须有落盘或可增量读取的策略。
- 控制:任务要能被中止(abort/kill),且中止的语义必须明确。
再看实现
TaskType 把任务类型分成 local_bash、local_agent、remote_agent、in_process_teammate、local_workflow、monitor_mcp、dream 等。TaskStatus 定义统一状态机。见 src/Task.ts。
isTerminalTaskStatus() 是一个小但关键的函数:它把“可回收”变成显式判断,避免系统继续向已结束任务注入消息或保留 UI 资源。见 src/Task.ts。
generateTaskId(type) 生成带前缀的短 ID,并使用大小写不敏感安全字母表。注释里明确提到“抵抗 symlink 攻击”。这体现了一个现实约束:任务输出落盘到固定目录时,ID 不是纯装饰,它影响路径与安全。见 src/Task.ts。
TaskStateBase 包含:
id/type/status/description:识别与呈现。
startTime/endTime/totalPausedMs:计时与统计。
outputFile/outputOffset:输出持久化与增量读取。
toolUseId:把 task 关联到某次 tool use(例如 AgentTool 启动一个 agent task)。
createTaskStateBase() 把 output path 的计算收口到 getTaskOutputPath(id),避免每类任务重复实现。见 src/Task.ts。
getAllTasks() 和 getTaskByType() 的模式与 tools.ts 类似:集中注册、按 feature gate 条件加载、避免循环依赖。见 src/tasks.ts。
这背后同样是“产品面可裁剪”的哲学:不是所有构建形态都需要 workflow/monitor 等任务类型。
当任务是 local_agent 并且展示 transcript 时,系统会把 retain 打开,阻止 eviction;当退出视图或任务终止时,再释放回 stub 形态并设置 evictAfter。见 src/state/teammateViewHelpers.ts。
这说明 Task 不只是后台执行,它也是 UI 的内容源。只要 UI 可切换视图,就必然需要“任务数据的驻留策略”。
src/tasks/types.ts 用 TaskState union 把所有任务状态类型汇总,并提供 isBackgroundTask() 这样的通用谓词,方便 UI 在不关心具体任务类型的情况下做过滤与展示。
编程思想
- 把工作做成对象,而不是副作用。Task 是“系统正在做什么”的数据结构化表达,便于 UI、恢复、统计、调试统一依赖。
- 统一状态机比功能差异更重要。不同任务的执行细节可以差异化,但状态机必须统一,否则用户与 UI 永远在“猜测”系统状态。
- ID 与安全是同一个问题。只要任务输出会落盘,ID 就不能随便;注释提到 symlink 攻击说明这是被真实威胁驱动的设计。
- 输出持久化是任务抽象的一部分。
outputFile/outputOffset让系统可以不依赖内存消息列表,避免长会话下的内存压力,并支持 attach/logs 等能力。 - UI 资源回收必须由“终态”驱动。
isTerminalTaskStatus与evictAfter的组合是典型模式:既要展示“刚完成的任务”,又不能无限驻留。
源码练习
- 阅读
src/Task.ts的generateTaskId(),解释为何使用 8 字节随机数与 36 字母表,以及注释里提到的安全威胁场景是什么。 - 阅读
src/tasks/types.ts的isBackgroundTask(),解释它为什么要检查status与isBackgrounded。推演一个“前台任务”如何变成“背景任务”的 UI 语义。 - 阅读
src/tasks.ts,把它与src/tools.ts做对比,总结“集中注册 + feature gate require + 避免环依赖”这套模式的共性与价值。 - 阅读
src/state/teammateViewHelpers.ts,描述一个 local_agent 任务从启动到结束,相关 UI 状态(viewingAgentTaskId、retain、evictAfter)如何演化。
小结
在 Claude Code 里,Task 模型把“长期工作”从消息与日志中抽离出来,变成可追踪、可控制、可回收的对象。它是 UI 可靠呈现、后台执行、远程协作与安全落盘的共同底座。
单个 QueryEngine 解决的是一次会话如何推进,多代理系统解决的是工作如何拆分、委派、隔离和协同。这一部分关注 AgentTool、Team 模式与记忆系统,解释 Claude Code 怎样把“一个能做事的代理”升级成“一个能组织工作的系统”。
本章问题
Claude Code 里最像“自举”的能力不是 Bash、不是编辑文件,而是 AgentTool。它把“把任务交给另一个工作者”变成一等能力:可以同步跑一个子代理,也可以把一个代理放到后台,甚至在 Team 模式里直接拉起一个可被命名寻址的 teammate。
本章要回答的问题是:
- 为什么 AgentTool 必须是一种 Tool,而不是“内部函数调用”或“线程池”?
- 子代理、后台代理、teammate、worktree/remote 隔离,这些看似不同的形态,如何被统一到同一条生命周期里?
- 一个会递归调用自身能力的系统,怎样避免失控和爆炸式复杂度?
源码锚点
src/tools/AgentTool/AgentTool.tsxsrc/tools/AgentTool/agentToolUtils.tssrc/tools/AgentTool/runAgent.tssrc/tasks/LocalAgentTask/LocalAgentTask.tsxsrc/tasks/RemoteAgentTask/RemoteAgentTask.tsxsrc/tools/shared/spawnMultiAgent.tssrc/utils/worktree.tssrc/utils/forkedAgent.tssrc/utils/teammate.ts
源码图解:委派路径总览
1. AgentTool.call() 2. | 3. +--> sync subagent 4. +--> background agent 5. +--> teammate spawn 6. +--> worktree isolation 7. --> remote agent
先建立模型
先把 AgentTool 抽象成一个工程模型,不看任何实现细节。
- Agent 是一种“可控的执行体”
在传统 CLI 里,子过程(subprocess)是最常见的扩展方式:你把字符串交给 shell,等待一个退出码。在 agentic CLI 里,这远远不够,因为“做事”不再是单一步骤,而是一个多轮推理与行动的循环。
所以这里的 Agent 是一个具备以下属性的执行体:
- 有自己的消息输入输出(它跑的是一段“对话主循环”,不是单次函数)。
- 有自己的工具池(能力边界可裁剪、可继承、可受权限影响)。
- 有自己的生命周期(可前台、可后台、可中止、可观测)。
- 需要被隔离(worktree/remote/cwd override),以降低副作用外溢。
- AgentTool 是“递归调用”的边界
把 Agent 启动做成 Tool 的关键收益是:递归调用不会绕开系统治理层。
- 权限仍然统一走
canUseTool决策链。 - 进度仍然以 tool progress / task progress 的方式进入 UI/SDK。
- 预算、最大轮次、任务输出文件等约束仍然可被追踪与回收。
- 多形态统一为一个“启动协议”
从用户视角看,AgentTool 可能产生三类“结果”:
- 同步完成:直接返回结果。
- 异步启动:返回一个可跟踪的 agentId / outputFile。
- teammate 启动:返回可寻址的 name、pane 信息、team_name 等。
这些并不是“功能堆叠”,而是一个统一协议在不同环境下的不同分支:同样的输入(描述 + prompt + 运行模式)被映射到不同的执行后端。
再看实现
实现上,AgentTool 的代码并不试图“优雅”,它优先满足两个目标:可演进、可控。
- 输入 schema 本身就是产品开关
AgentTool.tsx 用 Zod 构造输入 schema,并且在 lazySchema() 里做 feature gate 裁剪:某些字段在某些构建或 gate 下会被 .omit() 移除,让模型根本看不到这些能力。这个做法很工程化:当能力不可用时,最好的防线不是“调用时报错”,而是“接口不存在”。见:
src/tools/AgentTool/AgentTool.tsx
- “teammate 启动”是一个早退分支
在 call() 里,如果当前在 team 上下文(teamName)且传入了 name,AgentTool 会走 spawnTeammate() 分支,直接把“启动一个可被 SendMessage 寻址的工作者”变成结果返回。它还显式阻止“teammate 再 spawn teammate”,理由不是技术限制,而是协作协议:team roster 是平的,嵌套 teammate 会破坏溯源与管理。见:
src/tools/AgentTool/AgentTool.tsxsrc/tools/shared/spawnMultiAgent.tssrc/utils/teammate.ts
- 隔离不是细节,而是第一类参数
AgentTool 把隔离作为输入的一部分(例如 worktree、remote、cwd override),并通过 createAgentWorktree()、removeAgentWorktree() 等机制把“副作用边界”工程化。隔离的意义不是“方便并行”,而是“把危险控制在一个可回收的容器里”。见:
src/utils/worktree.tssrc/tools/AgentTool/AgentTool.tsx
- 后台与前台的差异是“可交互性”
后台 agent 的核心差异不是线程或进程,而是“它不能依赖当前 UI 的交互”。这会反向影响权限模式、提示与回传方式。AgentTool 里既有显式的 run_in_background,也有“超时自动后台”的策略(例如若开启 gate/环境变量,超过一段时间自动转后台)。见:
src/tools/AgentTool/AgentTool.tsxsrc/tasks/LocalAgentTask/LocalAgentTask.tsx
- 进度与结果必须可被折叠与回放
AgentTool 并不直接“打印输出”,而是把子代理的进度与 shell 进度转发为统一的 tool progress 流,让上层 UI/SDK 可以在同一协议下消费。这里的重点是:系统把“可观测性”当作语义的一部分,而不是日志。见:
src/tools/AgentTool/agentToolUtils.tssrc/tasks/LocalAgentTask/LocalAgentTask.tsx
编程思想
- 递归能力必须被同一套治理层包起来:把 Agent 启动做成 Tool,才能自动继承权限、进度、预算、日志与回收。
- “接口不存在”比“调用时报错”更安全:feature gate 通过 schema 裁剪,从根上控制能力曝光。
- 多代理协作不是“多线程”,而是“多会话”:每个 agent 都是在运行一个小型的对话引擎,需要被显式建模、显式回收。
- 隔离是工程默认值,不是高端功能:worktree/remote/cwd override 的存在,是为了让副作用在设计时就可控。
- 协作协议要写进系统,而不是写进文档:禁止 teammate 嵌套 spawn teammate,是在用代码维持团队模型的简单性。
- 进度是产品能力:把进度事件作为协议输出,才能在 UI、SDK、日志系统里保持一致的用户体验。
源码练习
- 阅读
src/tools/AgentTool/AgentTool.tsx,梳理call()的分支图:同步子代理、异步后台、teammate spawn、fork path、remote path。把每个分支的“输入条件”和“输出类型”写成一张表。 - 阅读
src/tools/shared/spawnMultiAgent.ts,找出“哪些会话参数会被继承给 teammate”(例如权限模式、模型、插件目录等)。思考:哪些参数不应该被继承?如果要新增一个需要继承的参数,你会把它接到哪里? - 阅读
src/utils/worktree.ts与src/tools/AgentTool/AgentTool.tsx,整理一次 worktree 隔离的生命周期:创建、执行、清理。思考:如果子代理 crash 了,清理路径在哪里兜底?
小结
AgentTool 的核心价值不是“多叫一个模型”,而是把“委派”变成系统可治理的能力。它把递归执行包装进 Tool 协议,让权限、隔离、观测、协作都能沿着同一条工程主线演进。理解 AgentTool,基本就理解了 Claude Code 作为“终端中的 Agent 运行时”最关键的一层。
本章问题
当系统从“一个代理帮你写代码”演进到“多个代理并行做事”,最大风险不是模型能力不够,而是协作变成噪声:重复劳动、相互覆盖、无人认领、状态不同步、上下文丢失。
Team 模式要解决的是一个工程问题:在同一套终端界面里,让多个工作者遵循一套可执行的协作协议,而不是靠“大家注意沟通”。
本章要回答的问题是:
- 为什么多代理协作不能只靠 prompt,而必须外部化成任务与消息协议?
- Team、TaskList、SendMessage 三者分别承担什么职责?
- 如何把“并行”从随机并发变成可控流水线?
源码锚点
src/tools/TeamCreateTool/prompt.tssrc/tools/TeamCreateTool/TeamCreateTool.tssrc/tools/TaskListTool/prompt.tssrc/tools/SendMessageTool/prompt.tssrc/tools/shared/spawnMultiAgent.tssrc/utils/swarm/teamHelpers.tssrc/utils/teammateMailbox.ts
源码图解:团队协作协议
1. team config <--> members 2. | 3. v 4. task list <--> owner / status / deps 5. | 6. v 7. SendMessage<--> leader / teammates 8. | 9. v 10. idle notification ->next assignment
先建立模型
先定义一个最小但可运行的协作模型。不要急着讨论 tmux、pane、UI 细节。
- Team 是“协作边界”,不是聊天室
Team 的核心语义是:一组成员共享同一份任务清单(task list)与寻址空间(按 name 寻址),并且系统负责把成员消息自动送达。
换句话说,Team 是一种“有共享状态的多 agent 运行时”,而不是一个“多人对话窗口”。这一点会直接影响后续设计:
- 需要一个持久化、可查询的共享状态源(TaskList)。
- 需要一个统一的寻址与投递机制(SendMessage)。
- 需要一个最小但强约束的工作流(谁可以领任务、如何标记完成、如何广播)。
- TaskList 是“系统真相”,消息只是事件
消息能告诉你“我做完了”,但无法告诉你“现在整个项目处在什么状态”。任务清单能回答这类问题:还有哪些任务未做、谁在做、哪些被阻塞、依赖关系是什么。
因此一个成熟的多 agent 系统会把 TaskList 作为“系统真相”,而把消息当作“提醒与协调的事件流”。这就是为什么 TeamCreate 的 prompt 里直接把 “Team = TaskList” 写成 1:1 对应关系。
- SendMessage 是“显式通信”,不是隐式打印
在 Claude Code 的协作语义里,“你在屏幕上打出来的话”并不会被其他 agent 自动看见。要通信必须走工具(SendMessage),这相当于要求开发者在协作系统里保持“显式 side-effect”的习惯:把协作视作一次可审计的操作。
这会显著降低协作系统的隐性耦合:输出与沟通分离,沟通走协议,协议能被记录、转发、节流、甚至跨会话转接。
再看实现
- TeamCreate 把协作的两个实体一次性落盘
从 prompt 描述看,TeamCreate 会创建:
~/.claude/teams/{team-name}/config.json~/.claude/tasks/{team-name}/
这种“团队配置 + 任务清单同构创建”的做法,等价于把协作协议外部化到文件系统:团队是谁、任务有哪些、状态如何,不依赖当前进程内存。见:
src/tools/TeamCreateTool/prompt.tssrc/tools/TeamCreateTool/TeamCreateTool.ts
- TaskList prompt 强制“按 ID 优先”是一种反混乱策略
TaskList 的 prompt 明确要求“prefer tasks in ID order”。这不是 UX 建议,而是工程策略:在并行系统里,最容易出现的失败是“大家都挑喜欢的做”,导致关键前置没人做、依赖没人解。
用一个简单规则(按 ID 顺序)就能把随机并行转成可预测的流水线,降低协调成本。见:
src/tools/TaskListTool/prompt.ts
- SendMessage prompt 把“命名寻址”写进协议
Team 模式强制按人类可读的 name 寻址,而不是 UUID。这是对协作系统的一个约束:系统会为成员提供稳定名字,消息依赖名字,任务 owner 也依赖名字。它让沟通更像项目管理,而不是底层 RPC。见:
src/tools/SendMessageTool/prompt.ts
- teammate spawn 的工程细节:继承与约束
spawnMultiAgent.ts 里有一个关键动作:构造“继承的 CLI flags”。它会把 leader 的关键设置(权限模式、--model、settings 路径、插件目录等)传播给 teammate,这样多个工作者不会形成“各自为政的配置孤岛”。见:
src/tools/shared/spawnMultiAgent.ts
同时,AgentTool 会禁止“teammate 再 spawn teammate”(团队 roster 平铺),这属于协作约束的一部分:通过禁止一种结构,保持团队拓扑简单,避免出现不可追踪的层级树。见:
src/tools/AgentTool/AgentTool.tsx
编程思想
- 多代理协作首先是“状态管理问题”,其次才是“并行计算问题”。因此必须有 TaskList 这种共享真相源。
- 协作协议要外部化:把 team config、task list 放在文件系统里,才能跨进程、跨会话、跨崩溃恢复。
- 简单规则胜过复杂调度:按任务 ID 优先是一种低成本的确定性策略,减少协调开销与死锁。
- 显式通信优于隐式输出:SendMessage 强制把沟通作为工具调用,让沟通可审计、可追踪、可中继。
- 拓扑约束是一种设计力量:禁止嵌套 teammate,换取更清晰的 ownership 与溯源。
源码练习
- 阅读
src/tools/TeamCreateTool/prompt.ts与src/tools/TaskListTool/prompt.ts,把 Team 的“协作协议”整理成 10 条以内的强约束规则,然后判断这些规则分别由“系统实现”还是“prompt 约定”来保证。 - 阅读
src/tools/SendMessageTool/prompt.ts,解释为什么它强调“plain text 输出不会被其他 agent 看见”。如果你要做一个“自动同步关键输出到团队频道”的功能,你会把它接到 Tool 层还是 UI 层?为什么? - 阅读
src/tools/shared/spawnMultiAgent.ts,列出被继承的 CLI flags,并思考其中哪一项是“安全相关”的(例如权限模式)。如果 teammate 必须强制 plan mode,你会如何保证它不会继承 bypass?
小结
Team 模式的关键不在“能同时跑多个 agent”,而在“把协作变成可执行的协议”。TaskList 提供共享真相,SendMessage 提供显式通信,spawn 逻辑提供一致的继承与约束。多代理系统能否稳定工作,取决于这些协议是否足够硬,而不是取决于“每个 agent 都很聪明”。
本章问题
在 agentic 系统里,“上下文”天然是短期资源:受 token 上限约束、受压缩策略影响、受多轮对话漂移影响。Claude Code 的回答不是“塞更多上下文”,而是把一部分信息外部化为文件系统里的长期记忆,并且把写入/读取规则工程化。
本章要回答的问题是:
- 为什么把记忆做成文件与索引,而不是“每轮都总结塞回 system prompt”?
- Memory 与 Session Memory 有什么边界?一个是长期事实库,一个是本会话的演进笔记。
- 怎样把“可用性”和“成本控制”同时写进记忆系统?
源码锚点
src/memdir/memdir.tssrc/memdir/paths.tssrc/memdir/memoryTypes.tssrc/memdir/memoryScan.tssrc/memdir/findRelevantMemories.tssrc/services/SessionMemory/sessionMemory.tssrc/services/SessionMemory/sessionMemoryUtils.tssrc/services/SessionMemory/prompts.ts
源码图解:长期记忆文件结构
1. memory/ 2. | 3. +--> MEMORY.md (index) 4. +--> user_*.md 5. +--> project_*.md 6. +--> reference_*.md 7. --> feedback_*.md 9. load -> truncate -> prompt 10. write -> update index -> future recall
先建立模型
先定义 Claude Code 的“记忆”到底是什么。
- 记忆是可回放的外部状态
记忆的目标不是帮助模型“想起来”,而是帮助系统“稳定地记住”。当对话结束、进程退出、上下文被压缩,记忆仍然存在,并且在新会话里可以被重新加载。
这解释了为什么记忆落在文件系统上:文件系统是所有进程共同可见的、可审计的、可版本化(至少可被 git/备份系统捕捉)的持久层。
- Index 不是内容,Index 是路由表
如果把所有记忆都堆进一个大文件,会立即碰到三个工程问题:
- 加载成本不可控(每次都要读大文件)。
- 内容更新容易冲突(多人协作时更明显)。
- 很难做“相关性检索”(文件粒度太大)。
所以这里的设计更像“路由表 + 分片内容”:MEMORY.md 是索引,每条记忆是一个独立文件;索引只放指针和一行 hook,不放正文。
- Session Memory 是另一种“可压缩的运行日志”
Memory(memdir)更像长期事实库,强调 taxonomy 和可复用;Session Memory 更像一份本对话的动态笔记,强调自动提取与更新频率控制。
二者的差异可以总结成:
- Memory:手工或半手工维护,生命周期跨会话,内容应稳定、可复用。
- Session Memory:系统后台维护,生命周期以会话为主,内容允许更“过程化”。
再看实现
- 记忆系统先解决“加载上限”,再谈“写入格式”
memdir.ts 明确限制 MEMORY.md 的最大行数与最大字节数,并提供 truncateEntrypointContent():先按行截断,再按字节截断,并在尾部追加警告。这里体现的不是“文本处理技巧”,而是成本控制:让任何一次加载都具有确定上界。见:
src/memdir/memdir.ts
- 写入规则写进 prompt,避免模型浪费回合
buildMemoryLines() 会把一套非常具体的写入流程写进提示词:两步保存(写文件 + 更新索引)、索引每行长度建议、避免重复记忆、不要把可推导信息写入记忆等。它甚至在文案里明确禁止“先 mkdir / 先 ls 检查目录是否存在”,因为系统会在后台确保目录存在。见:
src/memdir/memdir.tssrc/memdir/memoryTypes.ts
这是一种很典型的 agent 工程思想:把“减少无意义行动”的约束写进系统提示,降低工具调用噪声。
- Session Memory 的核心是“后台、阈值、可复用上下文”
sessionMemory.ts 的注释直接点出它的定位:后台周期性运行,用 forked subagent 提取关键信息,不打断主对话。触发条件则是阈值控制:初始化阈值、两次更新间的 token 增长阈值、工具调用阈值,以及“上一轮是否有 tool call”的安全条件。见:
src/services/SessionMemory/sessionMemory.tssrc/services/SessionMemory/sessionMemoryUtils.ts
这里的工程取舍很明确:
- 不追求“每轮都总结”,而是追求“在自然断点总结”。
- 不追求“最完整”,而是追求“最少打扰 + 可控成本”。
- 安全与正确性:文件读写缓存要能被打破
Session Memory 在更新前会主动删除 readFileState 的缓存条目,避免 FileReadTool 的去重逻辑返回 file_unchanged 之类的 stub。这个细节很小,但体现出一个现实:当系统有多层缓存时,正确性往往来自“知道如何失效缓存”。见:
src/services/SessionMemory/sessionMemory.ts
编程思想
- 把记忆外部化成文件系统,是为了让“系统记住”而不是“模型记住”。这让记忆可审计、可迁移、可跨会话复用。
- 索引是一种成本控制手段:路由表负责“可发现性”,分片文件负责“可维护性”。
- 上限必须是硬的:行数、字节数、触发阈值,是把系统从“无限增长”拉回“可控运行”的关键。
- 把操作规程写进 prompt,是减少无效工具调用的最便宜方式。对 agent 系统而言,流程约束就是性能优化。
- 自动化写作必须有节流:Session Memory 的阈值策略,体现的是“后台维护”的工程纪律,而不是“更聪明”。
源码练习
- 阅读
src/memdir/memdir.ts,解释truncateEntrypointContent()为什么要同时做行截断与字节截断。写下你认为最可能触发“字节截断但不触发行截断”的真实场景。 - 阅读
src/memdir/memoryTypes.ts,总结它明确禁止写入哪些类型的内容(尤其是“可从项目状态推导”的内容)。思考:为什么这些内容不应该进入记忆系统? - 阅读
src/services/SessionMemory/sessionMemory.ts的shouldExtractMemory(),把触发条件写成布尔表达式,并解释“为什么最后一轮有 tool calls 时要谨慎触发提取”。 - 阅读
src/services/SessionMemory/sessionMemory.ts的setupSessionMemoryFile(),解释它为什么要删除toolUseContext.readFileState的缓存项。给出一个你能想到的 bug:如果不删会发生什么?
小结
Claude Code 的记忆体系不是“把上下**大”,而是“把上下文拆层”:长期事实进入 memdir(索引 + 分片文件),会话演进进入 Session Memory(后台提取 + 阈值控制)。这两层共同回答了一个工程问题:当对话变长、工具变多、并行发生时,如何让系统既能记得住,又不会被自己的记忆拖垮。
当系统已经具备会话内核、工具协议和协作机制之后,下一步就是面对真实世界:外部服务、远程执行、网络失败和产品治理。这里的主题不再是单个功能,而是扩展点与控制面的统一设计。
本章问题
一个 agentic CLI 的真实边界不是“模型能不能写代码”,而是“系统能不能接入外部世界”:工单系统、代码索引、浏览器、IDE、内部 API、企业策略。
Claude Code 的答案是把外部能力统一接入到 Tool/Command/Resource 这三种形态里,并通过 MCP(Model Context Protocol)把“外部系统”变成可被调度的能力集合,同时让连接生命周期、权限、错误处理、热更新都能进入同一套工程框架。
本章要回答的问题是:
- 为什么扩展不能只是“更多工具”,还需要命令与资源的统一视图?
- MCP 连接管理如何兼顾动态配置、重连、错误去重、UI 状态同步?
- 如何在不修改主循环的前提下,把新增能力接进系统?
源码锚点
src/services/mcp/client.tssrc/services/mcp/useManageMCPConnections.tssrc/services/mcp/MCPConnectionManager.tsxsrc/services/mcp/types.tssrc/services/mcp/utils.tssrc/services/mcp/config.tssrc/Tool.tssrc/tools.ts
源码图解:扩展能力总线
1. MCP server 2. | 3. transport (stdio / http / ws / sse / sdk) 4. | 5. client.ts 6. | 7. +--> tools 8. +--> commands 9. --> resources 10. | 11. v 12. unified runtime bus
先建立模型
- 扩展的最小合同:能力必须“可描述、可调用、可治理”
把外部能力接入一个 agentic 系统,最怕两件事:
- 能力不可描述:模型不知道它是什么、怎么用、有什么限制。
- 调用不可治理:权限、速率、错误、重试、日志都各搞一套。
所以系统必须给扩展一个最小合同(contract):至少要能映射成 Tool(可调用)、Command(可发现/可交互)、Resource(可读取/可引用)中的一种或几种,且能进入统一的权限与执行链路。
- MCP 把“连接”变成一等对象
在很多系统里,连接只是 transport 细节:你拿到一个 client,然后调用。
在 Claude Code 里,连接是状态机:可能是 pending、connected、needs-auth、failed、disabled。这些状态不仅影响可用性,还影响 UI 呈现、提示词可见的工具集合、错误通知策略、重连策略。见:
src/services/mcp/types.ts
- 扩展总线不是“插件 API”,而是“能力宇宙的合并规则”
当系统同时有:
- 内置 tools(Bash、Read/Write、Agent 等)
- skills(本地 markdown/目录驱动)
- plugins(可能提供 MCP servers)
- MCP servers(提供 tools/commands/resources)
关键问题变成:这些能力如何被合并成“一个可供模型使用的工具池”?以及当某个扩展变化(插件 reload、MCP 工具列表变化)时,系统如何把变化传播到正在运行的会话?
再看实现
- MCP 的 transport 不是单一实现,而是一个矩阵
从 types.ts 可以看到 MCP server config 支持多种 transport:stdio、sse、http、ws、sdk 等。扩展系统在设计上接受“连接方式多样”,但要求它们统一汇入同一类 client 行为与错误语义。见:
src/services/mcp/types.tssrc/services/mcp/client.ts
- client.ts 把“外部工具”映射成内部 Tool
client.ts 的职责不是简单发请求,它要完成几个转换:
- 将 server 暴露的工具列表拉取并裁剪(例如描述长度上限)。
- 将 MCP tool call 的错误分类成可被上层安全记录的类型(例如
McpAuthError、McpToolCallError)。 - 处理会话过期等协议细节(例如 HTTP 404 + JSON-RPC code -32001 的组合)。
这些都说明:系统不把 MCP 当作“一个 SDK”,而是当作“能力接入层”,需要承担稳定性与可观测性责任。见:
src/services/mcp/client.ts
- useManageMCPConnections.ts 是“连接生命周期的编排器”
在交互式终端里,连接管理本质是“状态同步问题”:连接变化来自网络 I/O,UI 状态变化来自 React store。useManageMCPConnections() 做了几类工程动作:
- 批量 flush:把短时间内多次连接更新合并成一次
setAppState,避免 UI 抖动。 - 错误去重:对 plugin errors 建 key,避免同一错误刷屏。
- 重连策略:指数退避的自动重连,且限制最大次数。
- 通知订阅:处理 tool/resource/prompt list changed 之类的通知并触发刷新。
它更像一个“事件驱动的连接控制器”,而不是一个 hook 小工具。见:
src/services/mcp/useManageMCPConnections.ts
- utils.ts 解决“归属与清理”问题
一旦系统允许动态启停 MCP server,就必须能回答“哪些 tools/commands/resources 属于这个 server”。utils.ts 用命名约定(例如 mcp__
)与 normalize 规则建立这种归属关系,并提供清理/排除工具,甚至提供 config hash 来判断是否需要重连。见:
src/services/mcp/utils.ts
- ToolUseContext 预留了“刷新工具池”的入口
ToolUseContext 的 options 里有 refreshTools?: () => Tools。这类设计很关键:当外部世界变化(MCP 连接建立、工具列表更新),系统需要一种可控方式把“最新工具集合”反映到后续模型调用里,而不用重启会话引擎。见:
src/Tool.ts
编程思想
- 扩展系统的核心是“合并与治理”,不是“提供更多入口”。Tool/Command/Resource 三者统一,才能在 UI 与主循环里有一致体验。
- 连接必须被建模为状态机:
pending/connected/needs-auth/failed/disabled让 UI、提示词、权限与重连策略都有清晰挂载点。 - 把协议边界的错误语义提炼成内部错误类型,是稳定性的前提。否则系统只能靠字符串匹配兜底。
- 动态系统需要明确的归属与清理策略:命名约定 + normalize + config hash,保证“能加也能删,能改也能重连”。
- 性能来自批处理与去重:批量 flush AppState、去重错误通知,避免终端 UI 被网络抖动拖垮。
源码练习
- 阅读
src/services/mcp/types.ts,画出 MCP server config 支持的 transport 类型矩阵,并为每种 transport 写下你认为最容易踩坑的失败模式(例如认证、断线、会话过期)。 - 阅读
src/services/mcp/client.ts,定位isMcpSessionExpiredError()的判定逻辑。解释为什么它需要同时检查 HTTP 404 和 JSON-RPC code -32001,而不是只看其中一个。 - 阅读
src/services/mcp/useManageMCPConnections.ts,找出“批量 flush AppState”的实现位置,并解释为什么这里不能用queueMicrotask之类更激进的批处理方式,而选择时间窗口。 - 阅读
src/services/mcp/utils.ts,跟踪excludeStalePluginClients()如何识别 stale client(dynamic scope 缺失、config hash 变化),并说明这对/reload-plugins的正确性意味着什么。
小结
MCP 与插件扩展的价值不在“接进更多能力”,而在“把外部能力纳入同一套运行时治理”。Claude Code 用连接状态机、归属清理规则、批处理状态同步、错误语义归一化,把动态的外部世界变成一个可控的能力总线。对于任何要做 agentic 产品的人,这是比“写更多工具”更关键的一步。
本章问题
Claude Code 的“远程化”不是把本地输入转发到云端这么简单。它要回答三个更尖锐的问题:
- 一个本地终端里的长期会话,怎样在远端继续跑,但仍然可控?
- 权限提示、取消、重连、会话不存在等“控制面”问题,应该放在什么层解决?
- Bridge/Remote Control 为什么需要一整套资格校验、策略限制与后台心跳,而不是一条 WebSocket?
这一章的目标是建立一个可迁移的心智模型:远程执行系统要显式区分“数据面”和“控制面”,并把失败当作常态来设计。
源码锚点
src/remote/RemoteSessionManager.tssrc/remote/SessionsWebSocket.tssrc/entrypoints/cli.tsxsrc/bridge/bridgeEnabled.tssrc/bridge/bridgeMain.ts
源码图解:远程控制面与数据面
1. Local UI 2. | 3. | \ control plane 4. | 5. | v 6. |RemoteSessionManager 7. || 8. |+-->SessionsWebSocket 9. |+--> permission flow 10. | --> HTTP send 11. +<--------event/ result stream
先建立模型
把远程会话想成“分裂的运行时”:
- 本地仍然负责交互:读用户输入、渲染 UI、决定如何响应权限请求。
- 远端负责执行:模型推理与工具调用在远端发生,结果以事件流形式回传。
但远程系统真正难的是控制面。你必须为远端运行时提供几类控制能力,它们都不等价于“业务消息”:
- 权限请求与响应:远端要执行 Bash/写文件时,可能需要本地用户批准。
- 取消与中断:本地 Ctrl+C/Escape 要不要中断远端?
- 重连语义:网络闪断时是“尽力而为继续”,还是“立即宣告会话死亡”?
- 观测与诊断:错误要可归因,但不能泄露敏感信息。
因此,一个成熟的远程设计通常会落到两条通道:
- 数据面:连续的对话/事件流。
- 控制面:权限、取消、ack、协议升级等。
Claude Code 在恢复源码里,很明显把“控制消息”当成一等公民,而不是夹在普通消息里靠约定解析。
再看实现
RemoteSessionManager 明确声明它管理的东西包含三块:WebSocket 订阅、HTTP 发送、权限流。它做了一件很关键的事:先把消息分类,再决定路由。isSDKMessage() 是一个小但很工程化的分界线,它把控制消息挡在普通消息回调之外。见 src/remote/RemoteSessionManager.ts。
权限流在远程场景下有两个现实约束:
- 远端发起请求,本地做决策。
- 请求可能会被服务器取消,本地 UI 必须能同步撤销。
源码里用 pendingPermissionRequests: Map
保存挂起请求,并在收到 control_cancel_request 时清理并回调 onPermissionCancelled。这不是“锦上添花”,而是远程系统要避免 UI 悬挂、状态泄露的必要条件。见 src/remote/RemoteSessionManager.ts。
SessionsWebSocket 体现的是另一个工程现实:远程连接会断,断了之后你需要一套可预期的重连策略,同时要识别“永久拒绝”和“暂时失败”。关键点有:
- 重连参数是常量,不是散落在各处的 magic number:
RECONNECT_DELAY_MS、MAX_RECONNECT_ATTEMPTS、PING_INTERVAL_MS。见src/remote/SessionsWebSocket.ts。 - 关闭码分层:
PERMANENT_CLOSE_CODES里有4003(unauthorized)这种永久拒绝,直接停止重连。见src/remote/SessionsWebSocket.ts。 - 对
4001 (session not found)做了有限次数重试,注释里解释原因:compaction 期间服务端可能短暂认为 session stale。也就是说,客户端用“业务语义”解释了一个网络错误码,并把它固化成策略。见src/remote/SessionsWebSocket.ts。 isSessionsMessage的判断刻意宽松:只要有字符串
type就接受,把兼容未知消息类型的责任交给下游。这样做的取舍是:宁可让新消息类型进入日志/诊断,也不要被旧客户端静默丢弃。见src/remote/SessionsWebSocket.ts。
如果你写过分布式系统,你会发现这些看似“细碎”的条件分支,其实就是系统可用性的边界。
cli.tsx 的 fast-path 里把 remote-control/rc/remote/sync/bridge 作为一条独立的启动路径,并在进入 bridgeMain 前做了多层校验:OAuth token、GrowthBook gate、版本门槛、组织策略限制。见 src/entrypoints/cli.tsx。
这说明 Bridge 在产品定义上不是“调试功能”,而是“远程控制能力”,它必须回答“谁被允许用、在什么组织策略下能用”。这部分细节集中在 bridgeEnabled.ts:
isBridgeEnabled()是非阻塞判断,读取缓存 gate。
isBridgeEnabledBlocking()在需要公平性的地方会阻塞等待 gate 刷新,避免 stale false 把用户挡在门外。
getBridgeDisabledReason()不是返回 boolean,而是返回可执行的诊断信息,指导用户重新登录或补齐 profile scope。见
src/bridge/bridgeEnabled.ts。
真正的 Bridge 主循环在 bridgeMain.ts 里,你能看到一个“长期运行的 supervisor”需要的典型构件:
- 大量 backoff 配置与 give-up 窗口,区分连接类错误与一般错误。见
src/bridge/bridgeMain.ts。 - 心跳
heartbeatWork与 token 过期后的reconnectSession重投递,注释解释了如果不做会发生 PEL 卡死 (work ACK 后永远不再派发)。这已经是标准的队列语义问题,不是 WebSocket 细节。见src/bridge/bridgeMain.ts。 - 多会话 spawn gate,以及容量唤醒
capacityWake,说明系统把吞吐当成第一等指标在设计。见src/bridge/bridgeMain.ts。
Bridge 的信息量很大,但你可以只抓一个核心:它把远程化当成“运行时治理”,而不是“传输层”。
编程思想
- 把控制面变成类型与回调,不要靠字符串约定塞在普通消息里。
control_request/control_cancel_request的存在,本质上是在为可控性付结构化成本。见src/remote/RemoteSessionManager.ts。 - 远程系统的错误码要有业务语义。
4001 session not found被解释为 compaction 期间的短暂不一致,所以做有限重试。这种策略如果不写进代码,就会变成线上随机的“偶现问题”。见src/remote/SessionsWebSocket.ts。 - 资格校验要能给“可行动”的失败原因。把
getBridgeDisabledReason()做成字符串而不是 boolean,等于把“用户支持”写进了产品协议。见src/bridge/bridgeEnabled.ts。 - 长期运行进程要显式管理生命周期:心跳、重连、超时、容量控制。Bridge 的复杂性不是多余,是远程控制的真实成本。见
src/bridge/bridgeMain.ts。
源码练习
- 阅读
src/remote/SessionsWebSocket.ts,找到所有会触发“停止重连”的条件,用一句话写出每个条件背后的产品含义。 - 阅读
src/remote/RemoteSessionManager.ts,画出权限请求的状态机:进入 pending、用户响应、服务器取消、请求丢失四种路径如何收敛。 - 阅读
src/bridge/bridgeEnabled.ts,总结 Remote Control 的“资格判定链”包含哪些信息源 (订阅、scope、组织 UUID、gate、版本)。 - 阅读
src/entrypoints/cli.tsx,找到进入bridgeMain之前的所有检查点,写下它们分别保护了什么风险。
小结
Remote/Bridge 的核心不是“远端也能跑”,而是“远端跑得可控”。Claude Code 的实现把控制面单独建模,把网络失败当成常态,把资格与策略当成产品的一部分。这些取舍决定了远程能力能否从内部工具走向可规模化交付。
最后两章不再解释某个局部模块,而是回到产品与工程实践本身:复杂代理系统怎样演进、怎样观测、怎样回滚,以及如果你要重写一个同类系统,最该保留哪些结构。
本章问题
Claude Code 这种 agentic CLI 有一个天然矛盾:
- 它必须“敢做事”,触达 shell、文件系统、网络与外部工具。
- 它又必须“可控且可演进”,否则一次错误就会变成用户机器上的事故。
所以你会在源码里反复看到三类机制:
- 可观测性:记录发生了什么,但不泄露敏感内容。
- 特性开关:同一套代码要支持多产品形态、灰度、回滚与实验。
- 恢复性:长期运行的进程要能清理资源、降级运行、避免“半死不活”。
这一章要回答的是:这些机制为什么不是“运维补丁”,而应该是架构的一部分。
源码锚点
src/entrypoints/init.tssrc/services/analytics/index.tssrc/services/analytics/growthbook.tssrc/utils/cleanupRegistry.tssrc/services/tools/toolExecution.ts
源码图解:演进闭环
1. feature gate --> behavior path 2. analytics -->event queue --> sink 3. cleanup --> registry ----> shutdown 5. observe -> gate -> ship -> rollback
先建立模型
把 Claude Code 当成“长期运行的交互式系统”,你就会发现传统 CLI 的很多假设不成立:
- 传统 CLI 的生命周期短,退出就回收一切。Claude Code 会话是持续的,需要显式资源治理。
- 传统 CLI 的输出就是 stdout/stderr。Claude Code 需要把“发生了什么”编码成结构化事件,给 UI、诊断、产品分析多路复用。
- 传统 CLI 的发布模型是“版本升级”。Claude Code 需要在同版本内做灰度、实验与风险隔离,否则每次改动都是赌博。
因此,可观测性与特性开关不是“在业务逻辑外再包一层”,而是系统运行时的一部分,甚至会反过来影响模块边界与依赖关系。
一个判断标准是:你是否愿意为这些机制专门设计 API,并且用依赖约束保证它们不会把系统拖进循环依赖或启动变慢。
再看实现
src/services/analytics/index.ts 的注释写得很直白:这个模块“没有依赖,避免 import cycle”,并且在 sink attach 之前把事件排队。
这背后是两个工程目标:
- 不让任何业务模块为了打点而引入复杂依赖,进而引发循环依赖。
- 不让早期启动路径为 analytics 付初始化成本,但又不丢事件。
代码上体现为:
eventQueue缓存
QueuedEvent。attachAnalyticsSink()幂等,并用
queueMicrotask()异步 drain,避免 startup path 增加延迟。见src/services/analytics/index.ts。
更值得注意的是它对“敏感内容泄露”的防御方式:
- 用
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS这种 marker type 强迫调用方显式确认字符串安全。 - 用
_PROTO_*约定隔离 PII 路由,并通过stripProtoFields()在进入通用后端前统一剥离。见src/services/analytics/index.ts。
这不是****。对于一个会读写源码、执行 shell 的 agent,“日志泄露”本身就是安全事故。
src/services/analytics/growthbook.ts 的复杂度来自一个现实:feature flag 不是静态配置,它有缓存、覆盖、曝光日志、初始化时序和权限变化。
源码里能看到几类典型工程手法:
- 多级覆盖优先级:env overrides、config overrides、远程 eval、磁盘缓存。见
src/services/analytics/growthbook.ts。 - refresh 监听机制
onGrowthBookRefresh():允许“把 gate 值烘焙进长寿命对象”的系统在 gate 刷新时重建,而不是散落全局的重新初始化。见src/services/analytics/growthbook.ts。 - 明确承认缓存可能 stale:许多 API 都带
_CACHED_MAY_BE_STALE命名,让调用者在语义上做选择:快路径还是公平路径。见src/services/analytics/growthbook.ts。
这是一种很成熟的做法:把“风险选择”显式地体现在 API 命名上,避免调用方误用。
src/entrypoints/init.ts 做了大量看似与“回答问题”无关的事:配置启用、安全环境变量、证书、优雅退出、代理设置、scratchpad、LSP cleanup、team cleanup 等等。
但从工程角度看,这就是恢复性:
init被
memoize包裹,明确“全局只初始化一次”。见src/entrypoints/init.ts。- 不同能力按风险拆分时机:
applySafeConfigEnvironmentVariables()在信任建立前执行,而完整的 telemetry 初始化通过initializeTelemetryAfterTrust()延迟到信任之后。见src/entrypoints/init.ts。 - 资源回收走统一注册表:
registerCleanup()把 cleanup 函数集中起来,由 graceful shutdown 统一触发。见src/utils/cleanupRegistry.ts与src/entrypoints/init.ts。
注意,cleanupRegistry.ts 本身同样是“极薄模块”:一个 Set 加两个函数。薄模块的意义在这里很明确:它要被任何地方调用,但不能反过来依赖任何地方。
工具执行是另一个“敏感区”。classifyToolError() 的注释解释了为什么不能依赖 error.constructor.name:外部构建/混淆后会被 mangled。
因此它选择提取稳定且安全的信息:
- TelemetrySafeError 用专门字段。
- Node fs error 用
code(ENOENT/EACCES 等)。 - 其他错误用稳定
.name或 fallback。见src/services/tools/toolExecution.ts。
这属于“可观测性与隐私”的交叉点:你既要能定位问题,又不能把用户代码、路径带出机器。
编程思想
- 把可观测性做成“零依赖核心”,让所有模块都能安全地使用它,而不是在边角缝补。见
src/services/analytics/index.ts。 - 用 API 命名暴露风险选择:
_CACHED_MAY_BE_STALE让调用方主动决定一致性与延迟。见src/services/analytics/growthbook.ts。 - 初始化不是堆代码,是确定系统纪律:什么能在信任前做,什么必须等信任后做;什么必须幂等;什么必须可清理。见
src/entrypoints/init.ts。 - 诊断要同时满足“稳定性”和“安全性”:在混淆构建下仍可用,并且不会泄露代码与路径。见
src/services/tools/toolExecution.ts。
源码练习
- 阅读
src/services/analytics/index.ts,找出事件从logEvent()进入队列到attachAnalyticsSink()drain 的完整路径,写下为什么要用queueMicrotask()而不是同步 drain。 - 阅读
src/services/analytics/growthbook.ts,选择一个你在源码里见到的 gate 名,追踪它的值在“env 覆盖、config 覆盖、远程 eval、磁盘缓存”之间如何决策。 - 阅读
src/entrypoints/init.ts,列出所有通过registerCleanup()注册的 cleanup,并为每个 cleanup 写出它避免的资源泄露类型。 - 阅读
src/services/tools/toolExecution.ts,总结classifyToolError()的输出集合大致是什么形状,为什么这比直接 stringify error 更适合 telemetry。
小结
对 Claude Code 来说,可观测性、特性开关与恢复性不是外围设施,而是“让系统在真实世界里可用”的核心机制。你可以不喜欢它们带来的复杂度,但如果没有它们,远程化、权限治理、多工具编排都会把系统拖进不可诊断、不可回滚、不可清理的状态。
本章问题
读完前面的机制之后,最难的问题反而变简单了:
如果你要从零做一个“终端里的 agent 运行时”,哪些思想必须保留,哪些可以先不要?
本章不写“复刻所有功能”的清单,而给出一个最小可行的架构蓝图。它的目标是让你能在几周内做出一个可用的原型,并且不会在半年后被自己写出来的技术债吞掉。
源码锚点
src/entrypoints/cli.tsxsrc/main.tsxsrc/QueryEngine.tssrc/Tool.tssrc/services/tools/toolOrchestration.tssrc/services/tools/toolExecution.tssrc/Task.tssrc/state/AppState.tsx
源码图解:最小可行运行时蓝图
1. 1. entry + init 2. 2. context assembly 3. 3. query engine 4. 4. tool protocol 5. 5. permissions 6. 6. tasks / agents 7. 7. memory / extension bus 8. 8. observability / rollback
先建立模型
先把“产品目标”翻译成“系统不变量”。Claude Code 这类系统至少有五个不变量:
- 对话是长期状态机,不是一次 RPC。
- 能力必须通过类型化边界暴露给模型,不能让模型直接获得无限权力。
- 工具执行是可观测的,且必须可中断、可回滚或至少可止损。
- 长工作要提升为一等对象(Task),才能被 UI、并发、远程化统一管理。
- 扩展与灰度是常态,所以 feature gate、动态装配、降级路径必须存在。
这些不变量决定了你的架构分层。一个实用的分层是:
- 启动与配置层:决定系统以什么权限、什么策略启动。
- 会话引擎层:持有 messages、预算、缓存与生命周期。
- 工具运行时层:schema、权限、编排、执行、回写。
- 任务与协作层:把长工作拆成 Task,支持子代理与团队。
- UI/交互层:让状态被看见,让控制面**作。
你不需要一次性做完全部层的全部特性,但你要从第一天就让层与层之间的接口清晰。
再看实现
下面给出一个“最小 Claude Code”的实现顺序,每一步都能在恢复源码里找到对应的成熟形态,你可以照着对齐。
先学 src/entrypoints/cli.tsx 的做法:入口文件的职责是分流,不是堆逻辑。它用 fast-path 避免无谓 import,并把重逻辑延迟到真正需要时才加载。
对于你的最小系统,入口只需要三条路径:
--version之类的零依赖输出。
- 交互式 REPL (带 UI)。
- headless/SDK (不带 UI,但要同一个会话引擎)。
QueryEngine 的价值在于它把“会话状态”集中起来:messages、usage、permission denials、file cache 等,并把一次提交实现为 submitMessage() 的异步生成器。见 src/QueryEngine.ts。
你的最小版本可以先简化,但要保留两个形状:
- 会话对象长期存在,不在每次请求中重建。
- 每一轮产出“可流式消费”的事件,而不是一次性字符串。
这样 UI 与 SDK 才能共享同一套核心逻辑。
ToolUseContext 是恢复源码里最重要的类型之一,因为它把“执行现场”显式化:tools、commands、mcpClients、abortController、getAppState/setAppState 等。见 src/Tool.ts。
你最小实现需要的不是所有字段,而是三个核心:
tools:一组可被模型调用的能力定义,每个有输入 schema 与执行函数。
canUseTool:一个统一的权限决策入口。
abortController:让工具执行可被中断。
做到这一步,你就从“聊天机器人”跨到了“可执行代理”。
当模型一次吐出多个工具调用时,你需要一个编排器决定并发/串行。Claude Code 的做法是基于 isConcurrencySafe 把调用切成批次:并发执行只读工具,串行执行会修改上下文的工具。见 src/services/tools/toolOrchestration.ts。
你的最小版本甚至可以先全串行,但建议尽早引入“批次”概念,因为它会影响你怎么设计工具的副作用边界。
执行层还需要统一处理:
- 进度事件与结果事件的生成。
- 错误分类与诊断安全性。见
src/services/tools/toolExecution.ts。
一旦你支持后台 agent 或长时间 shell,“任务”就不能只是 console 输出。Claude Code 把 Task 定义为:
id/type/status/description/start/end/outputFile等基础字段。
- 终态判断
isTerminalTaskStatus。 - 多种 task type 的统一 dispatch。见
src/Task.ts与src/tasks.ts。
你最小版本可以只支持一种 task type,但必须让 task 有稳定 ID 与状态机,否则 UI 与远程化迟早会崩。
AppStateProvider 的核心不是 React,而是“外部 store + 细粒度订阅”,以承受高频状态更新。见 src/state/AppState.tsx。
你的最小版本可以先不做复杂 UI,但要在架构上为“状态驱动 UI”留出位置:所有事件都更新 state,UI 只是 state 的投影。
编程思想
- 先定义不变量,再写代码。会话持久、能力边界、可中断、可观测、可扩展是最核心的五个。
- 把工具抽象成类型化边界。schema 不是为了好看,是为了把系统能力限制在可审计的集合里。见
src/Tool.ts。 - 把并发策略写进编排层,而不是让每个工具自作主张。见
src/services/tools/toolOrchestration.ts。 - 把长工作提升为 Task,让控制面与 UI 有稳定抓手。见
src/Task.ts。 - 把演进机制当成一等需求。feature gate、动态装配、降级路径越早做越省钱。见
src/entrypoints/cli.tsx、src/main.tsx。
源码练习
- 以
src/Tool.ts为参考,设计一个你自己的Tool接口:输入 schema、执行函数、权限声明、并发安全声明分别放在哪里,为什么。 - 以
src/services/tools/toolOrchestration.ts为参考,写出一个最小的“批次编排”规则:什么工具允许并发,什么工具必须串行,你如何在类型层表达它。 - 以
src/Task.ts为参考,为你的系统定义一个 Task 状态机,并写出“终态”判定函数。 - 以
src/QueryEngine.ts为参考,写一段伪代码描述“一轮提交”如何产出流式事件,并指出哪些状态必须跨轮次保存。
小结
从 Claude Code 的恢复源码抽象出来,真正可迁移的不是某个 UI 细节或某个工具实现,而是架构纪律:入口薄、会话厚、工具有边界、执行可控、任务可追踪、系统可演进。如果你按这些不变量搭骨架,你的最小版本会很快可用,并且有空间长成真正的产品。
附录保留两件和正文同样重要的事:一是怎样阅读这份恢复源码,二是怎样用 team-agent 的方式组织一项复杂写作或工程任务。它们不是边角料,而是正文方法论的补充。
本章问题
这个仓库不是一个“可直接重建的源码工程”,而是从 cli.js.map 里恢复出来的阅读用源码树。它的价值在于“设计证据”,而不是“可编译产物”。
因此阅读方法必须调整:
- 你要找的是模块边界、状态模型、执行链路与工程取舍。
- 你不应该把每一处 import error 当成“还原失败”,更不该试图用它来否定源码提供的信息量。
本附录回答三个实际问题:
- 恢复源码里哪些文件是“事实入口”?
- 怎样从入口建立系统级的导航图?
- 怎样避免被 feature gate、动态 import 与循环依赖处理方式绕晕?
源码锚点
RESTORE_NOTES.mdcli.jscli.js.mapsrc/entrypoints/cli.tsxsrc/main.tsxsrc/QueryEngine.ts
源码图解:恢复源码阅读地图
1. cli.js -> runnable fact 2. src/-> design evidence 3. _virtual/-> bundle shadow 4. node_modules/->partial dependency trace 6. entry -> main loop -> tools -> boundaries
先建立模型
阅读恢复源码,先把仓库分成三类:
- 可执行事实:
cli.js是可靠入口,可以运行。见RESTORE_NOTES.md。 - 设计证据:
src/是恢复的 TS/TSX,用于理解架构与实现取舍。见RESTORE_NOTES.md。 - 捆绑残影:
_virtual/、部分node_modules/可能是 source map 提供的碎片,只能在阅读时辅助定位,不能当作“完整依赖”。见RESTORE_NOTES.md。
然后用“从外到内”的方式读:
- 从入口分流看产品形态:哪些命令是 fast-path,哪些是长流程。
- 从主循环看状态持久:会话如何保存 messages、预算、权限与缓存。
- 从工具执行看系统边界:能力如何被 schema 限制,权限如何被治理。
恢复源码的限制反而能帮你集中注意力:你只能从结构与注释里寻找设计意图,不会陷入“跑一下看看就知道”的依赖。
再看实现
src/entrypoints/cli.tsx 是最好的第一站,因为它展示了 Claude Code 在启动层面的多个 fast-path,以及为什么要大量动态 import。
阅读时建议做两件事:
- 把每条 fast-path 的触发条件写成一张表:参数、环境变量、feature gate。
- 标记哪些路径会调用
enableConfigs()、initSinks()之类的重初始化,哪些路径刻意避免加载。
这能让你快速区分“交互式核心路径”和“工具化/守护进程路径”。见 src/entrypoints/cli.tsx。
src/main.tsx 很长,但你不需要逐行读。你要找的是:
- 哪些 side-effect 必须最早发生 (profiling、prefetch、MDM raw read)。
- 为什么大量逻辑用 lazy require:典型原因是避免循环依赖,或者把体积大的模块延迟加载。见
src/main.tsx。
当你看到注释里强调“必须在 import 之前运行”,基本可以推断这是 startup latency 与副作用顺序的硬约束。
src/QueryEngine.ts 是恢复源码里最接近“可迁移核心”的模块之一。它明确宣称:一个 QueryEngine 对应一个 conversation,每次 submitMessage() 是一个 turn,但状态跨 turn 持久。
阅读建议:
- 只追三条线:messages 变化、权限拒绝记录、file cache/usage 累积。
- 看它如何把
canUseTool包一层来捕获拒绝原因,这往往是你在构建系统时最容易漏掉的“诊断能力”。见src/QueryEngine.ts。
恢复源码里大量出现 feature('...') 与 _CACHED_MAY_BE_STALE 这样的命名。阅读它们不是为了记住每个 gate,而是为了理解“产品如何演进而不撕裂架构”。
一个实用技巧是:
- 看到
feature(...)包裹的动态 require,就去问:为什么必须 DCE?为什么不能普通 if/else? - 看到
_CACHED_MAY_BE_STALE,就去问:这个调用点为何允许返回过期值?代价与收益是什么?
这些问题往往比“这个功能具体怎么用”更接近本书主题。见 src/entrypoints/cli.tsx、src/services/analytics/growthbook.ts。
RESTORE_NOTES.md 明确写了限制:src/ 下恢复出的 .ts/.tsx 文件不能直接用 Node 跑,原构建管线不完整。
你仍然可以做两类验证:
- 结构验证:类型、模块边界、调用链路是否自洽。
- 运行验证:用
cli.js跑起来观察行为,再回到src/找对应设计证据。见RESTORE_NOTES.md。
编程思想
- 阅读 agentic 系统,先找“控制面”:权限、取消、重连、清理、日志。它们通常决定系统能否上线。
- 把恢复源码当作“证据集”,用多文件交叉印证结论,而不是依赖单点实现细节。
- 看到为避免循环依赖而做的 lazy require/拆模块,这通常是在高复杂度系统里被迫形成的架构纪律。
源码练习
- 阅读
RESTORE_NOTES.md,用一句话写出为什么cli.js是事实入口,而src/是分析入口。 - 阅读
src/entrypoints/cli.tsx,找出所有“fast-path 返回”的分支,统计其中哪些路径完全不触发重初始化。 - 阅读
src/main.tsx,找出所有“避免循环依赖”的注释与 lazy require,写下它们分别避免了哪条依赖边。 - 阅读
src/QueryEngine.ts,用自己的话写出 “One QueryEngine per conversation” 对测试与可维护性的意义。
小结
恢复源码的价值在于它让你看到真实产品在工程约束下如何组织复杂系统。接受“不可直接重建”,你反而能更专注于结构、边界与取舍,这正是《Claude Code 源码编程思想》想传递的部分。
本章问题
如果你把 Claude Code 当成“终端里的 agent 运行时”,那么写一本技术书本质上也是一次 multi-agent 协作项目:
- 有可并行的工作:读源码、定大纲、写章节、做审校。
- 有强依赖的工作:术语统一、交叉引用、风格收敛。
- 有沟通成本:谁在写什么、谁被阻塞、谁需要反馈。
仅靠“大家在群里说一声”会很快失控。Claude Code 的 team/task/message 机制提供了一种更工程化的协作协议,能把协作成本显式化并降低误解。
本附录给出一套可执行的写作流水线,你可以直接复用到写书、重构、迁移、文档化等复杂任务。
源码锚点
src/tools/TeamCreateTool/prompt.tssrc/tools/TaskListTool/prompt.tssrc/tools/SendMessageTool/prompt.tssrc/tools/AgentTool/prompt.ts
源码图解:team-agent 写作流水线
1. brainstorm 2. | 3. v 4. outline 5. | 6. v 7. task list 8. | 9. +--> parallel drafting 10. +--> integration 11. --> review / refinement
先建立模型
把“团队写作”建模成一个轻量的分布式构建系统:
- Team 相当于一个 workspace:它把成员与协作上下文固定下来。
- TaskList 相当于构建队列:它把工作拆成可领取、可阻塞、可完成的单元。
- SendMessage 相当于控制面通知:它用于澄清、对齐、催办和交接,但不承载工作状态本身。
关键思想是“把状态放到共享任务清单里”,把消息用于“解除不确定性”。否则,你会得到一堆聊天记录,却得不到可追踪的项目进度。
再看实现
TeamCreate 的 prompt 里明确写了:Team 与 TaskList 目录是一一对应的,并且会创建:
~/.claude/teams/{team-name}/config.json~/.claude/tasks/{team-name}/
这背后的设计很工程化:协作信息落盘,不依赖内存态;任务清单可被所有 agent 读写,协作协议外部化。见 src/tools/TeamCreateTool/prompt.ts。
TaskListTool 的 prompt 强调了 teammate workflow,并给出了很具体的领取规则:
- 找
pending、无 owner、blockedBy 为空的任务。 - 多个可选任务时,优先按 ID 从小到大。见
src/tools/TaskListTool/prompt.ts。
这条规则看起来琐碎,但它解决的是并行协作最常见的问题:大家都抢“看起来最有趣的”任务,导致基础工作没人做,后续任务被无谓阻塞。
写书时完全可以照搬:
- 低 ID 任务放“定义术语/建立结构/搭骨架”。
- 高 ID 任务放“扩展章节/加练习/补引用”。
SendMessage 的 prompt 有两个约束很关键:
- 你在 plain text 里写的东西不会被别的 agent 看见,必须用工具发送。
- 不要发送结构化 JSON 状态,用 TaskUpdate 标记完成。见
src/tools/SendMessageTool/prompt.ts。
这其实是在强迫你把“协作状态”集中到任务系统里,避免状态分散在消息里无法检索、无法聚合。
它还提到了跨 session 的寻址形式 (UDS/bridge),这意味着团队协作并不一定限定在同一个进程或同一台机器上。见 src/tools/SendMessageTool/prompt.ts。
AgentTool 的 prompt 里花了大量篇幅解释 agent 类型、工具 allow/deny list、以及把 agent 列表从 tool 描述里搬到 attachment 来减少 cache bust。
对写书来说,你至少要区分两类角色:
- 只读勘探:负责读源码、抽结论、列引用。
- 可写落笔:负责真正编辑章节文件、修订、整合。
把角色与工具权限绑定,会让协作更稳:只读 agent 不会误改文件,写作 agent 不会浪费时间做“全局扫描”。见 src/tools/AgentTool/prompt.ts。
编程思想
- 协作要外部化协议:用 Team+TaskList 固化成员与任务,用 SendMessage 做沟通,而不是用聊天记录承载项目状态。见
src/tools/TeamCreateTool/prompt.ts、src/tools/TaskListTool/prompt.ts、src/tools/SendMessageTool/prompt.ts。 - 任务领取需要规则,否则并行会退化成冲突。按 ID 优先是一种简单有效的“全局排序”策略。见
src/tools/TaskListTool/prompt.ts。 - 角色要与能力匹配:让只读 agent 做勘探,让可写 agent 做落笔,能显著降低协作事故。见
src/tools/TeamCreateTool/prompt.ts、src/tools/AgentTool/prompt.ts。
源码练习
- 阅读
src/tools/TeamCreateTool/prompt.ts,用自己的话解释为什么 Team 与 TaskList 要 1:1,而不是允许一个 team 绑定多个任务目录。 - 阅读
src/tools/TaskListTool/prompt.ts,设计一个“写书任务 ID 编排规则”,并说明为什么 ID 顺序能降低阻塞。 - 阅读
src/tools/SendMessageTool/prompt.ts,写出一份最小的团队沟通约定:哪些信息必须进 TaskUpdate,哪些信息适合用 SendMessage。 - 阅读
src/tools/AgentTool/prompt.ts,为“架构梳理、章节写作、技术审校、整合发布”四种角色定义各自的工具权限边界。
小结
Team-Agent 写作流水线的价值不在于“更像多人写作”,而在于它把协作变成可追踪的工程过程:任务被外部化、状态被结构化、沟通用于解除不确定性。你可以把这套方法迁移到任何复杂工作中,包括写书、重构、迁移与上线。
如果把本文压缩成一句话,那么 Claude Code 的关键不在“大模型会不会回答”,而在“系统能不能把回答变成可治理的行动”。
从入口分流到 QueryEngine,从工具协议到权限链,从任务与 Agent 到 Team、Memory、MCP、Remote,再到特性开关、观测和清理,Claude Code 的源码反复强调同一个判断:复杂代理系统的核心竞争力不是某个模型接口,而是围绕模型建立起来的运行时纪律。
这也是本文真正想提炼的“编程思想”。如果你要构建自己的 agentic CLI,最值得继承的不是某个局部实现,而是这些结构化的约束:入口做薄,系统做厚;上下文先于动作;能力必须类型化;并发必须服从一致性;协作必须外部化协议;远程化必须显式区分控制面与数据面;演进必须以内建恢复性为前提。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:齐鲁师院网络安全社团 codex
codex《Claude Code 源码编程思想》
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
如需转载请保留出处:https://51itzy.com/kjqy/256190.html