mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
docs: 添加文档大纲及 superpowers/outline 目录
Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
This commit is contained in:
155
docs/outline-output/design/00-prologue.md
Normal file
155
docs/outline-output/design/00-prologue.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 序章:一份被反编译重建的 CLI,为什么处处是"约束的印记"
|
||||
|
||||
> 这不是原版代码,而是反编译产物在 Bun/JSC 约束下重建出来的东西——每一个奇怪的设计都有具体的根因。
|
||||
|
||||
## 反编译的语义:stub、feature gate、_c() 都是正常的
|
||||
|
||||
打开 `src/types/global.d.ts:1`,你会看到这份代码开宗明义的声明:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Global declarations for compile-time macros and internal-only identifiers
|
||||
* that are eliminated via Bun's MACRO/bundle feature system.
|
||||
*/
|
||||
```
|
||||
|
||||
这不是普通的 TypeScript 项目。这份代码的源头是编译后的产物,而不是人类手写的源码。类型声明文件里塞满了"只在编译期存在、运行时会被消除"的标识符:`MACRO.VERSION`、`MACRO.BUILD_TIME`、`resolveAntModel()`、`Gates`、`TungstenPill()`。这些东西在原版 Anthropic 内部构建链里是真实的函数和对象,但在反编译产物里,它们只剩下一个类型签名——一个空壳。
|
||||
|
||||
再往下看 `global.d.ts:59`:
|
||||
|
||||
```ts
|
||||
// T — Generic type parameter leaked from React compiler output
|
||||
// (react/compiler-runtime emits compiled JSX that loses generic type params)
|
||||
declare type T = unknown
|
||||
```
|
||||
|
||||
`T = unknown`。这不是谁偷懒写了无意义的类型别名。React Compiler(react-compiler-runtime)在编译 JSX 时会把泛型参数丢掉,反编译产物于是到处出现裸露的 `T`。为了让 TypeScript 编译器不报错,只能声明 `type T = unknown`。这是一个典型的"反编译痕迹"——它不是设计决策,而是信息丢失后的补救。
|
||||
|
||||
打开 `src/types/react-compiler-runtime.d.ts:1`,类型声明更简洁:
|
||||
|
||||
```ts
|
||||
declare module 'react/compiler-runtime' {
|
||||
export function c(size: number): unknown[]
|
||||
}
|
||||
```
|
||||
|
||||
一个函数 `c`,接受一个数字参数,返回 `unknown[]`。这个函数在原版 Anthropic 代码库里是 React Compiler 的运行时 memoization 辅助函数,用于生成 `$` 变量(你在反编译的 React 组件里会看到 `const $ = _c(N)` 这样的模式)。但在反编译产物里,编译器把它内联了,原始模块不复存在。为了不破坏下游 import,只能声明一个 `unknown[]` 返回值——类型系统在说"我知道这里有东西,但我不知道它是什么"。
|
||||
|
||||
## 全书的叙事主线:约束驱动架构
|
||||
|
||||
这本书的组织逻辑不是"这个项目有什么功能",而是"哪些约束逼出了哪些设计决策"。这个区别很重要。
|
||||
|
||||
你将要读到的每一章,都在追问同一个问题:**如果不这么做会怎样?**
|
||||
|
||||
- 第一章讲 Code Splitting——答案是"RSS 暴涨到 1GB,CLI 启动就要吃掉你一整 GB 内存"。这不是优化,是生存需求。
|
||||
- 第三章讲 performanceShim——答案是"JSC 的 Performance 实现有个永不收缩的 C++ Vector,长会话累积数百 MB 死容量"。
|
||||
- 第五章讲 Feature Flag 的三个硬约束——答案是"Bun 编译器 DCE 的 AST 模式匹配限制,`feature()` 只能出现在 `if` 条件位置"。
|
||||
|
||||
这本书里几乎每一个看似奇怪的设计——`feature()` 不能赋值给变量、`--version` 必须零模块加载、构建产物要正则替换 `globalThis.Bun`——都指向同一个主题:**你面对的不是一张白纸,而是 JSC 内存模型、Bun 编译器限制、反编译信息丢失这三重约束的交叉压力。**
|
||||
|
||||
## 如何阅读本书:打开编辑器,对照锚点
|
||||
|
||||
每个章节末尾的"锚点"不是装饰,而是邀请。每一条锚点都是 `文件:行号` 格式,指向代码库中真实存在的代码。
|
||||
|
||||
比如本章提到 `src/types/global.d.ts:59` 的 `T = unknown`。你可以现在就打开那个文件,跳到第 59 行,亲眼看到那行代码和它上方的注释。再比如本章开头引用了 `CLAUDE.md`(项目根目录下的那份),第一句话就是:
|
||||
|
||||
> This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool.
|
||||
|
||||
这不是隐喻。这份代码库的每一个角落都带着反编译的指纹。有些指纹很明显——`declare type T = unknown`、`export function c(size: number): unknown[]`;有些指纹很隐蔽——feature flag 系统的硬约束、模块级单例状态、"42 条 lint 规则关闭"(那是第十五章的内容)。
|
||||
|
||||
建议你用 VS Code 或任何编辑器打开这个项目的根目录。每次看到锚点引用时,花十秒钟跳过去看一下。你会发现文档描述和实际代码之间的对应关系非常精确——这比任何架构图都直观。
|
||||
|
||||
## 两类禁用 feature:丢失的 stub vs 原本就 stubbed 的
|
||||
|
||||
`scripts/defines.ts:39` 的 `DEFAULT_BUILD_FEATURES` 列表里有 65+ 个 feature flag。其中有 8 个被注释掉了:
|
||||
|
||||
```ts
|
||||
// 'HISTORY_SNIP', // 已禁用:snip 功能暂时关闭
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开
|
||||
// 'UDS_INBOX', // 进程间通信管道(inbox/pipe/peers 等命令)构建后 nodejs 环境卡住
|
||||
// 'LAN_PIPES', // 局域网管道,依赖 UDS_INBOX 构建后 nodejs 环境卡住
|
||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||
// 'SKILL_LEARNING',
|
||||
// 'TEAMMEM', // 已禁用:依赖 COORDINATOR_MODE,邮箱文件无限增长
|
||||
```
|
||||
|
||||
表面上看它们都是"被禁用的",但禁用的原因截然不同。混淆这两类会导致严重误判。
|
||||
|
||||
**第一类:反编译丢失导致的 stub。** `CONTEXT_COLLAPSE`、`HISTORY_SNIP`、`FORK_SUBAGENT`、`UDS_INBOX`、`LAN_PIPES`、`REVIEW_ARTIFACT` 属于这一类。
|
||||
|
||||
打开 `src/setup.ts:290` 你会看到:
|
||||
|
||||
```ts
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
require('./services/contextCollapse/index.js').initContextCollapse()
|
||||
}
|
||||
```
|
||||
|
||||
`src/services/contextCollapse/` 目录确实存在,里面有 `index.ts`、`operations.ts`、`persist.ts` 三个文件。但注释明确说"实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效"。反编译过程保留了文件结构和函数签名,但丢失了核心逻辑。如果你强行启用 `FEATURE_CONTEXT_COLLAPSE=1`,init 函数会跑起来,但它做的事情是错误的——它会抑制自动压缩,导致长对话的上下文管理彻底崩溃。
|
||||
|
||||
`HISTORY_SNIP` 的情况类似。打开 `src/commands.ts:92`:
|
||||
|
||||
```ts
|
||||
const forceSnip = feature('HISTORY_SNIP')
|
||||
? require('./commands/force-snip.js').default
|
||||
: null
|
||||
```
|
||||
|
||||
但 `src/commands/force-snip/` 目录根本不存在。如果你启用这个 feature,运行时会直接 `MODULE_NOT_FOUND`。这个 feature 在原版里指向一个完整的消息历史裁剪子系统(`src/utils/messages.ts:2652` 里有它的运行时检查逻辑),但反编译过程丢失了 `force-snip` 命令模块。
|
||||
|
||||
**第二类:功能原本就 stubbed 的。** `SKILL_LEARNING` 和 `TEAMMEM` 属于这一类。
|
||||
|
||||
打开 `src/services/skillLearning/featureCheck.ts:11`:
|
||||
|
||||
```ts
|
||||
export function isSkillLearningCompiledIn(): boolean {
|
||||
if (feature('SKILL_LEARNING')) return true
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
这个目录下有 20+ 个文件(`agentGenerator.ts`、`evolution.ts`、`instinctParser.ts`、`skillLifecycle.ts` 等),结构完整。这不是反编译丢失——这是 Anthropic 原版里本身就 stubbed 的功能。feature flag 注释写的也很清楚:`SKILL_LEARNING` 的 slash command 被编译进 build,但运行时默认 OFF,需要 operator 主动 `/skill-learning start` 开启。这不是"丢了",而是"还没开放"。
|
||||
|
||||
`TEAMMEM` 也是类似情况。`src/memdir/memdir.ts:7`、`src/utils/memoryFileDetection.ts:17` 等多处引用了 `feature('TEAMMEM')` 的分支逻辑,相关代码路径是完整的。禁用的原因是"依赖 COORDINATOR_MODE,邮箱文件无限增长"——这是一个产品决策,不是反编译事故。
|
||||
|
||||
**区分这两类的实用方法**:看被注释掉的那行注释。如果注释说"实现是空壳 stub"或"构建后环境卡住",那是反编译丢失(第一类)。如果注释说"依赖某 feature"或"待排查",那是功能本身的问题(第二类)。第一类强行启用会破坏核心功能;第二类启用后可能有 bug 但不会让系统崩溃。
|
||||
|
||||
## bun:bundle 的幽灵模块
|
||||
|
||||
`src/types/internal-modules.d.ts:10` 声明了一个不存在的模块:
|
||||
|
||||
```ts
|
||||
declare module 'bun:bundle' {
|
||||
export function feature(name: string): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`bun:bundle` 是 Bun 运行时的内置模块,由 Bun 编译器在构建时解析。你在 Bun 以外的环境里跑 `import { feature } from 'bun:bundle'` 会报错——这个模块只存在于 Bun 的编译管道里。类型声明文件把它写出来,纯粹是为了让 TypeScript 不报 `Cannot find module 'bun:bundle'` 错误。
|
||||
|
||||
这个幽灵模块贯穿整个代码库。`scripts/vite-plugin-feature-flags.ts:29` 里有一个 Rollup 插件,专门在 Vite 构建时把 `bun:bundle` 虚拟化为一个始终返回 `false` 的 stub:
|
||||
|
||||
```ts
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
return 'export function feature(name) { return false; }'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同一个 `feature()` 函数,在 Bun 构建里是编译器的 DCE(dead code elimination)钩子,在 Vite 构建里被插件替换为字面量。两种构建管道对同一个函数的理解完全不同,但产出的行为一致。这种"双管道、单语义"的设计是反编译重建工作的典型特征——你不需要理解原版为什么这么做,你只需要在两条路径上复现相同的行为。
|
||||
|
||||
## 反编译产物的类型补丁成本
|
||||
|
||||
`bun:bundle` 不是唯一的幽灵模块。同一个文件里还声明了 `bun:ffi`(`internal-modules.d.ts:14`),以及 `bidi-js`、`asciichart`、`@napi-rs/keyring` 等没有 `@types` 包的第三方模块。所有导出都被类型化为 `any` 或最小接口。
|
||||
|
||||
这意味着什么?意味着你在阅读代码时看到的类型签名,有很多是"人为补丁"而非"原始设计"。`T = unknown` 是最极端的例子,但更常见的模式是 `Record<string, unknown>`——当反编译丢掉了结构信息时,退化为字典类型是唯一安全的选项。
|
||||
|
||||
如果你在代码里看到某个函数接收 `Record<string, unknown>` 参数,或者在某个地方有 `as unknown as SomeType` 的双重断言,那大概率是反编译信息丢失的痕迹。这不是代码质量问题,而是信息损失的必然结果——就像你把一栋建筑拆成零件再重建,总有些螺丝的规格对不上,只能用万能件替代。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想了解 Feature Flag 系统为什么有"三个硬约束",见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看 Code Splitting 是怎么被 JSC 内存压力逼出来的,见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md)
|
||||
- 想了解 biome.json 关掉 42 条规则的反编译指纹,见 [第十五章:biome.json 的 42 条规则关闭](./15-biome-42-rules.md)
|
||||
- 想看 performanceShim 如何修补 JSC 内存泄漏,见 [第三章:performanceShim —— JSC 内存泄漏的运行时补丁](./03-performance-shim.md)
|
||||
189
docs/outline-output/design/01-code-splitting.md
Normal file
189
docs/outline-output/design/01-code-splitting.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 第一章:Code Splitting 不是优化,是生存需求
|
||||
|
||||
> 17MB 单文件让 Bun/JSC 暴食 1GB 内存,分割成 600+ chunks 才降到 35MB。
|
||||
|
||||
## JSC 的贪婪解析 vs V8 懒解析:一场 5 倍的内存鸿沟
|
||||
|
||||
打开 `vite.config.ts:94`,你会看到一段与代码看起来无关、却写满血泪的注释:
|
||||
|
||||
```
|
||||
// Code splitting: Bun/JSC parses the entire single-file bundle eagerly,
|
||||
// consuming ~1 GB RSS for a 17 MB output (vs ~220 MB on Node/V8 which
|
||||
// lazy-parses). Splitting into chunks allows Bun to load modules on demand,
|
||||
// bringing RSS down to ~300 MB.
|
||||
```
|
||||
|
||||
这段注释不是工程美学,而是测出来的生存数据。把同一个项目两种构建方式分别跑一次 `claude --version`:
|
||||
|
||||
- 单文件 17MB 产物 + Bun/JSC:RSS 暴涨到约 1GB
|
||||
- 同样 17MB 产物 + Node/V8:RSS 只有约 220MB
|
||||
- 切成 600+ chunks + Bun/JSC:`--version` 的 RSS 从 966MB 骤降到 35MB
|
||||
|
||||
为什么差这么多?因为 JavaScriptCore(Bun 的 JS 引擎)和 V8(Node 的引擎)对"一个函数被 import 但还没被调用"的假设完全相反:
|
||||
|
||||
- **V8 假设你大概率不会立刻执行它**,所以只做懒解析(lazy parsing)—— 函数体在第一次被调用时才完整解析、编译成字节码。17MB 的 bundle 里 90% 的函数是死代码(启动路径根本不会走到),V8 几乎不为它们付钱。
|
||||
- **JSC 假设你大概率会立刻执行它**,于是对整个 bundle 做 eager parsing + bytecode 编译 + JIT。17MB 里每一个函数、每一个闭包、每一个 `_c()` 调用都被即时编译成机器码塞进 RSS。死代码和活代码付同样的代价。
|
||||
|
||||
反事实推演:如果项目坚持单文件输出会怎样?`claude --version` 会消耗近 1GB 内存——一个本该 50ms 返回版本号的命令,会让用户怀疑 CLI 在偷偷挖矿。这种启动代价直接杀死了工具。
|
||||
|
||||
所以"为什么必须 code splitting"的答案不是"分包更优雅",而是"JSC 的内存模型逼我们切割"。一旦切到 chunks 级别,JSC 的按需加载优势就回来了:Bun 只解析 `cli.js` 入口真正 import 的那些 chunk,其他 chunk 在被 import 之前完全不进内存。
|
||||
|
||||
## 双构建管线:Bun.build vs Vite,为什么不能合并
|
||||
|
||||
项目里同时存在 `build.ts`(用 `Bun.build()`)和 `vite.config.ts`(用 Rollup),两条链路做的事情高度重叠:都接收 `src/entrypoints/cli.tsx` 作为入口、都启用代码分割、都把 chunks 输出到 `dist/`。
|
||||
|
||||
打开 `build.ts:23`,你会看到 Bun 原生构建的全部代码分割配置只有一行:
|
||||
|
||||
```ts
|
||||
const result = await Bun.build({
|
||||
entrypoints: ['src/entrypoints/cli.tsx'],
|
||||
outdir,
|
||||
target: 'bun',
|
||||
splitting: true,
|
||||
sourcemap: 'linked',
|
||||
define: {
|
||||
...getMacroDefines(),
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
features,
|
||||
})
|
||||
```
|
||||
|
||||
`splitting: true` 是 Bun 的原生 code splitting 开关。产物落在 `dist/` 根目录下,每个 chunk 是平铺的 `.js` 文件。
|
||||
|
||||
而 Vite 那条链路(`vite.config.ts:91` 的 `rollupOptions`)输出布局完全不同:
|
||||
|
||||
```ts
|
||||
output: {
|
||||
format: 'es',
|
||||
entryFileNames: 'cli.js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
},
|
||||
```
|
||||
|
||||
入口固定是 `dist/cli.js`,所有 chunk 被集中扔进 `dist/chunks/` 子目录。这种布局差异不是审美分歧,而是两条链路要服务不同目的:
|
||||
|
||||
- **Bun.build** 是默认开发链路,产物给 Bun 运行时执行。
|
||||
- **Vite 链路** 服务于更深度的场景——它需要 `featureFlagsPlugin()`(feature flag 在 transform 阶段替换为字面量,见第五章)、`importMetaRequirePlugin()`(Node.js 兼容补丁)、`.md`/`.txt`/`.html`/`.css` 作为 raw 字符串加载(模拟 Bun 的 text loader 行为,对应 `vite.config.ts:43` 的 `rawAssetPlugin`),以及 `dedupe: ['react', 'react-reconciler', 'react-compiler-runtime']`(保证工作区里只有一份 React,否则两份 reconciler 会让 Ink 渲染器崩掉)。
|
||||
|
||||
为什么不直接弃用 Bun.build?因为 Bun 原生构建是最快的开发回路,开发者每次 `bun run build` 不想等 Vite + Rollup 全套 transpile。两条链路在工程上分工明确:Bun.build 是 quick path,Vite 是 production-grade path。
|
||||
|
||||
## post-build 阶段:为什么必须 patch `globalThis.Bun` 解构
|
||||
|
||||
打开 `build.ts:62`,你会看到构建完成后还要跑一段第二轮补丁:
|
||||
|
||||
```ts
|
||||
// Also patch unguarded globalThis.Bun destructuring from third-party deps
|
||||
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
|
||||
let bunPatched = 0
|
||||
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
|
||||
const BUN_DESTRUCTURE_SAFE =
|
||||
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.js')) continue
|
||||
const filePath = join(outdir, file)
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
if (BUN_DESTRUCTURE.test(content)) {
|
||||
await writeFile(
|
||||
filePath,
|
||||
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
|
||||
)
|
||||
bunPatched++
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段正则补丁把 `var {x, y} = globalThis.Bun;` 改写成 `var {x, y} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};`。
|
||||
|
||||
为什么要这么做?因为 `@anthropic-ai/sandbox-runtime` 这类第三方依赖在源码里直接 `var {...} = globalThis.Bun;` 解构 Bun 全局对象。在 Bun 运行时下这没事,`globalThis.Bun` 永远存在。但如果用户用 `node dist/cli.js` 启动同一个产物,`globalThis.Bun` 是 `undefined`,对 `undefined` 做解构会立刻抛 `TypeError: Cannot destructure property 'x' of 'globalThis.Bun' as it is undefined`,整个 CLI 启动失败。
|
||||
|
||||
补丁的策略是后处理:扫描所有产物文件(包括 `dist/` 平铺文件 + `dist/chunks/` 子目录文件——Vite 链路对应 `scripts/post-build.ts:38` 的第二步扫描),把无保护的解构全部转成带 `typeof` 守卫的版本。这是一种"产物级兼容"——上游源码不改一行,靠后处理把跨运行时兼容性焊死在产物里。
|
||||
|
||||
反事实推演:如果不打这个补丁,产物就只能用 `bun` 跑、不能用 `node` 跑,"双入口"承诺(见下一节)直接作废。这恰恰解释了为什么 `build.ts:43` 处理完 `import.meta.require` 之后,紧接着在 `build.ts:62` 处理 `globalThis.Bun` 解构——这两段都是为了让同一份产物同时活在两个运行时里。
|
||||
|
||||
## 构建产物同时兼容 bun/node:双入口与 `import.meta.require` 探测
|
||||
|
||||
打开 `build.ts:43`,你会看到第一轮补丁:
|
||||
|
||||
```ts
|
||||
const IMPORT_META_REQUIRE = 'var __require = import.meta.require;'
|
||||
const COMPAT_REQUIRE = `var __require = typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url);`
|
||||
```
|
||||
|
||||
Bun 把 `import.meta.require` 当作一等公民——它是 Bun 内置的同步 `require`。但 Node.js 不认这个 API。所以补丁把无脑访问替换成运行时探测:在 Bun 下走 `import.meta.require`,在 Node 下退到 `(await import("module")).createRequire(import.meta.url)`,靠 `createRequire` 桥接 CommonJS。
|
||||
|
||||
补丁完成后,`build.ts:95` 会生成两个可执行入口:
|
||||
|
||||
```ts
|
||||
const cliBun = join(outdir, 'cli-bun.js')
|
||||
const cliNode = join(outdir, 'cli-node.js')
|
||||
|
||||
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
|
||||
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n')
|
||||
|
||||
const { chmodSync } = await import('fs')
|
||||
chmodSync(cliBun, 0o755)
|
||||
chmodSync(cliNode, 0o755)
|
||||
```
|
||||
|
||||
两个文件的唯一区别是 shebang——一个声明 `#!/usr/bin/env bun`、一个声明 `#!/usr/bin/env node`。两者都 `import "./cli.js"`,加载同一份主产物。
|
||||
|
||||
为什么必须保留双入口?因为部署环境五花八门:
|
||||
|
||||
- 一些 CI 容器只装了 Node.js
|
||||
- 一些用户的开发机偏好 Bun 的启动速度
|
||||
- 一些 Docker 镜像为了体积只装 Node.js
|
||||
|
||||
如果只发一个 `bun` 入口,Node 用户就用不了;如果只发 `node` 入口,Bun 用户拿不到 `import.meta.require` 的性能优势。双入口让同一份 `dist/cli.js` 适配两种部署,唯一的代价是 96 字节的额外文件。
|
||||
|
||||
注意 `build.ts:95` 这段写入的产物是 Bun.build 链路的;Vite 链路对应 `scripts/post-build.ts:71`,逻辑完全镜像——同样的 shebang 写入、同样的 chmod 0o755、同样的 `import "./cli.js"`。两条链路都必须各自生成双入口,因为它们各自产出的 `dist/cli.js` 不能交叉引用。
|
||||
|
||||
## distRoot.ts:让 chunk 文件在任何深度都能找到 vendor 二进制
|
||||
|
||||
打开 `src/utils/distRoot.ts:15`,你会看到一个被反复使用的 `distRoot` 函数:
|
||||
|
||||
```ts
|
||||
const distRoot = (() => {
|
||||
const parts = __dirname.split(path.sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(path.sep)
|
||||
}
|
||||
// Dev mode: from src/utils/ → project root
|
||||
const srcIdx = parts.lastIndexOf('src')
|
||||
if (srcIdx !== -1) {
|
||||
return parts.slice(0, srcIdx).join(path.sep)
|
||||
}
|
||||
return __dirname
|
||||
})()
|
||||
```
|
||||
|
||||
这段代码用 `lastIndexOf('dist')` 在 `__dirname` 里倒着找 `dist` 目录,找到就返回那个目录的绝对路径;找不到再找 `src`(dev 模式 fallback);都找不到就回退到 `__dirname` 本身。
|
||||
|
||||
为什么需要这个函数?因为 code splitting 之后,chunk 文件可能躺在三个不同的深度:
|
||||
|
||||
- 单文件构建:`dist/cli.js`,深度 = `dist/`
|
||||
- 代码分割 Bun.build:`dist/chunk-xxx.js`,深度 = `dist/`
|
||||
- 代码分割 Vite:`dist/chunks/chunk-xxx.js`,深度 = `dist/`(多了一层 `chunks/`)
|
||||
|
||||
而 vendor 二进制(`dist/vendor/audio-capture/`、`dist/vendor/ripgrep/`)永远在 `dist/vendor/` 下。`ripgrep.ts`、`computerUse/setup.ts`、`claudeInChrome/setup.ts`、`updateCCB.ts` 都需要从各自的位置反推 `dist/` 根目录才能拼出正确的 vendor 路径。
|
||||
|
||||
如果用 `import.meta.url` 内联推算,每个调用点都得自己写一遍 `lastIndexOf('dist')` 逻辑——而且一旦 Vite 链路改动 `chunks/` 子目录的深度,所有调用点全部失效。`distRoot.ts` 把这个脆弱推算收敛到一处,让上层调用方写 `path.join(distRoot(), 'vendor/ripgrep/ripgrep-' + process.platform + '-' + process.arch)` 就够了。
|
||||
|
||||
反事实推演:如果直接用 `path.resolve(__dirname, '../vendor/ripgrep/...')`,在 Bun.build 平铺布局下能跑、在 Vite `chunks/` 子目录布局下就会拼出 `dist/chunks/vendor/ripgrep/...`——一个根本不存在的路径,Grep 工具一调用就 spawn ENOENT。这就是为什么 `CLAUDE.md` 特意点名 `distRoot` 函数被多个文件复用:vendor 路径解析的脆弱性必须集中收口。
|
||||
|
||||
## 锚点的诚实:为什么 Vite 注释说 "~300MB" 而本章说 "35MB"
|
||||
|
||||
最后留一个诚实的核对:`vite.config.ts:94` 的注释说 code splitting 后 RSS "bringing RSS down to ~300 MB",而本章开篇引用的数据是 `--version` 的 35MB。
|
||||
|
||||
这两个数字都对,但测量的是不同的东西:
|
||||
|
||||
- **35MB** 是 `claude --version` 这种零模块加载的 fast-path(见第二章)——CLI 在加载完入口判断完参数就直接退出,几乎所有 chunk 都没被 import。
|
||||
- **300MB** 是 CLI 完整启动、加载完 REPL、初始化完 Ink 渲染器之后的稳态 RSS——大量 chunk 已经按需加载进来了。
|
||||
|
||||
这两个数字一起讲完整的故事:code splitting 让 fast-path 极致轻量(35MB),让 full-session 也能控制在合理范围(300MB vs 单文件的 1GB)。如果只引用其中一个数字会误导——前者让人以为 Bun 已经轻如鸿毛,后者让人以为它仍然吃内存。完整的对照表才是这条设计决策的全部证据。
|
||||
|
||||
## 延伸阅读
|
||||
- 想理解 `--version` 为什么能做到 35MB RSS,见 [第二章:入口的 Fast-Path 优先级链](./02-fast-path.md)
|
||||
- 想看 JSC 在长会话里继续作妖的另一个证据(`performanceShim` 兜底 C++ Vector 永不收缩),见 [第三章:performanceShim](./03-performance-shim.md)
|
||||
- 想了解 MACRO 编译期注入的另一面(`process.env.NODE_ENV='production'` 顺手干掉 6,889 个 `_debugStack` Error 对象、省下 12MB),见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
156
docs/outline-output/design/02-fast-path.md
Normal file
156
docs/outline-output/design/02-fast-path.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# 第二章:入口的 Fast-Path 优先级链 —— 为什么 --version 必须零模块加载
|
||||
|
||||
> 十几条快速路径按优先级串接,--version 的代码路径上没有任何 import。
|
||||
|
||||
## 从 main() 的第一条分支说起
|
||||
|
||||
打开 `src/entrypoints/cli.tsx:76`,你会看到整个 CLI 的入口函数 `main()`。它做的第一件事是 `process.argv.slice(2)`,然后立刻检查是不是 `--version` 或 `-v`:
|
||||
|
||||
```typescript
|
||||
// src/entrypoints/cli.tsx:80
|
||||
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
这看起来平淡无奇。但注意注释里写的:**"Fast-path for --version/-v: zero module loading needed"**。整条代码路径不需要任何 `import`。`MACRO.VERSION` 不是运行时变量 -- 它是编译期字面量替换的结果,在产物中会被直接内联为字符串 `"2.7.0"`。打开 `scripts/defines.ts:18`,你会看到它的来源:
|
||||
|
||||
```typescript
|
||||
// scripts/defines.ts:20
|
||||
'MACRO.VERSION': JSON.stringify(pkg.version),
|
||||
```
|
||||
|
||||
其中 `pkg.version` 读取自 `package.json`。版本号的单一来源是 `package.json`,不是散落在代码各处的 hardcoded 字符串。这是一个看似显而易见、但反编译产物特别容易弄丢的属性 -- 反编译不保留构建元信息,`MACRO.VERSION` 在重建时必须重新接回 `package.json`,否则每次升级都要改两处,版本号漂移就只是时间问题。
|
||||
|
||||
**如果不这么做会怎样?** 如果版本号 hardcoded 在 `cli.tsx` 里,`bun run dev` 和 `bun run build` 走两条注入路径(`-d` flag vs `Bun.build define`),两者必须各自维护一份版本号,迟早会漂移。`package.json` 是 npm 生态的约定真相源,所有工具都认它,CI、发布、changelog 生成都从这里读。
|
||||
|
||||
## 完整的优先级链
|
||||
|
||||
`--version` 之后是 `--dump-system-prompt`(feature-gated,`src/entrypoints/cli.tsx:93`)。这条路径稍微重一点 -- 需要 import `config.js`、`model.js`、`prompts.js`,但仍然是动态 import,不会在 `--version` 被执行时付出任何代价。
|
||||
|
||||
然后是 Chrome MCP(`src/entrypoints/cli.tsx:106`)、Computer Use MCP(`src/entrypoints/cli.tsx:116`)、ACP agent(`src/entrypoints/cli.tsx:124`)、weixin(`src/entrypoints/cli.tsx:131`)等独立服务模式的快速路径。
|
||||
|
||||
再往下是 `--daemon-worker`(`src/entrypoints/cli.tsx:164`),Bridge/Remote Control(`src/entrypoints/cli.tsx:183`),daemon 子命令(`src/entrypoints/cli.tsx:231`),background sessions 的 `--bg` 快捷方式(`src/entrypoints/cli.tsx:266`),向后兼容的 `ps/logs/attach/kill` 映射(`src/entrypoints/cli.tsx:278`),模板 jobs(`src/entrypoints/cli.tsx:297`),BYOC runners(`src/entrypoints/cli.tsx:319`),tmux worktree(`src/entrypoints/cli.tsx:338`)。
|
||||
|
||||
所有路径都满足同一个约束:**只在自身真正需要的模块上做动态 import,然后 return**。没有哪条路径会把无关代码拉进来。
|
||||
|
||||
最后,如果没有命中任何快速路径,`src/entrypoints/cli.tsx:375` 才会 `import('../main.jsx')`,加载完整的 Commander.js CLI 定义和 REPL 启动逻辑。
|
||||
|
||||
**如果不这么做会怎样?** 如果所有路径都走 `import('../main.jsx')`,那 `claude --version` 的启动延迟就和 `claude` 完整启动一样长。`main.jsx` 有 5674 行,注册了上百个 subcommand,pull 了一整棵依赖树。在一个 code-split 的 600+ chunk 产物中,这意味着 dozens of chunks 要被解析和执行。JSC 又不是 V8 -- 它没有懒解析,每个 chunk 一加载就开始全量编译。
|
||||
|
||||
## 一条脆弱但必要的初始化顺序依赖
|
||||
|
||||
`src/entrypoints/cli.tsx:52` 到 `cli.tsx:69` 有一段看起来很不寻常的代码:
|
||||
|
||||
```typescript
|
||||
// src/entrypoints/cli.tsx:55
|
||||
// Harness-science L0 ablation baseline. Inlined here (not init.ts) because
|
||||
// BashTool/AgentTool/PowerShellTool capture DISABLE_BACKGROUND_TASKS into
|
||||
// module-level consts at import time — init() runs too late.
|
||||
if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
||||
for (const k of [
|
||||
'CLAUDE_CODE_SIMPLE',
|
||||
'CLAUDE_CODE_DISABLE_THINKING',
|
||||
'DISABLE_INTERLEAVED_THINKING',
|
||||
'DISABLE_COMPACT',
|
||||
'DISABLE_AUTO_COMPACT',
|
||||
'CLAUDE_CODE_DISABLE_AUTO_MEMORY',
|
||||
'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS',
|
||||
]) {
|
||||
process.env[k] ??= '1';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注释说的很直白:这段代码必须 **inline 在 `cli.tsx` 顶层**,不能放在 `init.ts` 或其他任何晚于工具 import 的地方。原因是什么?打开 `packages/builtin-tools/src/tools/BashTool/BashTool.tsx:296`:
|
||||
|
||||
```typescript
|
||||
// BashTool.tsx:296
|
||||
const isBackgroundTasksDisabled =
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS);
|
||||
```
|
||||
|
||||
这是一个 **模块级 const**。它在 `BashTool.tsx` 被 import 的那一刻求值,之后不再更新。`AgentTool.tsx:118` 和 `PowerShellTool.tsx:254` 也有同样的模式。如果 `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` 的设置发生在这些工具被 import **之后**,工具会读到 `undefined`,背景任务就不会被禁用。
|
||||
|
||||
这就是为什么 ablation baseline 的环境变量注入必须在 `cli.tsx` 顶层 -- 在 `main()` 被调用之前、在任何工具模块被 import 之前。`init.ts` 跑得太晚了,它会被 `main.jsx` 的某处 import 时才执行。
|
||||
|
||||
**如果不这么做会怎样?** ablation baseline 的实验数据会失效 -- 某些禁用项会被漏掉,研究者得到的不是真正的 "L0 精简" 基线,而是一个混杂了部分功能的半吊子配置。这在 harness-science 实验里是致命的。
|
||||
|
||||
这是一个典型的 **模块求值顺序** 陷阱。在 ESM 中,模块级代码在 import 时执行,而且只执行一次。你不能 "事后补" 一个模块级 const 的值。这不是 bug,这是 ESM 的设计语义 -- 但它在大型工具链中制造了隐式的时序耦合。
|
||||
|
||||
`feature('ABLATION_BASELINE')` 的 gate 在外部构建中会被 DCE(Dead Code Elimination)消除。打开 `scripts/defines.ts` 的 `DEFAULT_BUILD_FEATURES` 列表(`scripts/defines.ts:39`),你会发现里面根本没有 `ABLATION_BASELINE`。也就是说,在标准构建产物中,这段代码完全不存在。
|
||||
|
||||
## MACRO 编译期注入的三层防线
|
||||
|
||||
版本号和构建时间这些常量不是运行时读的。它们有三层注入机制:
|
||||
|
||||
**第一层:dev 模式的 `-d` flag**。打开 `scripts/dev.ts:17`,你会看到 `getMacroDefines()` 返回的值被展开为 `-d MACRO.VERSION:"2.7.0"` 之类的命令行参数,传递给 `bun run`。Bun 的 `-d` flag 做的是编译期文本替换,效果等同于 `#define`。
|
||||
|
||||
**第二层:build 的 `Bun.build({ define })`**。打开 `build.ts:25`,同样的 `getMacroDefines()` 被传入 `Bun.build` 的 `define` 选项。产物中的 `MACRO.VERSION` 在构建时就变成了字面字符串。
|
||||
|
||||
**第三层:运行时 fallback**。打开 `src/entrypoints/cli.tsx:11`,如果 `globalThis.MACRO` 未定义(说明既没有走 dev 也没有走 build,而是直接 `bun src/entrypoints/cli.tsx`),会用环境变量 `CLAUDE_CODE_VERSION` 或 hardcoded 的 fallback 值 `'2.1.888'` 初始化。
|
||||
|
||||
为什么需要三层?因为 `cli.tsx` 有三种运行方式:`bun run dev`(dev 脚本注入)、`bun dist/cli.js`(build 注入)、`bun src/entrypoints/cli.tsx`(裸跑,什么注入都没有)。三层防线保证无论哪种方式,`MACRO.VERSION` 都不会是 `undefined`。
|
||||
|
||||
**如果不这么做会怎样?** 直接 `bun src/entrypoints/cli.tsx` 时 `MACRO.VERSION` 会抛 `ReferenceError`,因为编译期注入没发生,运行时 fallback 也没装。三层防线确保开发调试时不会被构建系统的遗漏卡住。
|
||||
|
||||
## 双入口 cli-bun.js / cli-node.js
|
||||
|
||||
`package.json` 的 `bin` 字段注册了两个入口:
|
||||
|
||||
```json
|
||||
"bin": {
|
||||
"ccb": "dist/cli-node.js",
|
||||
"ccb-bun": "dist/cli-bun.js",
|
||||
"claude-code-best": "dist/cli-node.js"
|
||||
}
|
||||
```
|
||||
|
||||
打开 `dist/cli-bun.js` 和 `dist/cli-node.js`,内容各只有两行:
|
||||
|
||||
```javascript
|
||||
// dist/cli-bun.js
|
||||
#!/usr/bin/env bun
|
||||
import "./cli.js"
|
||||
|
||||
// dist/cli-node.js
|
||||
#!/usr/bin/env node
|
||||
import "./cli.js"
|
||||
```
|
||||
|
||||
同一份 `dist/cli.js` 产物被两个 shebang 不同的 wrapper 引用。`cli-bun.js` 走 Bun 运行时,`cli-node.js` 走 Node.js 运行时。这之所以可行,是因为 `build.ts` 的 post-build 阶段做了两个兼容性修补(`build.ts:43` 和 `build.ts:62`):把 `import.meta.require` 替换为 Node.js 兼容的 `createRequire`,把 `globalThis.Bun` 解构改为带 fallback 的安全写法。
|
||||
|
||||
**如果不这么做会怎样?** 如果只有 `#!/usr/bin/env node` 一个入口,Bun 专属的 `bun:bundle` 模块(`feature()` 函数的来源)在 Node.js 里根本不存在。Node.js 用户会得到 `ERR_MODULE_NOT_FOUND`。反过来,如果只有 bun 入口,就无法在 CI 环境中利用预装的 Node.js 而不必额外安装 Bun。
|
||||
|
||||
## 每条快速路径的 feature() gate 都在 parse 阶段可见
|
||||
|
||||
整条优先级链里,除了 `--version` 之外,每条快速路径都被 `feature()` 保护。打开 `src/entrypoints/cli.tsx:93`:
|
||||
|
||||
```typescript
|
||||
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
|
||||
```
|
||||
|
||||
以及 `cli.tsx:116`:
|
||||
|
||||
```typescript
|
||||
} else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') {
|
||||
```
|
||||
|
||||
这些 `feature()` 调用不是运行时布尔值查询。打开 `src/types/internal-modules.d.ts:10`,你会看到 `bun:bundle` 模块声明的 `feature` 函数签名。在 Bun 构建时,`feature('FLAG_NAME')` 会被编译器替换为字面量 `true` 或 `false`。如果 flag 未启用,`if (feature('DUMP_SYSTEM_PROMPT') && ...)` 整个分支会在 DCE 阶段被删除,连里面的动态 import 都不会被 Bun 打包进 chunk。
|
||||
|
||||
这就是为什么 `feature()` 只能出现在 `if` 条件或三元表达式的直接位置(Bun 编译器的 AST 模式匹配限制),不能赋值给变量、不能放在回调里、不能做 `&&` 链的一部分。它必须在 parse 阶段就可见为可以被静态分析的布尔分支。
|
||||
|
||||
**如果不这么做会怎样?** 如果 feature gate 是运行时函数调用,DCE 无法工作,所有快速路径的代码都会被 Bun 打包进产物。即使某个 feature 在目标构建中完全不需要,它的依赖树(import 的模块、那些模块的依赖)仍然会被打包。产物体积膨胀,启动时间变长。在 code-split 的架构下,这意味着更多 chunks 要被解析,RSS 随之上涨。
|
||||
|
||||
## startupProfiler: 快速路径的时间戳
|
||||
|
||||
非 `--version` 的路径会第一个 import `startupProfiler.js`(`src/entrypoints/cli.tsx:87`),调用 `profileCheckpoint('cli_entry')`。之后每条快速路径都有自己的 checkpoint 名称:`cli_dump_system_prompt_path`、`cli_claude_in_chrome_mcp_path`、`cli_bridge_path` 等等。这形成了一条完整的启动时间线,可以精确测量每个阶段的耗时。
|
||||
|
||||
`startupProfiler` 本身有采样控制(`src/utils/startupProfiler.ts:30`):0.5% 的外部用户和 100% 的内部用户会被采样,其余用户不付出任何性能代价。这个模块不是快速路径本身,但它衡量了快速路径的效果 -- 如果 `--version` 的 checkpoint 和进程退出的时间差大于 10ms,说明有什么东西不该被加载。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看为什么 `performanceShim` 必须是 `cli.tsx` 的第一行 import,见 [第三章](./03-performance-shim.md)
|
||||
- 想看 `feature()` 的三个硬约束为什么决定了整个构建管线的设计,见 [第五章](./05-feature-flags.md)
|
||||
- 想看 code splitting 如何让快速路径的 chunk 加载成本趋近于零,见 [第一章](./01-code-splitting.md)
|
||||
223
docs/outline-output/design/03-performance-shim.md
Normal file
223
docs/outline-output/design/03-performance-shim.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 第三章:performanceShim —— JSC 内存泄漏的运行时补丁
|
||||
|
||||
> 170 行纯 JS 替换全局对象,拦住 JSC C++ Vector 那条永不收缩的内存黑洞。
|
||||
|
||||
## 一行 import,必须放在最前面
|
||||
|
||||
打开 `src/entrypoints/cli.tsx:1`,整个文件的第一个有效行不是 `#!/usr/bin/env bun`(那是注释),而是:
|
||||
|
||||
```typescript
|
||||
// src/entrypoints/cli.tsx:2
|
||||
// Performance shim MUST be the first import — it replaces globalThis.performance
|
||||
// with a JS-backed implementation before React/OTel capture the native reference.
|
||||
// Without this, JSC's C++ Vector grows without bound in long-running sessions.
|
||||
import '../utils/performanceShim.js';
|
||||
```
|
||||
|
||||
注意:这一行甚至排在 `import { feature } from 'bun:bundle'` 之前(`cli.tsx:6`),也排在所有业务逻辑 import 之前。`cli.tsx` 是整个程序的真正入口,任何东西都不会比它更早执行。
|
||||
|
||||
为什么必须这么早?答案藏在两个消费者的 import 时序里。
|
||||
|
||||
## JSC 原生 Performance 的陷阱:C++ Vector 永不收缩
|
||||
|
||||
JavaScriptCore(Bun 的 JS 引擎)内置的 `globalThis.performance` 对象把所有 marks、measures 和 resource timings 存储在一个 C++ 层的 `Vector` 里。这个 Vector 的关键问题不是"慢",而是"只增不减"——即使你调用了 `performance.clearMarks()`,C++ Vector 的 capacity(已分配内存)不会缩小。`clear` 操作只是把逻辑长度归零,底层 buffer 的 capacity 一直挂在那里,被 GC 完全忽略,因为 GC 管不到 C++ 堆。
|
||||
|
||||
在短命脚本里这不是问题:进程一退出,操作系统回收一切。但 Claude Code 是一个长驻进程——一次 `claude` 会话可能运行几十分钟甚至更长,`/loop` 模式下更是无限制。每一轮 API 调用,OpenTelemetry 的 `SpanImpl` 都会在 `performance.mark()` 上创建条目(用来记录 span 的 startTime)。一轮对话下来可能积累几千个 marks,但 span 数据在 flush 之后就已经没用了——只是 C++ Vector 还记得它们。
|
||||
|
||||
打开 `src/query.ts:359`,你会看到注释里提到了具体的数字:
|
||||
|
||||
```typescript
|
||||
// src/query.ts:358-360
|
||||
// Break the closure chain: toolUseContext captures langfuseTrace which
|
||||
// holds SpanImpl → otperformance (the 571MB Performance object). Nulling
|
||||
// these after endTrace allows GC to reclaim the span tree.
|
||||
```
|
||||
|
||||
571MB。这是一个 Performance 对象在长会话里膨胀到的体量。注释里甚至画了一条引用链:`toolUseContext -> langfuseTrace -> SpanImpl -> otperformance`。只要这条链上任何一个节点还活着,那个 571MB 的 Performance 对象就无法被 GC。
|
||||
|
||||
反事实推演:如果没有这个 shim,一个运行 30 分钟的 daemon 会话,光是 Performance 对象的 C++ Vector 残留就可能吃掉数百 MB。内存不会随对话轮次增长——它会**阶梯式跳跃**,每次大量 span 被创建又 flush 之后留下一截不可回收的 C++ capacity。这不是 OOM 崩溃,而是那种让系统越来越慢、越来越卡的"温水煮青蛙"式泄漏。
|
||||
|
||||
## 为什么保留 `performance.now()` 走原生,只劫持 mark/measure/getEntries
|
||||
|
||||
打开 `src/utils/performanceShim.ts:19`,整个文件的第一行实际代码是:
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:19
|
||||
const original = globalThis.performance
|
||||
```
|
||||
|
||||
然后 `performanceShim.ts:28-30` 实现的 `now()` 函数直接委托给了原生的 `original.now()`:
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:28-30
|
||||
function now(): number {
|
||||
return original.now()
|
||||
}
|
||||
```
|
||||
|
||||
这是一个刻意的性能决策。`performance.now()` 返回的是高精度时间戳(微秒级),底层是一个单调递增的计数器,不涉及任何数据存储,所以零内存开销。Bun/JSC 的原生实现利用了 `clock_gettime(CLOCK_MONOTONIC)` 系统调用,精度和性能都最优。
|
||||
|
||||
但 `mark()`、`measure()`、`getEntriesByType()` 是另一回事——它们会在 C++ Vector 里插入和存储条目。shim 把这些操作全部重定向到一个 JS `Map`(`performanceShim.ts:22-26`):
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:22-26
|
||||
// JS-backed storage — fully GC-able
|
||||
const marks = new Map<string, number>()
|
||||
const measures = new Map<
|
||||
string,
|
||||
{ name: string; startTime: number; duration: number }
|
||||
>()
|
||||
```
|
||||
|
||||
`Map` 是 JS 堆上的普通对象。当 `marks.clear()` 被调用时(`performanceShim.ts:112`),Map 的内部 buffer 会被 V8/Bun 的 GC 正常回收。没有 C++ Vector 的 capacity 残留问题。
|
||||
|
||||
反事实推演:如果把 `now()` 也用 JS 实现(比如用 `Date.now()` 或 `process.hrtime()`),精度会降低到毫秒级,而且 OTel 的 span 时间计算依赖 `performance.now()` 与 `performance.timeOrigin` 之间的差值来得到单调递增的相对时间——换成其他时间源会破坏 OTel 的计时语义。
|
||||
|
||||
## 为什么不能继承 Performance.prototype
|
||||
|
||||
`performanceShim.ts:124-126` 有一个容易被忽略的注释:
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:124-126
|
||||
// Plain object shim — must NOT inherit from Performance.prototype because
|
||||
// native getters (onresourcetimingbufferfull, timeOrigin, toJSON) check
|
||||
// that `this` is an actual JSC Performance instance and throw otherwise.
|
||||
```
|
||||
|
||||
如果 shim 用 `Object.create(Performance.prototype)` 来创建,JSC 的原生 getter(比如 `timeOrigin`)会检查 `this instanceof Performance`——当 `this` 是一个 JS 平面对象时,这些原生 getter 会直接抛出 TypeError。所以 shim 必须用纯平面对象(plain object literal),然后手动覆盖需要的属性。
|
||||
|
||||
但 `timeOrigin` 是只读属性,shim 需要把它代理回原生对象(`performanceShim.ts:142-144`):
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:142-144
|
||||
get timeOrigin() {
|
||||
return original.timeOrigin
|
||||
},
|
||||
```
|
||||
|
||||
还有一个细节——`onresourcetimingbufferfull` 的 setter 被故意设成了 no-op(`performanceShim.ts:149-151`):
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:149-151
|
||||
set onresourcetimingbufferfull(_v: any) {
|
||||
// no-op — prevent accumulation
|
||||
},
|
||||
```
|
||||
|
||||
这是因为 JSC 的 `Performance` 在 resource timing buffer 满时会触发这个回调——但既然 shim 已经把 resource timing 的写入变成了空操作(`clearResourceTimings` 和 `setResourceTimingBufferSize` 都是 `() => {}`),这个回调永远不该被触发,所以 setter 什么都不做。
|
||||
|
||||
## "未定义的必备方法":undici 的 markResourceTiming
|
||||
|
||||
`performanceShim.ts:138-140` 里有一行看起来很奇怪——一个永远不做事的空函数,但注释说"必须存在":
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:138-140
|
||||
// Node.js v22 undici internal calls this after every fetch — must exist to
|
||||
// avoid TypeError: markResourceTiming is not a function
|
||||
markResourceTiming: (() => {}) as () => void,
|
||||
```
|
||||
|
||||
Node.js v22 内部使用的 HTTP 客户端 undici,在每次 fetch 完成后都会调用 `performance.markResourceTiming()` 来记录网络请求的时间。构建产物是 Node.js 兼容的(`build.ts` 会后处理 `import.meta.require`),所以当用户用 `node dist/cli.js` 运行时,undici 会期望这个方法存在。如果 shim 不提供它,每次 fetch 都会抛 `TypeError: markResourceTiming is not a function`,整个 HTTP 请求链就断了。
|
||||
|
||||
这跟 OpenTelemetry 无关,跟 React 无关——纯粹是 Node.js 运行时的内部约定。shim 的角色不仅是拦截 JSC 的泄漏,还得兼容 Node.js 运行时的接口预期。
|
||||
|
||||
## 为什么必须最先 import:原生引用的"快照"语义
|
||||
|
||||
`cli.tsx` 把 `performanceShim` 放在第一个 import 的位置,不是风格偏好,而是 JS 模块系统的硬约束。
|
||||
|
||||
OpenTelemetry 的 `@opentelemetry/core` 包导出了一个 `otperformance` 对象,它在模块初始化时读取 `globalThis.performance` 并缓存到一个模块级变量里。这个变量在模块的整个生命周期内都不会变——它是一个"快照",记录的是模块被 import 那一瞬间 `globalThis.performance` 指向什么。
|
||||
|
||||
类似的,React 的 reconciler 在初始化时也会读取 `globalThis.performance`。一旦它们捕获了原生 Performance 的引用,后续你再替换 `globalThis.performance` 也无济于事——那些模块仍然持有一条指向原生对象的引用链,mark/measure 继续往那个永不收缩的 C++ Vector 里塞东西。
|
||||
|
||||
所以 `performanceShim` 必须在 OTel 和 React 之前安装。`cli.tsx:2` 的 import 保证了这一点——ESM 规范要求 import 按书写顺序深度优先执行,`performanceShim.js` 的顶层代码(`performanceShim.ts:169` 的 `installPerformanceShim()`)会在其他任何模块被加载之前执行完毕。
|
||||
|
||||
反事实推演:如果把 `performanceShim` 的 import 放到第 10 行甚至第 50 行,OTel 或 React 很可能在它之前就被某个间接依赖链拉进来了(ESM 的 import 图是深度优先的)。一旦错过窗口,shim 就完全失效,而你还不知道——因为 `performance.now()` 仍然正常工作,只有 `mark/measure` 在偷偷泄漏。
|
||||
|
||||
## installPerformanceShim 的幂等保护
|
||||
|
||||
`performanceShim.ts:162-165`:
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceShim.ts:162-165
|
||||
export function installPerformanceShim(): void {
|
||||
if ((globalThis as Record<string, unknown>).__performanceShimInstalled) return
|
||||
;(globalThis as Record<string, unknown>).__performanceShimInstalled = true
|
||||
globalThis.performance = shim
|
||||
}
|
||||
```
|
||||
|
||||
用 `__performanceShimInstalled` 做幂等检查。这个看起来是多余的——shim 不是只在 `cli.tsx` 里 import 一次吗?实际上不是。`performanceShim.ts:169` 的 `installPerformanceShim()` 在模块顶层调用,而 ESM 模块在同一个进程内只执行一次顶层代码,所以正常情况下确实只运行一次。
|
||||
|
||||
但这个保护是为 sub-agent 场景预留的——如果 sub-agent 进程(比如 `spawn` 出的子进程)独立加载了 `performanceShim`,幂等检查确保不会创建多层代理。`installPerformanceShim` 是 `export` 的,意味着它也可以被手动调用——这在测试环境或嵌套场景里有用。
|
||||
|
||||
## query.ts 的 finally 块:shim 的第二道防线
|
||||
|
||||
`cli.tsx` 的第一行 import 是第一道防线。但防线可能被突破——比如 sub-agent 直接 import `src/query.ts` 而不经过 `cli.tsx` 入口。这种情况下 shim 可能还没装上,OTel 的 span marks 就直接写进了原生 Performance。
|
||||
|
||||
打开 `src/query.ts:367-380`,在 `yield* queryLoop()` 的 finally 块里,你会看到一段兜底代码:
|
||||
|
||||
```typescript
|
||||
// src/query.ts:367-380
|
||||
// Clear JSC's native Performance buffers. OTel (otperformance) references
|
||||
// globalThis.performance which stores marks/measures/resource timings in a
|
||||
// C++ Vector that never shrinks. Long-running sessions accumulate hundreds
|
||||
// of MB of dead capacity even after spans are flushed and nullified.
|
||||
const gPerf = globalThis.performance
|
||||
if (gPerf && typeof gPerf.clearMarks === 'function') {
|
||||
try {
|
||||
gPerf.clearMarks()
|
||||
gPerf.clearMeasures?.()
|
||||
gPerf.clearResourceTimings?.()
|
||||
} catch {
|
||||
// Non-critical — some environments may not support all methods
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意这段代码的防御性写法:先检查 `typeof gPerf.clearMarks === 'function'`,再用 `try/catch` 包裹。如果 shim 已经装上,`clearMarks()` 清空的是 JS Map——无害但也没必要(Map 本来就在每一轮 turn 之后由业务代码正常管理)。如果 shim 没装上,`clearMarks()` 清空的是原生 C++ Vector——逻辑长度归零,但 capacity 不缩小,只能算是"止血"而非"治愈"。
|
||||
|
||||
这就是为什么这段 finally 块只是"兜底":它能阻止情况恶化,但不能根治 C++ Vector 不收缩的问题。真正的修复是 shim 本身——把数据存储从 C++ Vector 转移到 JS Map。
|
||||
|
||||
注释里还提到了一个细节(`query.ts:358-360`):在调用 `clearMarks` 之前,代码先断开了引用链——把 `langfuseTrace`、`langfuseRootTrace`、`langfuseBatchSpan` 全部设为 `null`。这是因为 Langfuse 的 `SpanImpl` 对象持有 `otperformance` 的引用,而 `otperformance` 指向原生 Performance 对象。只有把整条引用链上的指针都断开,GC 才能回收 span 树。
|
||||
|
||||
## 为什么 dev 模式把 NODE_ENV 设成 'production'
|
||||
|
||||
`scripts/dev.ts:17-22`:
|
||||
|
||||
```typescript
|
||||
// scripts/dev.ts:17-22
|
||||
const defines = {
|
||||
...getMacroDefines(),
|
||||
// React production mode — prevents 6,889+ _debugStack Error objects
|
||||
// (12MB) from accumulating during long-running sessions.
|
||||
// dev 模式使用 development 模式
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}
|
||||
```
|
||||
|
||||
这是一个反直觉的决策:开发模式为什么要把 `NODE_ENV` 设成 `production`?React 在 `development` 模式下会为每个组件实例创建一个 `_debugStack` 属性——这是一个完整的 `Error` 对象,用来在 DevTools 里显示组件的调用栈。每个 `Error` 对象携带 stack trace 字符串,大约 1.7KB。
|
||||
|
||||
Claude Code 的 UI 层有 149 个组件目录,在一个活跃的 REPL 会话里组件创建/销毁极其频繁。注释里给出了实测数据:6,889 个 `_debugStack` Error 对象,累计 12MB。这不是一次性的——组件在每次渲染周期都会重新创建,这些 Error 对象在 development 模式下会不断累积。
|
||||
|
||||
`process.env.NODE_ENV` 在这里是通过 Bun 的 `-d` flag(`scripts/dev.ts:25-28`)做编译期替换的——它不是运行时的 `process.env` 读取,而是在编译时被字面量 `'production'` 替换。这意味着 React 的条件分支(`if (process.env.NODE_ENV !== 'production')`)会在编译期被 DCE(Dead Code Elimination)完全移除,零运行时开销。
|
||||
|
||||
注释里有一处中文"dev 模式使用 development 模式"跟实际代码矛盾——代码确实设成了 `production`。这是反编译产物里残留的原始注释与实际行为不一致的痕迹之一:原始代码可能在某个迭代中从 `development` 改成了 `production`,但注释没有同步更新。
|
||||
|
||||
反事实推演:如果 dev 模式保留 `development`,每次启动 REPL 后几分钟就会积累 12MB 的 `_debugStack` 对象。对一个本来就因为 JSC eager parsing 而内存紧张的运行时来说,这是雪上加霜。
|
||||
|
||||
## 两个防御层次的设计哲学
|
||||
|
||||
`performanceShim` 和 `NODE_ENV='production'` 解决的是同一个类问题:JSC 运行时在长会话场景下的内存管理缺陷。但它们用了完全不同的策略:
|
||||
|
||||
- `performanceShim` 是**替换策略**:在消费者看到原生对象之前,用一个可控的替代品换掉它。这需要精确的时序控制(必须第一个 import)。
|
||||
- `NODE_ENV='production'` 是**消除策略**:通过编译期 DCE 让问题代码根本不存在于产物中。不需要时序控制,因为代码已经被删除了。
|
||||
|
||||
`query.ts:367` 的 `clearMarks` 兜底是第三种策略——**缓解策略**:问题已经发生了,但至少不让它继续恶化。它承认 shim 可能没装上,而 C++ Vector 已经在泄漏了。
|
||||
|
||||
三层防御,从"预防"到"消除"到"缓解",覆盖了不同场景下的内存泄漏路径。这种分层不是过度工程——每一层对应的失败模式都不一样,而且每一层的失败概率都不为零。
|
||||
|
||||
## 延伸阅读
|
||||
- 想看 JSC 的另一个内存陷阱(eager parsing 导致 17MB 单文件暴食 1GB),见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md)
|
||||
- 想理解 `process.env.NODE_ENV` 编译期替换背后的 Bun 编译器 DCE 机制,见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看 `query.ts` 的 finally 块在更大上下文中的作用(async generator 的生命周期管理),见 [第四章:核心 Query Loop —— 为什么 query() 是 async generator](./04-query-loop.md)
|
||||
- 想了解 Langfuse span 引用链如何与 OTel 的 `otperformance` 串联,见 [第十一章:状态管理](./11-state-management.md)
|
||||
261
docs/outline-output/design/04-query-loop.md
Normal file
261
docs/outline-output/design/04-query-loop.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# 第四章:核心 Query Loop -- 为什么 query() 是 async generator
|
||||
|
||||
> 流式响应把"结果"与"副作用"解耦,调用方选择性消费——这是 async generator 而不是回调或事件发射器的根本原因。
|
||||
|
||||
## async generator vs 回调:为什么用 yield 而不是 EventEmitter
|
||||
|
||||
打开 `src/query.ts:276`,你会看到整个 query loop 的核心签名:
|
||||
|
||||
```ts
|
||||
export async function* query(
|
||||
params: QueryParams,
|
||||
): AsyncGenerator<
|
||||
| StreamEvent
|
||||
| RequestStartEvent
|
||||
| Message
|
||||
| TombstoneMessage
|
||||
| ToolUseSummaryMessage,
|
||||
Terminal
|
||||
>
|
||||
```
|
||||
|
||||
返回类型是 `AsyncGenerator<YieldedType, ReturnType>`。每次 `yield` 产出一个消息,最终 `return` 一个 `Terminal` 对象。这个设计不是风格偏好——它解决了一个具体的架构问题:**谁控制消息的流向**。
|
||||
|
||||
如果用 EventEmitter,调用方需要注册多个 listener(`on('message')`, `on('error')`, `on('end')`),然后在一个外部数组里手动拼装消息流。事件的消费者和 query loop 的执行是解耦的——你不知道 loop 在 yield 消息的时候自己处于什么状态。
|
||||
|
||||
如果用 callback,调用方需要在 callback 里处理分支逻辑:这是 tool_use 还是 thinking block?是否需要 withhold?这些分支本质上属于 query loop 的内部状态机,但 callback 把它们推给了调用方。
|
||||
|
||||
async generator 把状态机留在 loop 内部,只把"我现在有一个消息给你"这个事实暴露出去。调用方写一个简单的 `for await`,里面只关心"拿到消息后做什么",不需要知道 loop 有几条 continue 路径、是否在 withhold 错误、是否正在重试 fallback 模型。
|
||||
|
||||
反事实推演:如果用 EventEmitter,`QueryEngine` 在 `src/QueryEngine.ts:688` 的消费循环会变成一个散落着 `if` 分支的事件处理器,而不是一个线性的 `switch (message.type)` 结构。更关键的是,`yield` 天然支持背压——调用方没消费完,loop 就不继续。EventEmitter 没有这个能力,消息会在内存里堆积。
|
||||
|
||||
## queryLoop() 的委托模式:两层 generator 的分离
|
||||
|
||||
`query()` 本身并不直接包含 `while (true)` 循环。它做的是三件事:初始化 Langfuse trace、委托给 `queryLoop()`、在 finally 块里清理资源和通知命令生命周期。
|
||||
|
||||
打开 `src/query.ts:393`,你会看到 `queryLoop()`:
|
||||
|
||||
```ts
|
||||
async function* queryLoop(
|
||||
params: QueryParams,
|
||||
consumedCommandUuids: string[],
|
||||
consumedAutonomyCommands: QueuedCommand[],
|
||||
): AsyncGenerator<...> {
|
||||
```
|
||||
|
||||
`query()` 用 `yield*` 把自己变成 `queryLoop()` 的透明管道。`yield*` 委托意味着 `query()` 产出的每一条消息都来自 `queryLoop()`,但 `query()` 的 finally 块在 `queryLoop()` 结束(无论是正常 return 还是 throw)后一定会执行。
|
||||
|
||||
为什么要把清理逻辑放在外层 generator 的 finally 里?因为 `queryLoop()` 内部有 7 个 `state = next; continue` 跳转点(打开 `src/query.ts:1372`、`src/query.ts:1437`、`src/query.ts:1524`、`src/query.ts:1581`、`src/query.ts:1616` 等处),每个跳转都可能因为新状态而触发不同路径。如果把清理分散在每个 return 之前,任何一个遗漏都会泄漏。`yield*` 的保证是:无论内层 generator 怎么退出,外层 finally 一定跑。
|
||||
|
||||
打开 `src/query.ts:367` 看那个 finally 块在做什么:
|
||||
|
||||
```ts
|
||||
const gPerf = globalThis.performance
|
||||
if (gPerf && typeof gPerf.clearMarks === 'function') {
|
||||
try {
|
||||
gPerf.clearMarks()
|
||||
gPerf.clearMeasures?.()
|
||||
gPerf.clearResourceTimings?.()
|
||||
} catch { }
|
||||
}
|
||||
```
|
||||
|
||||
这是上一章讲过的 `performanceShim` 的兜底防线。如果 sub-agent 直接 import `query.ts` 而没经过 `cli.tsx` 的 shim 注入,JSC 原生 Performance 的 C++ Vector 仍然会在每轮循环中膨胀。finally 块在这里做了最后一道清理。
|
||||
|
||||
## thinking 块的三条硬约束
|
||||
|
||||
打开 `src/query.ts:181`,你会看到一段罕见的、用中世纪英语风格写的注释:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* The rules of thinking are lengthy and fortuitous. ...
|
||||
*
|
||||
* The rules follow:
|
||||
* 1. A message that contains a thinking or redacted_thinking block must be part of a query whose max_thinking_length > 0
|
||||
* 2. A thinking block may not be the last message in a block
|
||||
* 3. Thinking blocks must be preserved for the duration of an assistant trajectory
|
||||
* (a single turn, or if that turn includes a tool_use block then also its
|
||||
* subsequent tool_result and the following assistant message)
|
||||
*
|
||||
* Heed these rules well, young wizard. For they are the rules of thinking, and
|
||||
* the rules of thinking are the rules of the universe. If ye does not heed these
|
||||
* rules, ye will be punished with an entire day of debugging and hair pulling.
|
||||
*/
|
||||
```
|
||||
|
||||
这三条规则是 Anthropic API 的硬性约束。违反任何一条都会得到 400 错误。反编译者在这里留下了这段风格化的注释,因为他们在调试时确实被这些规则惩罚过。
|
||||
|
||||
规则 1 意味着:如果启用了 thinking,`max_thinking_length` 参数必须大于 0。否则 API 拒绝带 thinking block 的请求。
|
||||
|
||||
规则 2 意味着:thinking block 后面必须有内容(text 或 tool_use)。不能以 thinking 结束一条消息。在恢复循环(下文讲)中,这决定了 recovery message 的构造方式——你不能只发一个 thinking block,必须在后面跟一个续写指令。
|
||||
|
||||
规则 3 意味着:thinking block 的生命周期是整个"assistant 轨迹"——一次单轮,或者如果那次调用了工具,还包括工具结果和下一轮 assistant 回复。这意味着在工具执行的中间步骤里,thinking block 必须原封不动地保留在消息历史中。不能因为压缩或 compact 而把 thinking block 从轨迹中摘出去。
|
||||
|
||||
反事实推演:如果没有规则 3,compact 算法可以把 thinking block 当作普通内容摘要掉。但 API 校验会 400,所以 compact 逻辑必须特别处理 thinking block——要么保留,要么在 compact 前把它从轨迹里剥离。这增加了 compact 的复杂性,但无法绕过。
|
||||
|
||||
## MAX_OUTPUT_TOKENS_RECOVERY_LIMIT=3:扣留错误的恢复博弈
|
||||
|
||||
打开 `src/query.ts:194`:
|
||||
|
||||
```ts
|
||||
const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3
|
||||
```
|
||||
|
||||
这个数字后面藏着一个精巧的设计决策。当 Claude 的输出触及 `max_output_tokens` 上限时,API 返回一个带 `apiError: 'max_output_tokens'` 的 assistant message。正常情况下,这个错误应该直接 yield 给调用方。但问题在于:SDK 调用方(比如 cowork、desktop 客户端)会在收到任何带 `error` 字段的消息时**立即终止会话**。
|
||||
|
||||
打开 `src/query.ts:196` 的注释:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Is this a max_output_tokens error message? If so, the streaming loop should
|
||||
* withhold it from SDK callers until we know whether the recovery loop can
|
||||
* continue. Yielding early leaks an intermediate error to SDK callers (e.g.
|
||||
* cowork/desktop) that terminate the session on any `error` field — the
|
||||
* recovery loop keeps running but nobody is listening.
|
||||
*/
|
||||
```
|
||||
|
||||
这就是为什么有 `isWithheldMaxOutputTokens` 函数(`src/query.ts:205`)。在流式循环中(`src/query.ts:1059`),如果消息是 `max_output_tokens` 错误,它不会被 yield,而是被扣留。
|
||||
|
||||
恢复机制分两个阶段:
|
||||
|
||||
**阶段 1:升级重试。** 如果从未设置过 `maxOutputTokensOverride`(意味着使用了默认的 8k 上限),把上限提升到 `ESCALATED_MAX_TOKENS`(`src/query.ts:1472`),然后用 `continue` 重试同一个请求。不需要插入 recovery message——模型拿到更大的上限后能自己续写。这个阶段只触发一次。
|
||||
|
||||
**阶段 2:多轮恢复。** 如果升级后仍然触及上限(或者一开始就用了自定义上限),插入一条 `isMeta: true` 的 user message(`src/query.ts:1497`),内容是 `"Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened."`,然后 `continue` 重试。这个阶段最多触发 3 次(`MAX_OUTPUT_TOKENS_RECOVERY_LIMIT`)。
|
||||
|
||||
3 次是个工程折中:太少会导致长代码生成任务频繁失败,太多会导致无限循环。在极端情况下(模型陷入重复输出),3 次重试足以检测到问题并 surface 错误。
|
||||
|
||||
打开 `src/query/transitions.ts` 可以看到所有 continue 原因的类型定义:
|
||||
|
||||
```ts
|
||||
export type Continue =
|
||||
| { reason: 'collapse_drain_retry'; committed: number }
|
||||
| { reason: 'reactive_compact_retry' }
|
||||
| { reason: 'max_output_tokens_escalate' }
|
||||
| { reason: 'max_output_tokens_recovery'; attempt: number }
|
||||
| { reason: 'stop_hook_blocking' }
|
||||
| { reason: 'token_budget_continuation' }
|
||||
| { reason: 'next_turn' }
|
||||
```
|
||||
|
||||
每个 `continue` 站点都构造一个新的 `State` 对象(`src/query.ts:261`),包含完整的 9 个字段。这不是偷懒——用解构 + 单一赋值 `state = next` 代替 9 个独立赋值,让每个 continue 站点只改它关心的字段,其余字段从解构的旧值自动继承。如果用 9 个独立赋值,任何一个遗漏都会导致状态不一致。
|
||||
|
||||
反事实推演:如果 `max_output_tokens` 错误不被扣留而是直接 yield,SDK 调用方会在 recovery loop 还在跑的时候就断开连接。recovery loop 可能成功续写了剩余内容,但没有人听。用户看到的是一个截断的回答和"出错了"的提示,而实际上再等几秒就能拿到完整结果。
|
||||
|
||||
## QueryEngine:跨 turn 的会话编排器
|
||||
|
||||
`query()` 处理的是单次用户输入到完成(或失败)的完整过程。但一个对话有多个 turn。`QueryEngine`(`src/QueryEngine.ts:192`)就是这个跨 turn 的编排器。
|
||||
|
||||
打开 `src/QueryEngine.ts:192` 的类定义:
|
||||
|
||||
```ts
|
||||
export class QueryEngine {
|
||||
private config: QueryEngineConfig
|
||||
private mutableMessages: Message[]
|
||||
private abortController: AbortController
|
||||
private permissionDenials: SDKPermissionDenial[]
|
||||
private totalUsage: NonNullableUsage
|
||||
private hasHandledOrphanedPermission = false
|
||||
private readFileState: FileStateCache
|
||||
private discoveredSkillNames = new Set<string>()
|
||||
private loadedNestedMemoryPaths = new Set<string>()
|
||||
```
|
||||
|
||||
每个字段都有明确的跨 turn 生命周期:
|
||||
|
||||
- `mutableMessages`:消息历史,跨 turn 不断增长(除非 compact/snip)
|
||||
- `totalUsage`:token 消耗累计,跨 turn 叠加
|
||||
- `readFileState`:文件内容缓存,避免跨 turn 重复读取同一个文件
|
||||
- `discoveredSkillNames`:turn 内发现的新 skill 名称,每个 turn 开始时清空(`src/QueryEngine.ts:246`),防止无限增长
|
||||
|
||||
`submitMessage()` 本身也是 async generator(`src/QueryEngine.ts:217`):
|
||||
|
||||
```ts
|
||||
async *submitMessage(
|
||||
prompt: string | ContentBlockParam[],
|
||||
options?: { uuid?: string; isMeta?: boolean },
|
||||
): AsyncGenerator<SDKMessage, void, unknown>
|
||||
```
|
||||
|
||||
它在内部调用 `query()`(`src/QueryEngine.ts:688`),但做了三件 `query()` 不管的事:
|
||||
|
||||
1. **消息持久化**:在进入 query loop 之前就把用户消息写入 transcript(`src/QueryEngine.ts:460`),确保即使进程在 API 响应到达前被杀死,`--resume` 也能恢复到发送点。
|
||||
|
||||
2. **SDK 消息转换**:把内部 `Message` 类型转换为 `SDKMessage` 格式,通过 `normalizeMessage` 做字段映射(`src/QueryEngine.ts:789`)。
|
||||
|
||||
3. **权限拒绝追踪**:通过 `wrappedCanUseTool`(`src/QueryEngine.ts:253`)包装每个工具调用的权限检查,记录拒绝事件到 `permissionDenials`,最终随 `result` 消息返回给 SDK 调用方。
|
||||
|
||||
为什么不把这些逻辑放进 `query()` 里?因为 `query()` 需要保持与 UI 路径(REPL screen)的通用性。REPL 不做 transcript 持久化(它有自己的会话管理),不需要 SDK 消息转换。`QueryEngine` 是 SDK/Headless 路径特有的编排层。
|
||||
|
||||
反事实推演:如果把 transcript 持久化放进 `query()`,REPL 路径也必须处理 transcript 逻辑,要么做条件分支(污染 `query()` 的纯净性),要么 REPlay 模式也写 transcript(造成重复写入)。分离后,`query()` 保持通用,`QueryEngine` 专注 SDK 语义。
|
||||
|
||||
## snipReplay 回调:feature gate 的依赖注入技巧
|
||||
|
||||
打开 `src/QueryEngine.ts:166`,你会看到 `snipReplay` 字段的注释:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Snip-boundary handler: receives each yielded system message plus the
|
||||
* current mutableMessages store. Returns undefined if the message is not a
|
||||
* snip boundary; otherwise returns the replayed snip result. Injected by
|
||||
* ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside
|
||||
* the gated module (keeps QueryEngine free of excluded strings and testable
|
||||
* despite feature() returning false under bun test).
|
||||
*/
|
||||
```
|
||||
|
||||
这是一个精心设计的依赖注入模式。`QueryEngine` 本身不 import `snipCompact.js`——它只定义了一个回调接口。实际的 snip 逻辑在 `src/QueryEngine.ts:1346` 处,由工厂函数有条件地注入:
|
||||
|
||||
```ts
|
||||
...(feature('HISTORY_SNIP')
|
||||
? {
|
||||
snipReplay: (yielded: Message, store: Message[]) => {
|
||||
if (!snipProjection!.isSnipBoundaryMessage(yielded))
|
||||
return undefined
|
||||
return snipModule!.snipCompactIfNeeded(store, { force: true })
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
```
|
||||
|
||||
当 `HISTORY_SNIP` 关闭时(包括 `bun test` 环境下 `feature()` 返回 `false`),`snipReplay` 就是 `undefined`。`QueryEngine` 在 `src/QueryEngine.ts:948` 用可选链调用它:
|
||||
|
||||
```ts
|
||||
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
|
||||
```
|
||||
|
||||
这样做解决了两个问题:
|
||||
|
||||
**问题 1:excluded-strings 检查。** `snipCompact.js` 里包含 snip 特有的字符串(边界消息文本等)。如果 `QueryEngine` 直接 import 它,即使在 feature 关闭时,这些字符串也会被 bundle 进产物,触发内部的 excluded-strings 安全检查。通过回调注入,feature 关闭时 `snipCompact.js` 根本不会被 import。
|
||||
|
||||
**问题 2:测试隔离。** `bun test` 下 `feature()` 永远返回 `false`。如果 `QueryEngine` 直接依赖 `feature('HISTORY_SNIP')` 的结果来决定控制流,测试时所有 snip 分支都是死代码。通过回调注入,测试时 `snipReplay` 是 `undefined`,所有 snip 逻辑被跳过,`QueryEngine` 的主路径仍然可测。想要测试 snip 行为的测试可以手动注入一个 mock 回调。
|
||||
|
||||
反事实推演:如果不用回调注入而是直接在 `QueryEngine` 里写 `if (feature('HISTORY_SNIP')) { snipModule.snipCompactIfNeeded(...) }`,`bun test` 下这个分支永远不执行。测试无法覆盖 snip 的边界情况。更糟的是,每次有人改了 `snipCompact.js` 的导出签名,`QueryEngine` 的类型检查也会报错——即使 feature 关闭时这段代码根本不会运行。
|
||||
|
||||
## 无限循环的 `while(true)` 和它 7 个出口
|
||||
|
||||
回到 `queryLoop()` 的 `src/query.ts:460`:
|
||||
|
||||
```ts
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
```
|
||||
|
||||
这不是失控的循环。它是一个有限状态机,每个 `continue` 都带着一个明确的 `transition` 原因(记录在 `src/query/transitions.ts:13` 的 `Continue` 类型中)。循环出口有三类:
|
||||
|
||||
**正常退出(return Terminal):** `completed`(`src/query.ts:1633`)、`blocking_limit`(`src/query.ts:830`)、`image_error`(`src/query.ts:1224`)、`model_error`(`src/query.ts:1243`)、`aborted_streaming`(`src/query.ts:1324`)、`stop_hook_prevented`(`src/query.ts:1555`)、`prompt_too_long`(`src/query.ts:1448`)、`max_turns`。
|
||||
|
||||
**异常退出(throw):** 任何未被内层 try/catch 捕获的异常会向上传播,`query()` 的外层 finally 块负责清理。
|
||||
|
||||
**continue 跳转(state = next; continue):** 7 个跳转点覆盖恢复场景:context collapse drain retry、reactive compact retry、max_output_tokens 升级、max_output_tokens 多轮恢复、stop hook blocking、token budget continuation、next turn(工具调用后的下一轮)。
|
||||
|
||||
每个 continue 站点构造一个完整的新 `State` 对象。这不是冗余——`State` 类型有 9 个字段,其中 `transition` 字段记录了"为什么继续"。测试可以断言 `state.transition?.reason === 'max_output_tokens_recovery'` 来验证恢复路径是否被触发,而不需要检查消息内容。
|
||||
|
||||
反事实推演:如果不用统一的 `State` 对象而是用散落的变量赋值(`messages = newMessages; toolUseContext = newCtx; maxOutputTokensRecoveryCount++`),任何一个 continue 站点漏了一个变量都会导致后续迭代读到过期的状态。`state = { ...state, messages, toolUseContext, ... }` 的模式虽然看起来啰嗦,但保证了每次跳转都是原子替换。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 query loop 的内存防线(performanceShim),见 [第三章](./03-performance-shim.md)
|
||||
- 想看 feature flag 为什么让 `query()` 顶部的 conditional require 成为必须,见 [第五章](./05-feature-flags.md)
|
||||
- 想看 QueryEngine 的上层状态管理(bootstrap/state.ts 的 singleton 限制),见 [第十一章](./11-state-management.md)
|
||||
- 想看 query loop 里的 compact 子系统如何被触发,见产品大纲第三章"上下文管理与自动压缩"
|
||||
265
docs/outline-output/design/05-feature-flags.md
Normal file
265
docs/outline-output/design/05-feature-flags.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# 第五章:Feature Flag 系统的三个硬约束
|
||||
|
||||
> `feature()` 不是普通函数,它是 Bun 编译器用来做死代码消除的语法标记。
|
||||
|
||||
打开 `src/types/internal-modules.d.ts:10`,你会看到这样一行声明:
|
||||
|
||||
```ts
|
||||
declare module 'bun:bundle' {
|
||||
export function feature(name: string): boolean
|
||||
}
|
||||
```
|
||||
|
||||
这是一个虚假的模块声明 -- `bun:bundle` 不存在于文件系统上,也不是 npm 包。它是 Bun 编译器在打包(`Bun.build()`)时内建的编译期原语。当 `Bun.build()` 看到 `feature('X')` 时,它会根据构建配置中的 `features` 列表决定把调用点替换为 `true` 或 `false`,然后对所有不可达分支执行死代码消除(Dead Code Elimination,DCE)。
|
||||
|
||||
反编译重建之后,这个原语不再由编译器直接提供,必须通过类型声明 + 双构建管线各自模拟。这带来了三个硬约束,贯穿了整个代码库的每一个 feature-gated 代码块。
|
||||
|
||||
## 约束一:`feature()` 只能出现在 `if` 条件或三元表达式的位置
|
||||
|
||||
CLAUDE.md 里有一条铁律:
|
||||
|
||||
> `feature()` 只能直接用在 `if` 语句或三元表达式的条件位置,不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。
|
||||
|
||||
打开 `src/hooks/useReplBridge.tsx:117`,你能看到一段注释精确解释了为什么:
|
||||
|
||||
```ts
|
||||
// feature() check must use positive pattern for dead code elimination —
|
||||
// negative pattern (if (!feature(...)) return) does NOT eliminate
|
||||
// dynamic imports below.
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
```
|
||||
|
||||
这个约束的根源是 Bun 编译器 AST 模式匹配的局限性。编译器只识别两种模式:
|
||||
|
||||
1. `if (feature('X')) { ... }` -- 把 `feature('X')` 替换为 `false` 后,整个代码块变成 `if (false) { ... }`,DCE 可以整块删除。
|
||||
2. `feature('X') ? a : b` -- 替换后变成 `false ? a : b` 或 `true ? a : b`,DCE 可以删掉不会走的分支。
|
||||
|
||||
如果你写成 `const enabled = feature('X'); if (enabled) { ... }`,编译器看到的是对变量 `enabled` 的判断,无法确定其值为常量,整个 feature-gated 代码块都会保留在产物里。
|
||||
|
||||
**反事实推演**:如果 `feature()` 能赋值给变量,整个 `tools.ts` 的条件导入模式就不需要那么别扭的 `feature('X') ? require(...) : null` 三元表达式了。你可以写 `const enabled = feature('X'); const tool = enabled ? require(...) : null;`,代码可读性会好很多。但代价是:所有被 gate 的代码(包括 `require()` 引用不存在的文件)都会被打进产物,运行时可能触发 `MODULE_NOT_FOUND` 崩溃。
|
||||
|
||||
### 正面模式与负面模式的陷阱
|
||||
|
||||
`src/hooks/useReplBridge.tsx:117` 提到了另一个细微之处:**正面模式**(`if (feature('X'))`)才能触发 DCE,**负面模式**(`if (!feature('X')) return`)不行。
|
||||
|
||||
打开 `src/entrypoints/cli.tsx:165` 看一个正面模式的例子:
|
||||
|
||||
```ts
|
||||
if (!feature('DAEMON')) {
|
||||
console.error('Error: --daemon-worker requires DAEMON feature...');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
这里用了 `!feature('DAEMON')`,但注意后面的 `return` 是从 `main()` 函数退出的,不是 return 从一个 require 块。DCE 只需要把 `feature('DAEMON')` 替换为 `false` 后变成 `if (!false)` 即 `if (true)`,保留这个检查分支没问题。真正的问题是当 feature 为 true 时,Bun 需要把 `require('../daemon/workerRegistry.js')` 打进产物 -- 这要求文件存在。如果 DAEMON 在构建 features 列表里,一切正常;如果不在,那 `require()` 所在的分支因为 `!feature()` 为 `false` 会被 DCE 删掉。
|
||||
|
||||
关键区别在于:**`if (feature('X'))` 包裹的 `require()` 路径在 `X=false` 时被 DCE 删除**,所以文件可以不存在。但 **`if (!feature('X'))` 包裹的 `require()` 路径在 `X=true` 时必须存在**,因为 DCE 保留的是 `else` 分支。
|
||||
|
||||
## 约束二:`if (false)` 必须在 parse 阶段可见,否则 bundler 会崩溃
|
||||
|
||||
这是 Vite/Rollup 构建管线独有的约束。打开 `scripts/vite-plugin-feature-flags.ts:29`,你会看到注释:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Vite/Rollup plugin that replaces `feature('X')` calls with boolean literals
|
||||
* at the transform stage, BEFORE the bundler resolves imports.
|
||||
*
|
||||
* This approach is necessary because some feature-gated code blocks contain
|
||||
* require() calls to files that don't exist (e.g. hunter.js inside
|
||||
* feature('REVIEW_ARTIFACT')). The bundler must see these as dead code
|
||||
* (`if (false) { ... }`) before attempting import resolution.
|
||||
*/
|
||||
```
|
||||
|
||||
打开 `src/skills/bundled/index.ts:44`,看这个致命的模式:
|
||||
|
||||
```ts
|
||||
if (feature('REVIEW_ARTIFACT')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { registerHunterSkill } = require('./hunter.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
registerHunterSkill()
|
||||
}
|
||||
```
|
||||
|
||||
文件 `src/skills/bundled/hunter.js` **不存在**。你可以在终端里验证:`ls src/skills/bundled/hunter.js` 返回 "No such file or directory"。代码库中完全找不到任何名为 `hunter*` 的文件。
|
||||
|
||||
这在 `Bun.build()` 管线下不是问题 -- Bun 的打包器知道 `feature('REVIEW_ARTIFACT')` 返回 `false`(因为它不在 `DEFAULT_BUILD_FEATURES` 列表里,见 `scripts/defines.ts:72` 的注释),直接 DCE 掉整个 `if` 块,从来不会尝试解析 `./hunter.js`。
|
||||
|
||||
但 Vite/Rollup 不同。Rollup 的处理管线是:resolve imports -> transform -> bundle。如果 Vite 在 transform 之前尝试 resolve imports,它会看到 `require('./hunter.js')` 然后 `MODULE_NOT_FOUND` 崩溃。
|
||||
|
||||
这就是为什么 `vite-plugin-feature-flags.ts` 必须在 `transform` 阶段(而非 `load` 或 `resolveId` 阶段)替换 `feature('X')` 调用。打开 `scripts/vite-plugin-feature-flags.ts:54`,`transform` 函数用正则匹配替换:
|
||||
|
||||
```ts
|
||||
transform(code, id) {
|
||||
if (id.includes('node_modules')) return null
|
||||
let transformed = code.replace(FEATURE_CALL_RE, (match, flagName) => {
|
||||
return features.has(flagName) ? 'true' : 'false'
|
||||
})
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
替换发生在 `resolveId` 之后、bundle 之前。这样 Rollup 看到 `if (false) { require('./hunter.js') }` 就知道整个分支不可达,不会尝试解析 `./hunter.js`。
|
||||
|
||||
插件还提供了一个虚拟模块解决 `import { feature } from 'bun:bundle'` 的 "module not found" 错误(`scripts/vite-plugin-feature-flags.ts:47`):
|
||||
|
||||
```ts
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
return 'export function feature(name) { return false; }'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个 stub 的 `return false` 在运行时永远不会被调用,因为所有 `feature()` 调用都在 `transform` 阶段被替换成了字面量。它存在的唯一意义是让 Rollup 不报 unresolved import 错误。
|
||||
|
||||
**反事实推演**:如果 `transform` 替换不够早,Vite 构建管线在遇到任何引用不存在文件的 feature-gated `require()` 时都会崩溃。这意味着所有被注释掉的 feature(`CONTEXT_COLLAPSE`、`UDS_INBOX`、`REVIEW_ARTIFACT` 等)在 Vite 管线下都是"定时炸弹" -- 只要它们的代码块里有 `require()` 指向不存在的文件,替换时机不对就会炸。
|
||||
|
||||
## 约束三:Vite 的 `using` 声明必须 transpile,否则 Node.js 崩溃
|
||||
|
||||
`vite-plugin-feature-flags.ts` 在 feature flag 替换之外还承担了一项额外职责。打开 `scripts/vite-plugin-feature-flags.ts:68`:
|
||||
|
||||
```ts
|
||||
// 2. Transpile `using _ = expr;` to `const _ = expr;` for Node.js compat.
|
||||
// Node.js v22 does not support `using` declarations (Explicit Resource Management).
|
||||
// Safe because: SLOW_OPERATION_LOGGING is not enabled, so slowLogging returns
|
||||
// a no-op disposable whose [Symbol.dispose]() is empty.
|
||||
if (transformed.includes('using _')) {
|
||||
transformed = transformed.replace(/\busing\s+(_\w*)\s*=/g, 'const $1 =')
|
||||
modified = true
|
||||
}
|
||||
```
|
||||
|
||||
这段正则把所有 `using _x = expr` 替换成 `const _x = expr`。注释解释了安全性前提:`SLOW_OPERATION_LOGGING` 未启用时,`slowLogging` 返回的 disposable 的 `[Symbol.dispose]()` 是空操作,所以 `using` 和 `const` 行为等价。
|
||||
|
||||
但这里有一条脆弱的依赖链:如果有人启用了 `SLOW_OPERATION_LOGGING` 并在 Vite 构建产物上用 Node.js 运行,资源清理就不会执行 -- `using` 的 `Symbol.dispose` 语义被丢弃了。
|
||||
|
||||
**反事实推演**:如果不做这个 transpile,Vite 构建的产物在 Node.js v22 上会直接 `SyntaxError: Unexpected token 'using'`。这意味着整个 "产物兼容 bun/node" 的承诺(`build.ts` 的 post-build `import.meta.require` 补丁)在 Vite 管线上多了一个前提条件。
|
||||
|
||||
## 三层切换机制:Build 默认、Dev 全开、运行时环境变量
|
||||
|
||||
打开 `scripts/defines.ts:39`,你会看到 `DEFAULT_BUILD_FEATURES` 列表,65+ 个 feature flag 中大约有 40 个默认启用,其余被注释掉。打开 `scripts/dev.ts:39`,dev 模式使用同一个列表:
|
||||
|
||||
```ts
|
||||
const allFeatures = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])]
|
||||
const featureArgs = allFeatures.flatMap(name => ['--feature', name])
|
||||
```
|
||||
|
||||
但 dev 模式可以通过 `FEATURE_<NAME>=1` 环境变量额外启用。例如 `FEATURE_REVIEW_ARTIFACT=1 bun run dev` 会尝试启用 `REVIEW_ARTIFACT`,然后代码会尝试 `require('./hunter.js')`,由于文件不存在而崩溃。
|
||||
|
||||
三层机制的行为差异:
|
||||
|
||||
| 层级 | 何时生效 | feature() 的值 | DCE 是否生效 |
|
||||
|------|----------|---------------|-------------|
|
||||
| `Bun.build()` | 构建时 | 编译期常量 | 是 -- 不可达代码被删除 |
|
||||
| `vite build` | 构建时(通过 transform 插件) | transform 后的字面量 | 是 -- Rollup 删除不可达分支 |
|
||||
| `bun run dev` | 运行时(通过 `--feature` flag) | 运行时布尔值 | 否 -- 所有分支都在内存中 |
|
||||
|
||||
这意味着 dev 模式下所有 feature-gated 的 `require()` 路径都必须实际存在,否则运行时会崩溃。对 Bun 原生 dev 来说 `--feature` flag 是 Bun 运行时提供的;对 Vite dev 来说 `feature()` 被 transform 插件替换为字面量,运行时不存在 `bun:bundle` 模块。
|
||||
|
||||
## 反编译产物的 stub 陷阱:两类禁用,一个混淆
|
||||
|
||||
`DEFAULT_BUILD_FEATURES` 中被注释掉的 feature 可以分为两类。打开 `scripts/defines.ts:62-72`,看注释中的措辞差异:
|
||||
|
||||
**第一类:反编译丢失导致的空壳 stub**:
|
||||
|
||||
```ts
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
// 'HISTORY_SNIP', // 已禁用:snip 功能暂时关闭
|
||||
```
|
||||
|
||||
这些 feature 在原始 Claude Code 中是完整功能,反编译过程中逻辑丢失,留下的实现要么是空壳(`CONTEXT_COLLAPSE`),要么会破坏核心功能(`HISTORY_SNIP` 启用后 `SnipTool` 出现但上下文管理不正常)。启用它们不是"多了一个功能",而是"引入了一个损坏的功能"。
|
||||
|
||||
**第二类:功能原本就 stubbed 或已废弃**:
|
||||
|
||||
```ts
|
||||
// 'SKILL_LEARNING', // 已禁用
|
||||
// 'TEAMMEM', // 已禁用:依赖 COORDINATOR_MODE,邮箱文件无限增长
|
||||
// 'REVIEW_ARTIFACT', // 已禁用:代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||
```
|
||||
|
||||
`SKILL_LEARNING` 和 `TEAMMEM` 在原始版本中也是 stubbed 或内部工具,并非完整的对外功能。`REVIEW_ARTIFACT` 更有趣 -- 它的 `hunter.js` 根本不存在于反编译产物中,说明要么原始代码中也是动态加载的(但反编译时丢失了),要么是整个 hunter 子系统在某个版本中被删除但 feature gate 的引用没清理干净。
|
||||
|
||||
打开 `src/tools.ts:148`,`ReviewArtifactTool` 的条件加载用的是标准的三元模式:
|
||||
|
||||
```ts
|
||||
const ReviewArtifactTool = feature('REVIEW_ARTIFACT')
|
||||
? require('@claude-code-best/builtin-tools/tools/ReviewArtifactTool/ReviewArtifactTool.js')
|
||||
.ReviewArtifactTool
|
||||
: null
|
||||
```
|
||||
|
||||
打开 `packages/builtin-tools/src/tools/ReviewArtifactTool/` 验证一下 -- 这个目录是存在的,工具实现也完整。但 `hunter.js`(注册 hunter skill 的模块)不存在。这意味着 `REVIEW_ARTIFACT` 是"工具存在但 skill 不存在"的半死状态。
|
||||
|
||||
**如果不区分这两类**,有人可能觉得"注释掉的 feature 只要改一行配置就能启用"。对第二类也许可以,但对第一类,启用 `CONTEXT_COLLAPSE` 会让 auto compact 失效、启用 `UDS_INBOX` 会让 Node.js 构建卡住(`scripts/defines.ts:68` 的注释明确说了)。
|
||||
|
||||
## `const x = feature()` 为什么到处存在
|
||||
|
||||
CLAUDE.md 说 "不能赋值给变量",但你打开 `src/main.tsx:119` 就能看到违反这条规则的代码:
|
||||
|
||||
```ts
|
||||
const coordinatorModeModule = feature('COORDINATOR_MODE')
|
||||
? (require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js'))
|
||||
: null;
|
||||
```
|
||||
|
||||
这不矛盾。CLAUDE.md 说的"不能赋值给变量"指的是你不能把 `feature()` 的返回值单独赋给变量然后在 `if` 里用那个变量。但 `feature() ? a : null` 是三元表达式 -- `feature()` 在条件位置。Bun 编译器的 DCE 看到的是 `feature('X')` 这个 AST 节点在三元条件的根,它知道可以替换。
|
||||
|
||||
同样的模式在 `src/tools.ts:140-158` 中大量出现:
|
||||
|
||||
```ts
|
||||
const SnipTool = feature('HISTORY_SNIP')
|
||||
? require('@claude-code-best/builtin-tools/tools/SnipTool/SnipTool.js').SnipTool
|
||||
: null
|
||||
const ReviewArtifactTool = feature('REVIEW_ARTIFACT')
|
||||
? require('@claude-code-best/builtin-tools/tools/ReviewArtifactTool/ReviewArtifactTool.js').ReviewArtifactTool
|
||||
: null
|
||||
```
|
||||
|
||||
这是 "feature gate + 条件 require + null fallback" 三合一模式。如果 `feature()` 在条件位置,DCE 生效,`require()` 路径在 false 时不会被解析。如果写成 `const enabled = feature('X'); const tool = enabled ? require(...) : null;`,第二行的 require 不在 `feature()` 的 AST 子树里,DCE 无法保证它在 false 时被消除。
|
||||
|
||||
打开 `src/main.tsx:703`,看一个更微妙的三元用法:
|
||||
|
||||
```ts
|
||||
const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT')
|
||||
? {
|
||||
url: undefined,
|
||||
authToken: undefined,
|
||||
dangerouslySkipPermissions: false,
|
||||
}
|
||||
: undefined;
|
||||
```
|
||||
|
||||
这里不是 require,而是一个对象字面量。`feature('DIRECT_CONNECT')` 在三元条件位置,DCE 可以把 false 分支(对象字面量)消除。如果不这么做,`PendingConnect` 类型可能引用的内部模块会被全量引入。
|
||||
|
||||
## feature 字符串本身的 DCE
|
||||
|
||||
还有一个容易被忽略的 DCE 细节。打开 `src/components/TokenWarning.tsx:87`:
|
||||
|
||||
```ts
|
||||
// Each feature() block stands alone so the flag strings DCE from
|
||||
// external builds independently.
|
||||
if (feature('REACTIVE_COMPACT')) {
|
||||
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) {
|
||||
reactiveOnlyMode = true;
|
||||
}
|
||||
}
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
const { isContextCollapseEnabled } =
|
||||
require('../services/contextCollapse/index.js');
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
注释说 "each feature() block stands alone"。为什么不合并成一个 `if (feature('A') || feature('B'))` 块?因为合并后,即使 `A` 和 `B` 都为 false,`else` 分支中的 feature flag 字符串 `'REACTIVE_COMPACT'` 和 `'CONTEXT_COLLAPSE'` 可能不会从产物中消除。独立的 `if` 块让每个 flag 字符串在自己的 DCE 作用域里 -- `feature('X')` 替换为 `false` 后,整个 `if (false) { ... }` 块包括其中的字符串字面量都会被删除。
|
||||
|
||||
这对内部工具来说很重要:feature flag 的名称(如 `CONTEXT_COLLAPSE`)本身可能泄露内部项目代号或功能名称。独立 DCE 确保外部构建的产物里找不到任何被注释掉的 feature 名称。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 feature flag 如何与代码分割交互(为什么 600+ chunks 中的某些 chunks 只在特定 feature 启用时加载),见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md)
|
||||
- 想看入口函数如何用 feature gate 实现零模块加载的快速路径,见 [第二章:入口的 Fast-Path 优先级链](./02-fast-path.md)
|
||||
- 想看工具系统如何用 feature gate 实现延迟加载与白名单过滤,见 [第六章:工具系统的延迟加载与 CORE_TOOLS 白名单](./06-tools-deferred.md)
|
||||
- 想看 biome.json 关闭 42 条规则背后的反编译痕迹,见 [第十五章:biome.json 的 42 条规则关闭](./15-biome-42-rules.md)
|
||||
414
docs/outline-output/design/06-tools-deferred.md
Normal file
414
docs/outline-output/design/06-tools-deferred.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# 第六章:工具系统的延迟加载与 CORE_TOOLS 白名单
|
||||
|
||||
> 60 个工具不塞进同一条 prompt,按需搜索才能活下来。
|
||||
|
||||
## 为什么工具不能一股脑全塞给模型
|
||||
|
||||
Claude Code 有 62 个工具目录(打开 `/Users/konghayao/code/ai/claude-code/packages/builtin-tools/src/tools/` 你能数到),但每次 API 请求不可能把它们全部放进 `tools` 数组。原因很直接:每个工具的 JSON Schema 定义都要消耗 token。一个 MCP server 提供 20 个工具,每个工具的 `input_schema` 加起来可能吃掉几千 token。如果用户同时接入了 5 个 MCP server,光是工具描述就能占掉 context window 的 10% 以上。
|
||||
|
||||
这不是理论推测——代码里有一个自动检测机制。打开 `src/utils/searchExtraTools.ts:45`,你会看到:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_AUTO_SEARCH_EXTRA_TOOLS_PERCENTAGE = 10 // 10%
|
||||
```
|
||||
|
||||
当延迟工具的 schema 总量超过 context window 的 10%,系统自动启用延迟加载。`checkAutoThreshold` 函数(同文件 `:676`)会先用精确的 token 计数 API 衡量延迟工具总量,API 不可用时回退到字符数启发式(每 token 约 2.5 字符,同文件 `:95`)。
|
||||
|
||||
如果不做延迟加载,每次请求都携带全部工具 schema,后果是:prompt cache 频繁失效(工具列表一变,缓存键全部作废),模型在几十个工具中注意力稀释,token 账单膨胀。延迟加载让 tools 数组保持稳定——只有核心工具在里面,新工具按需发现。
|
||||
|
||||
## CORE_TOOLS:38 个"永远在线"的核心工具
|
||||
|
||||
`CORE_TOOLS` 定义在 `src/constants/tools.ts:137`。打开那个文件,你会看到一个 `Set<string>`,注释写得很清楚:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Core tools that are always loaded with full schema at initialization.
|
||||
* These tools are never deferred — they appear in the initial prompt.
|
||||
* All other tools (non-core built-in + all MCP tools) are deferred
|
||||
* and must be discovered via SearchExtraToolsTool / ExecuteExtraTool.
|
||||
*/
|
||||
export const CORE_TOOLS = new Set([
|
||||
// File operations
|
||||
...SHELL_TOOL_NAMES, // 'Bash', 'Shell'
|
||||
FILE_READ_TOOL_NAME, // 'Read'
|
||||
FILE_EDIT_TOOL_NAME, // 'Edit'
|
||||
FILE_WRITE_TOOL_NAME, // 'Write'
|
||||
GLOB_TOOL_NAME, // 'Glob'
|
||||
GREP_TOOL_NAME, // 'Grep'
|
||||
NOTEBOOK_EDIT_TOOL_NAME, // 'NotebookEdit'
|
||||
// Agent & interaction
|
||||
AGENT_TOOL_NAME, // 'Agent'
|
||||
ASK_USER_QUESTION_TOOL_NAME, // 'AskUserQuestion'
|
||||
// Task management
|
||||
TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME,
|
||||
TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME,
|
||||
TASK_LIST_TOOL_NAME, TASK_UPDATE_TOOL_NAME,
|
||||
TODO_WRITE_TOOL_NAME, // 'TodoWrite'
|
||||
// Planning
|
||||
ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_V2_TOOL_NAME,
|
||||
VERIFY_PLAN_EXECUTION_TOOL_NAME,
|
||||
// Web
|
||||
WEB_FETCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME,
|
||||
// Code intelligence
|
||||
LSP_TOOL_NAME,
|
||||
// Skills
|
||||
SKILL_TOOL_NAME,
|
||||
// Workflow orchestration
|
||||
WORKFLOW_TOOL_NAME,
|
||||
// Scheduling & monitoring
|
||||
SLEEP_TOOL_NAME,
|
||||
// Tool discovery (always loaded)
|
||||
SEARCH_EXTRA_TOOLS_TOOL_NAME, EXECUTE_TOOL_NAME,
|
||||
SYNTHETIC_OUTPUT_TOOL_NAME,
|
||||
])
|
||||
```
|
||||
|
||||
这个白名单的设计哲学是:模型完成日常编程任务所需的最小工具集。文件读写编辑搜索、shell 执行、agent 派发、任务管理、计划模式、web 获取、skill 调用——这些是"95% 的对话只需要这些"的工具。
|
||||
|
||||
注意最后三个:`SearchExtraTools`、`ExecuteExtraTool`、`SyntheticOutput`。它们本身是延迟加载机制的入口,所以必须放在核心集里,否则模型就无法发现和使用任何延迟工具——一个自举悖论。
|
||||
|
||||
### 反事实推演:如果把所有工具都放进 CORE_TOOLS
|
||||
|
||||
假设 `CORE_TOOLS` 包含全部 62 个工具。最直接的后果是每次 API 请求的 `tools` 数组体积翻倍甚至翻三倍。对 prompt cache 的影响是致命的:prompt cache 依赖 tools 列表的稳定性。`claude.ts:393` 的 `assembleToolPool` 注释里明确提到:
|
||||
|
||||
> The server's claude_code_system_cache_policy places a global cache breakpoint after the last prefix-matched built-in tool; a flat sort would interleave MCP tools into built-ins and invalidate all downstream cache keys whenever an MCP tool sorts between existing built-ins.
|
||||
|
||||
如果所有 MCP 工具都在核心集里,任何一次 MCP server 的连接/断开都会让下游所有缓存键失效。延迟加载把 MCP 工具完全排除在初始 tools 数组之外(`claude.ts:1188-1200`),保持了缓存稳定性。
|
||||
|
||||
## isDeferredTool 的判定逻辑
|
||||
|
||||
`isDeferredTool` 定义在 `packages/builtin-tools/src/tools/SearchExtraToolsTool/prompt.ts:69`。逻辑出奇地简单:
|
||||
|
||||
```typescript
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad']
|
||||
if (tool.alwaysLoad === true) return false
|
||||
|
||||
// Core tools are always loaded — never deferred
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
|
||||
// Everything else (non-core built-in + all MCP tools) is deferred
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
三条规则,没有灰色地带。要么你在 `CORE_TOOLS` 里,要么你设置了 `alwaysLoad: true`(一种 opt-out 机制,给需要特殊处理的工具留了口子),否则你就是延迟工具。所有 MCP 工具天然是延迟工具——MCP 工具的 `name` 以 `mcp__` 开头,永远不会出现在 `CORE_TOOLS` 里。
|
||||
|
||||
这个函数在 `claude.ts:1160-1166` 被调用时有一个性能注释:
|
||||
|
||||
```typescript
|
||||
// Precompute once — isDeferredTool does 2 GrowthBook lookups per call
|
||||
const deferredToolNames = new Set<string>()
|
||||
if (useSearchExtraTools) {
|
||||
for (const t of tools) {
|
||||
if (isDeferredTool(t)) deferredToolNames.add(t.name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
每次 `isDeferredTool` 调用内部会触发 GrowthBook(feature flag 平台)的远程配置查询,所以对整个工具列表遍历时必须预计算一次,缓存到 Set 里。这是反编译产物的一个典型痕迹——原版 Anthropic 代码依赖的 GrowthBook 实例在这个 fork 里被替换为空实现,但查询调用的结构保留了下来。
|
||||
|
||||
## SearchExtraToolsTool:两步发现协议
|
||||
|
||||
延迟工具的发现不是一次性完成的——它是一个两步协议,写死在 `SearchExtraToolsTool` 的 prompt 里(`prompt.ts:26-60`)。
|
||||
|
||||
第一步:模型调用 `SearchExtraTools`,传入查询字符串。系统搜索延迟工具池,返回匹配的工具名列表。
|
||||
|
||||
第二步:模型调用 `ExecuteExtraTool`,传入目标工具名和参数。系统从全局工具注册表中找到该工具,直接执行。
|
||||
|
||||
打开 `packages/builtin-tools/src/tools/SearchExtraToolsTool/SearchExtraToolsTool.ts:380`,你会看到第一步中 `select:` 前缀的处理:
|
||||
|
||||
```typescript
|
||||
const selectMatch = query.match(/^select:(.+)$/i)
|
||||
if (selectMatch) {
|
||||
const requested = selectMatch[1]!
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const found: string[] = []
|
||||
const alreadyLoaded: string[] = []
|
||||
const missing: string[] = []
|
||||
for (const toolName of requested) {
|
||||
const deferredMatch = findToolByName(deferredTools, toolName)
|
||||
const fullMatch = deferredMatch ?? findToolByName(tools, toolName)
|
||||
if (fullMatch) {
|
||||
if (!found.includes(fullMatch.name)) {
|
||||
found.push(fullMatch.name)
|
||||
if (!deferredMatch) {
|
||||
alreadyLoaded.push(fullMatch.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
missing.push(toolName)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
一个值得注意的细节:如果模型尝试 `select:` 一个已经是核心工具的名字,系统不会报错,而是把它放进 `alreadyLoaded` 列表返回。`mapToolResultToToolResultBlockParam` 方法(同文件 `:542`)会明确告诉模型:
|
||||
|
||||
```
|
||||
Already loaded as core tool(s): Read. Call these directly using your normal tool interface — do NOT use ExecuteExtraTool for them.
|
||||
```
|
||||
|
||||
这不是防御性编程的冗余——它防止了模型在压缩(compact)后丢失上下文时,对已知工具发起无意义的搜索-执行循环。反编译产物中这种"防止模型犯蠢"的引导文本随处可见,说明原版代码在生产环境中确实遇到了模型行为退化的问题。
|
||||
|
||||
### 查询语法:四种子模式
|
||||
|
||||
`SearchExtraToolsTool` 支持四种查询格式,定义在 `prompt.ts:53-56`:
|
||||
|
||||
- `"select:CronCreate"` — 精确选择,支持逗号分隔多选
|
||||
- `"select:CronCreate,CronList"` — 多工具一次发现
|
||||
- `"discover:schedule cron job"` — 纯发现模式,返回工具名 + 描述 + schema,不触发加载
|
||||
- `"notebook jupyter"` — 关键词搜索,TF-IDF 语义匹配
|
||||
- `"+slack send"` — 前缀 `+` 表示必须包含的词,类似搜索引擎的强制匹配
|
||||
|
||||
`discover:` 模式的设计意图很巧妙:模型可以先了解一个延迟工具的 schema 结构,再决定是否执行。打开 `SearchExtraToolsTool.ts:444`,discover 分支会返回 TF-IDF 搜索结果,包含每个工具的名字、描述和完整 JSON Schema——模型读完这些信息后再构建正确的参数调用 `ExecuteExtraTool`。
|
||||
|
||||
## TF-IDF 索引:复用 skill 搜索的算法引擎
|
||||
|
||||
工具搜索和 skill 搜索共享同一套 TF-IDF 算法。打开 `src/services/searchExtraTools/toolIndex.ts:1`,导入语句直接指向 skill 搜索模块:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
tokenizeAndStem,
|
||||
computeWeightedTf,
|
||||
computeIdf,
|
||||
cosineSimilarity,
|
||||
} from '../skillSearch/localSearch.js'
|
||||
```
|
||||
|
||||
这不是代码复用——这是两个子系统在同一算法上的独立实例化。`toolIndex.ts` 的 `buildToolIndex` 函数(`:80`)对每个延迟工具提取三组 token:工具名(权重 3.0)、searchHint(权重 2.5)、描述文本(权重 1.0),然后用 TF-IDF 计算向量:
|
||||
|
||||
```typescript
|
||||
const TOOL_FIELD_WEIGHT = {
|
||||
name: 3.0,
|
||||
searchHint: 2.5,
|
||||
description: 1.0,
|
||||
} as const
|
||||
```
|
||||
|
||||
工具名权重最高是合理的——模型通常知道它要找什么工具(比如 "CronCreate"),问题在于工具名不在核心集里。searchHint 是工具开发者手写的简短能力描述,信号密度比完整描述高得多,所以权重也高于 description。
|
||||
|
||||
### 为什么 skill prefetch 和 tool prefetch 用独立的去重集合
|
||||
|
||||
打开 `src/services/searchExtraTools/prefetch.ts:24`:
|
||||
|
||||
```typescript
|
||||
const discoveredToolsThisSession = new Set<string>()
|
||||
```
|
||||
|
||||
这个 Set 跟踪当前会话中已经发现的延迟工具,防止重复推荐。它有容量上限(`SESSION_TRACKING_MAX = 500`,超过后裁剪到 `SESSION_TRACKING_TRIM_TO = 400`,同文件 `:22-23`),防止长会话内存泄漏。
|
||||
|
||||
CLAUDE.md 里明确指出这个 Set 与 skill prefetch 的去重集合互不影响。为什么?因为两个子系统的生命周期和业务语义不同。工具发现是 per-turn 的——模型每次调用 `SearchExtraTools` 都应该能看到全量延迟工具池,只是已经发现的不会重复推荐。Skill 发现是 per-session 的——一个 skill 一旦推荐过,整会话内都不应该再弹。如果共用一个 Set,工具发现可能会意外吞掉 skill 推荐,或者反过来。两个 Set 各管各的,互不干扰。
|
||||
|
||||
### CJK 大字符集的特殊处理
|
||||
|
||||
`toolIndex.ts:182-188` 有一个针对中日韩文字的特殊处理:
|
||||
|
||||
```typescript
|
||||
if (queryCjkTokens.length > 0 && score > 0) {
|
||||
const matchingCjk = queryCjkTokens.filter(t => entry.tfVector.has(t))
|
||||
if (matchingCjk.length < CJK_MIN_BIGRAM_MATCHES) {
|
||||
const hasAsciiMatch = queryAsciiTokens.some(t => entry.tfVector.has(t))
|
||||
if (!hasAsciiMatch) score = 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CJK 文字的特征是单字匹配噪音极大(一个 "发" 字可能匹配到 "开发"、"发现"、"发明" 等完全不同的概念),所以要求至少 2 个 CJK token 同时匹配(`CJK_MIN_BIGRAM_MATCHES = 2`)才认可搜索结果。这是一个从生产经验中总结出来的启发式——纯粹基于拉丁文字设计的 TF-IDF 算法在 CJK 环境下会产生大量误匹配。
|
||||
|
||||
## claude.ts 的过滤点:延迟工具如何被排除在 API 请求之外
|
||||
|
||||
实际的延迟加载执行点在 `src/services/api/claude.ts:1188-1205`:
|
||||
|
||||
```typescript
|
||||
if (useSearchExtraTools) {
|
||||
// Never include deferred tools in the API tools array — they are invoked
|
||||
// via ExecuteExtraTool which looks them up from the global tool registry
|
||||
// at runtime. Keeping the tools array stable preserves the prompt cache
|
||||
// across turns (discovered tools no longer bloat the tools JSON).
|
||||
filteredTools = tools.filter(tool => {
|
||||
// Always include non-deferred tools (core tools)
|
||||
if (!deferredToolNames.has(tool.name)) return true
|
||||
// Always include SearchExtraToolsTool (so it can discover more tools)
|
||||
if (toolMatchesName(tool, SEARCH_EXTRA_TOOLS_TOOL_NAME)) return true
|
||||
// All other deferred tools are excluded — use ExecuteExtraTool instead
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
filteredTools = tools.filter(
|
||||
t => !toolMatchesName(t, SEARCH_EXTRA_TOOLS_TOOL_NAME),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
这段代码揭示了延迟加载的核心权衡:延迟工具的 schema 完全不发送给模型,模型只能通过 `SearchExtraTools` 获取工具名,通过 `ExecuteExtraTool` 间接调用。这意味着模型在第一次使用某个延迟工具时,没有该工具的参数 schema 作为参考——它必须依赖 `SearchExtraTools` 返回的文本描述来猜测参数结构。
|
||||
|
||||
这就是为什么 `discover:` 查询模式存在:它让模型在执行前先看 schema。也是为什么 `SearchExtraToolsTool.ts:542-600` 的 `mapToolResultToToolResultBlockParam` 方法会返回结构化的引导文本,而不是让模型自由发挥。
|
||||
|
||||
## feature-gated 工具:另一种"延迟"
|
||||
|
||||
延迟加载和 feature flag 是两个独立的机制,但它们在 `tools.ts` 中产生了有趣的交汇。打开 `src/tools.ts:16-60`,你会看到大量这样的模式:
|
||||
|
||||
```typescript
|
||||
const SleepTool =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? require('@claude-code-best/builtin-tools/tools/SleepTool/SleepTool.js')
|
||||
.SleepTool
|
||||
: null
|
||||
|
||||
const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE')
|
||||
? require('@claude-code-best/builtin-tools/tools/RemoteTriggerTool/RemoteTriggerTool.js')
|
||||
.RemoteTriggerTool
|
||||
: null
|
||||
```
|
||||
|
||||
这是 feature flag 的条件导入模式:`feature('X')` 为真时 require 模块,否则为 null。在 `getAllBaseTools()`(同文件 `:217`)中,这些 null 值通过展开运算符被过滤掉:
|
||||
|
||||
```typescript
|
||||
...(SleepTool ? [SleepTool] : []),
|
||||
...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
|
||||
```
|
||||
|
||||
注意这里用了 `require()` 而不是 ESM `import`。原因是 `feature()` 只能在 `if` 条件中直接使用(Bun 编译器的 DCE 限制,详见第五章),而 ESM import 是静态的,无法放在条件分支里。`require()` 是动态的,可以被条件包裹。这种反编译产物特有的模式在整个 `tools.ts` 中反复出现——原始代码可能用了其他方式实现条件加载,但反编译后只能还原为 `require()` + null 检查。
|
||||
|
||||
### 如果不用 require() 而用静态 import
|
||||
|
||||
假设把所有工具改为顶层静态 import:
|
||||
|
||||
```typescript
|
||||
import { SleepTool } from '@claude-code-best/builtin-tools/tools/SleepTool/SleepTool.js'
|
||||
import { RemoteTriggerTool } from '@claude-code-best/builtin-tools/tools/RemoteTriggerTool/RemoteTriggerTool.js'
|
||||
```
|
||||
|
||||
即使 `feature()` 返回 false,这些模块仍然会被加载和初始化。对于大部分工具来说这不是问题,但某些工具在 import 时就会执行副作用(比如注册全局事件监听器或读取环境变量)。`require()` + null 检查确保了 feature 关闭时这些模块的代码完全不会执行。
|
||||
|
||||
此外,Bun 的 DCE(Dead Code Elimination)依赖 `feature()` 在 AST 层面被识别。静态 import 无法被 DCE 裁剪,意味着所有工具代码都会打包进产物——即使永远不会被调用。对于目标是按需加载 600+ chunk 的项目来说,这是不可接受的。
|
||||
|
||||
## SyntheticOutputTool:延迟加载体系中的特殊角色
|
||||
|
||||
`SyntheticOutputTool`(`packages/builtin-tools/src/tools/SyntheticOutputTool/SyntheticOutputTool.ts`)是一个看起来很奇怪的工具。它的名字叫 "StructuredOutput",功能是"接受任意 JSON 输入并原样返回"。
|
||||
|
||||
打开 `SyntheticOutputTool.ts:28`:
|
||||
|
||||
```typescript
|
||||
export const SyntheticOutputTool = buildTool({
|
||||
isMcp: false,
|
||||
isEnabled() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
name: SYNTHETIC_OUTPUT_TOOL_NAME,
|
||||
searchHint: 'return the final response as structured JSON',
|
||||
async call(input) {
|
||||
return {
|
||||
data: 'Structured output provided successfully',
|
||||
structured_output: input,
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
它之所以在 `CORE_TOOLS` 中,是因为它服务于非交互式场景(pipe mode、SDK 调用)。当外部调用者通过 `agent({schema: ...})` 传入一个 JSON schema 时,系统会用 `createSyntheticOutputTool`(同文件 `:116`)创建一个带有 Ajv 验证的版本:
|
||||
|
||||
```typescript
|
||||
export function createSyntheticOutputTool(
|
||||
jsonSchema: Record<string, unknown>,
|
||||
): CreateResult {
|
||||
const cached = toolCache.get(jsonSchema)
|
||||
if (cached) return cached
|
||||
|
||||
const result = buildSyntheticOutputTool(jsonSchema)
|
||||
toolCache.set(jsonSchema, result)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
注意这里的 `WeakMap` 缓存(同文件 `:109`)——同一个 schema 对象的重复创建会被跳过。注释说明了原因:Workflow 脚本在一次运行中可能调用 `agent({schema: ...})` 30-80 次,没有缓存的话每次都要做 `new Ajv() + validateSchema() + compile()`(约 1.4ms 的 JIT 编译),80 次调用就是 ~110ms 的 Ajv 开销;有缓存后降到 ~4ms。
|
||||
|
||||
这个工具在延迟加载体系中的角色是:它是唯一一个在核心集中但"按需配置"的工具。其他核心工具的 schema 是固定的,`SyntheticOutputTool` 的 schema 可以动态注入。
|
||||
|
||||
## 三种工具搜索模式的切换
|
||||
|
||||
`src/utils/searchExtraTools.ts:159-192` 定义了三种工具搜索模式:
|
||||
|
||||
| 模式 | 触发条件 | 行为 |
|
||||
|------|----------|------|
|
||||
| `tst` | `ENABLE_SEARCH_EXTRA_TOOLS=true` 或默认 | 始终延迟加载非核心工具 |
|
||||
| `tst-auto` | `ENABLE_SEARCH_EXTRA_TOOLS=auto` 或 `auto:N` | 当延迟工具 schema 超过 context window N% 时才启用 |
|
||||
| `standard` | `ENABLE_SEARCH_EXTRA_TOOLS=false` | 不延迟加载,所有工具直接暴露 |
|
||||
|
||||
默认行为是 `tst`——始终延迟加载。这意味着即使只有 2 个延迟工具,它们的 schema 也不会出现在初始请求中。`tst-auto` 模式给了用户一个折中选择:延迟工具少的时候全量加载(省去 SearchExtraTools 的额外一轮调用),多了才启用延迟。
|
||||
|
||||
`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` 环境变量仍然作为延迟加载的终极开关——即使 `ENABLE_SEARCH_EXTRA_TOOLS` 未设置,只要这个变量为 true,就强制进入 `standard` 模式。这是历史遗留:早期版本依赖 Anthropic API 的 `tool_reference` beta header 实现延迟加载,禁用 beta 就等于禁用延迟。现在 beta header 已经移除(统一使用自建的 TF-IDF + keyword 搜索),但这个开关被保留了下来。
|
||||
|
||||
## prefetch:提前预测模型需要什么工具
|
||||
|
||||
`prefetch.ts` 实现了一个"预取"机制:在模型的 assistant turn 开始之前,系统就会根据消息历史预测模型可能需要哪些延迟工具。
|
||||
|
||||
打开 `src/services/searchExtraTools/prefetch.ts:94`:
|
||||
|
||||
```typescript
|
||||
export async function startSearchExtraToolsPrefetch(
|
||||
tools: Tools,
|
||||
messages: Message[],
|
||||
): Promise<Attachment[]> {
|
||||
const startedAt = Date.now()
|
||||
const queryText = extractQueryFromMessages(null, messages)
|
||||
if (!queryText.trim()) return []
|
||||
|
||||
try {
|
||||
const index = await getToolIndex(tools)
|
||||
const results = searchTools(queryText, index, 3)
|
||||
|
||||
const newResults = results.filter(
|
||||
r => !discoveredToolsThisSession.has(r.name),
|
||||
)
|
||||
if (newResults.length === 0) return []
|
||||
```
|
||||
|
||||
注意 `extractQueryFromMessages`(从 `skillSearch/prefetch.ts` 导入的共享函数)从消息历史中提取查询文本,然后对延迟工具索引做搜索。预取结果最多返回 3 个匹配(`searchTools(queryText, index, 3)`),过滤掉已发现的工具,然后以 `tool_discovery` attachment 形式注入对话。
|
||||
|
||||
这个预取机制有一个被有意禁用的功能——turn-zero 预取(同文件 `:138-146`):
|
||||
|
||||
```typescript
|
||||
export async function getTurnZeroSearchExtraToolsPrefetch(
|
||||
_input: string,
|
||||
_tools: Tools,
|
||||
): Promise<Attachment | null> {
|
||||
// Disabled: turn-zero user-input tool recommendations caused frequent
|
||||
// popups. Inter-turn discovery (startSearchExtraToolsPrefetch) is still
|
||||
// active and provides non-intrusive suggestions during assistant turns.
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
注释很直白:用户输入第一条消息时就弹出工具推荐太烦了。这说明团队在"信息前置"和"用户打扰"之间做过权衡——预取可以保留在 assistant turn 之间(模型正在思考时悄悄准备),但不能在用户刚打字时就弹出来。
|
||||
|
||||
## 工具池的排序与缓存稳定性
|
||||
|
||||
`src/tools.ts:376-398` 的 `assembleToolPool` 函数有一个精心设计的排序策略:
|
||||
|
||||
```typescript
|
||||
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
|
||||
return uniqBy(
|
||||
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
|
||||
'name',
|
||||
)
|
||||
```
|
||||
|
||||
内置工具排在前面,MCP 工具排在后面,各自按名称排序。`uniqBy` 保证同名工具以内置优先。注释解释了原因:
|
||||
|
||||
> The server's claude_code_system_cache_policy places a global cache breakpoint after the last prefix-matched built-in tool; a flat sort would interleave MCP tools into built-ins and invalidate all downstream cache keys whenever an MCP tool sorts between existing built-ins.
|
||||
|
||||
如果用一个扁平的全局排序,MCP 工具可能插在内置工具之间(比如 `mcp__github__create_issue` 排在 `FileEdit` 和 `FileRead` 之间)。每增加或删除一个 MCP 工具,所有排在它后面的工具的缓存键都会变。分区排序让内置工具的缓存完全不受 MCP 工具变动的影响。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 feature flag 系统如何约束 `require()` 条件导入的写法,见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看 prompt cache 如何依赖工具列表的稳定性,见 [第七章:7-Provider 抽象层的单一调度点](./07-provider-dispatch.md)
|
||||
- 想看 skill prefetch 与 tool prefetch 共享 `extractQueryFromMessages` 的设计,见 [第十二章:ACP / Bridge / Daemon](./12-long-running-modes.md) 中的 ACP 权限管道段
|
||||
- 想看 `performanceShim` 如何在 JSC 内存约束下保护长会话的 tools 处理,见 [第三章:performanceShim](./03-performance-shim.md)
|
||||
248
docs/outline-output/design/07-provider-dispatch.md
Normal file
248
docs/outline-output/design/07-provider-dispatch.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# 第七章:7-Provider 抽象层的单一调度点
|
||||
|
||||
> 一个函数的精确位置,决定了六个兼容层"结构性跳过" Prompt 缓存和 beta 功能——不需要任何一个 feature flag。
|
||||
|
||||
## 为什么有 7 个 Provider,却只有一个调度点
|
||||
|
||||
打开 `src/services/api/claude.ts:1344`,你会看到一个由三个连续 `if` + `return` 组成的调度块:
|
||||
|
||||
```ts
|
||||
// claude.ts:1344-1382
|
||||
if (getAPIProvider() === 'openai') {
|
||||
const { queryModelOpenAI } = await import('./openai/index.js')
|
||||
yield* queryModelOpenAI(messagesForAPI, systemPrompt, tools, signal, options)
|
||||
return
|
||||
}
|
||||
|
||||
if (getAPIProvider() === 'gemini') {
|
||||
const { queryModelGemini } = await import('./gemini/index.js')
|
||||
yield* queryModelGemini(messagesForAPI, systemPrompt, filteredTools, signal, options, thinkingConfig)
|
||||
return
|
||||
}
|
||||
|
||||
if (getAPIProvider() === 'grok') {
|
||||
const { queryModelGrok } = await import('./grok/index.js')
|
||||
yield* queryModelGrok(messagesForAPI, systemPrompt, filteredTools, signal, options)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
三个非 Anthropic Provider 在这个位置被截走,各自的路径 `yield*` 事件后直接 `return`。执行流不会继续往下走。
|
||||
|
||||
往下走的是什么?Anthropic 特有的逻辑——`betas` 注入(`claude.ts:1486`)、`thinking` 配置、`prompt caching`(`claude.ts:1480`)、`buildSystemPromptBlocks`。这些逻辑从第 1384 行一直延伸到函数末尾。兼容层 Provider 因为在第 1344-1382 行就 `return` 了,所以**结构性跳过**了所有 Anthropic 特有的功能。
|
||||
|
||||
这就是整个多 API 兼容层最核心的设计决策:不是用 feature flag 去禁用缓存和 beta,而是让调度点的位置天然形成一条分界线。分界线之前的代码是共享的(消息归一化、工具过滤、媒体剔除),分界线之后的代码是 Anthropic 独占的。
|
||||
|
||||
如果不这么做——如果缓存逻辑在调度点之前运行——你就需要给每个非 Anthropic Provider 加 `if (provider === 'anthropic')` 的条件包裹。代码会变成条件分支的嵌套地狱,每加一个 Provider 就多一层。
|
||||
|
||||
## 调度点之前:共享预处理做了什么
|
||||
|
||||
从 `claude.ts` 函数入口到第 1344 行之间,所有 Provider 共用同一条预处理管道。按顺序:
|
||||
|
||||
1. **消息归一化**(`claude.ts:1290`)——`normalizeMessagesForAPI(messages, filteredTools)` 把内部消息格式转成 API 需要的格式
|
||||
2. **工具配对修复**(`claude.ts:1325`)——`ensureToolResultPairing` 修复远程会话恢复时 tool_use/tool_result 不匹配的问题
|
||||
3. **Advisor 块剥离**(`claude.ts:1328-1330`)——API 没有 advisor beta 头时会拒绝 advisor 块
|
||||
4. **媒体剔除**(`claude.ts:1336`)——API 拒绝超过 100 个媒体项的请求,静默丢弃最旧的
|
||||
|
||||
这四步对七个 Provider 一视同仁。在 Anthropic 原生路径中,这四步之后还会继续走 betas 注入、缓存标记、thinking 配置。但兼容层在第 1344 行就截断了。
|
||||
|
||||
## 调度点的不对称:tools vs filteredTools
|
||||
|
||||
仔细看第 1344-1382 行的三个分支,你会发现一个刻意的不对称:
|
||||
|
||||
- **OpenAI 路径**接收 `tools`(**全池**)
|
||||
- **Gemini 路径**接收 `filteredTools`(**裁剪后**)
|
||||
- **Grok 路径**接收 `filteredTools`(**裁剪后**)
|
||||
|
||||
`tools` 和 `filteredTools` 的区别在于延迟工具的过滤。打开 `claude.ts:1182-1205`:
|
||||
|
||||
```ts
|
||||
// claude.ts:1183-1205
|
||||
let filteredTools: Tools
|
||||
|
||||
if (useSearchExtraTools) {
|
||||
// Never include deferred tools in the API tools array
|
||||
filteredTools = tools.filter(tool => {
|
||||
if (!deferredToolNames.has(tool.name)) return true
|
||||
if (toolMatchesName(tool, SEARCH_EXTRA_TOOLS_TOOL_NAME)) return true
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
filteredTools = tools.filter(
|
||||
t => !toolMatchesName(t, SEARCH_EXTRA_TOOLS_TOOL_NAME),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
当 `useSearchExtraTools` 开启时,`filteredTools` 会排除所有延迟工具(除了 `SearchExtraToolsTool` 自身)。这些工具的 schema 不发给 API,只在模型通过 `SearchExtraTools` 发现后才通过 `ExecuteExtraTool` 动态加载。
|
||||
|
||||
那为什么 OpenAI 路径需要**全池**?注释在 `claude.ts:1346-1348` 解释了原因:
|
||||
|
||||
```ts
|
||||
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
|
||||
// the full tool pool so SearchExtraToolsTool can search deferred MCP tools that
|
||||
// were intentionally filtered out of the initial API tool list above.
|
||||
```
|
||||
|
||||
OpenAI 适配器(`src/services/api/openai/index.ts:214`)收到 `tools` 后,在内部做了自己的过滤逻辑(`index.ts:253-263`)。它保留全池是为了让 `SearchExtraToolsTool` 的 prompt 里能看到所有可搜索的 MCP 工具。Gemini 和 Grok 的适配器不需要这个——它们直接用传入的 `filteredTools` 构建请求。
|
||||
|
||||
这个不对称恰恰是"调度点位置精确"论点的最强证据:如果调度点在更前面(消息归一化之前),`filteredTools` 还没计算出来,三个路径都无法做延迟工具优化。如果调度点在更后面(Anthropic 逻辑之后),兼容层就需要处理 beta/caching 的副作用。当前这个精确位置——归一化之后、Anthropic 逻辑之前——是唯一的甜蜜点。
|
||||
|
||||
## getAPIProvider():单一真相源
|
||||
|
||||
打开 `src/utils/model/providers.ts:15`:
|
||||
|
||||
```ts
|
||||
// providers.ts:15-32
|
||||
export function getAPIProvider(
|
||||
settings: Pick<SettingsJson, 'modelType'> = getInitialSettings(),
|
||||
): APIProvider {
|
||||
const modelType = settings.modelType
|
||||
if (modelType === 'openai') return 'openai'
|
||||
if (modelType === 'gemini') return 'gemini'
|
||||
if (modelType === 'grok') return 'grok'
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) return 'foundry'
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok'
|
||||
|
||||
return 'firstParty'
|
||||
}
|
||||
```
|
||||
|
||||
这个函数有三层优先级:
|
||||
|
||||
1. **`modelType` 参数**——来自 `settings.json` 的持久化配置(`/provider` 命令写入)
|
||||
2. **`CLAUDE_CODE_USE_*` 环境变量**——Bedrock / Vertex / Foundry 的云 Provider 检测
|
||||
3. **兜底 `firstParty`**——Anthropic 直连 API
|
||||
|
||||
注意 `bedrock`、`vertex`、`foundry` 只通过环境变量检测。打开 `src/commands/provider.ts:127-161`,你会看到 `/provider` 命令对这两类 Provider 的处理不同:
|
||||
|
||||
```ts
|
||||
// provider.ts:129-161
|
||||
if (
|
||||
arg === 'anthropic' ||
|
||||
arg === 'openai' ||
|
||||
arg === 'gemini' ||
|
||||
arg === 'grok'
|
||||
) {
|
||||
// 清除所有云 provider 环境变量
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GROK
|
||||
updateSettingsForSource('userSettings', { modelType: arg })
|
||||
applyConfigEnvironmentVariables()
|
||||
return { type: 'text', value: `API provider set to ${arg}.` }
|
||||
} else {
|
||||
// 云 Provider:只设环境变量,不碰 settings.json
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GROK
|
||||
process.env[getEnvVarForProvider(arg)] = '1'
|
||||
applyConfigEnvironmentVariables()
|
||||
return { type: 'text', value: `API provider set to ${arg} (via environment variable).` }
|
||||
}
|
||||
```
|
||||
|
||||
`/provider openai` 写 `settings.json`(下次启动仍生效),`/provider bedrock` 只设环境变量(进程退出即消失)。这个区分是有道理的:Bedrock/Vertex/Foundry 的认证依赖 AWS/GCP/Azure 的 credential chain,不适合持久化到用户配置文件。
|
||||
|
||||
切换时还有一个重要的原子性设计:**先清除所有竞争 Provider 的标记,再设置目标 Provider**。`/provider unset`(`provider.ts:49-61`)更彻底——同时删除所有 `CLAUDE_CODE_USE_*` 环境变量并清除 `modelType`。
|
||||
|
||||
如果不做这个"全部清除再设置"的原子操作,用户从 `openai` 切到 `gemini` 时,`CLAUDE_CODE_USE_OPENAI=1` 可能残留在环境中,导致 `getAPIProvider()` 在 `modelType` 检查之后命中环境变量层,返回错误的 Provider。
|
||||
|
||||
## "类型谎言":4 个 SDK 伪装成 Anthropic
|
||||
|
||||
打开 `src/services/api/client.ts:84`,`getAnthropicClient()` 函数返回类型声明为 `Promise<Anthropic>`。但在函数体内部,Bedrock、Vertex、Foundry 三个分支返回的是完全不同的 SDK 实例,通过 `as unknown as Anthropic` 强转:
|
||||
|
||||
```ts
|
||||
// client.ts:189 — Bedrock
|
||||
return new BedrockClient(bedrockArgs) as unknown as Anthropic
|
||||
|
||||
// client.ts:219 — Foundry
|
||||
return new AnthropicFoundry(foundryArgs) as unknown as Anthropic
|
||||
|
||||
// client.ts:297 — Vertex
|
||||
return new AnthropicVertex(vertexArgs) as unknown as Anthropic
|
||||
```
|
||||
|
||||
注释甚至承认了这个"谎言":
|
||||
|
||||
```ts
|
||||
// client.ts:188
|
||||
// we have always been lying about the return type - this doesn't support batching or models
|
||||
```
|
||||
|
||||
`BedrockClient`、`AnthropicFoundry`、`AnthropicVertex` 各自有不同的构造参数、不同的认证方式、不同的 region 处理。但它们的 SDK 都实现了与 `Anthropic` 类似的 `messages.create()` 接口,所以下游代码可以统一调用。这是一个鸭子类型(duck typing)的实用主义选择——不依赖 TypeScript 的类型系统来保证接口兼容,而是靠运行时的 API 契约。
|
||||
|
||||
反事实推演:如果为每种 SDK 定义独立的类型(`BedrockClient | AnthropicVertex | AnthropicFoundry | Anthropic`),下游 `claude.ts` 中每处调用都需要联合类型缩窄。代码量至少翻三倍,但安全性收益微乎其微——三个云 SDK 都是 Anthropic 官方发布的,接口一致性有保障。
|
||||
|
||||
## isFirstPartyAnthropicBaseUrl() 的 TODO 陷阱
|
||||
|
||||
回到 `providers.ts:43-59`:
|
||||
|
||||
```ts
|
||||
// providers.ts:43-59
|
||||
export function isFirstPartyAnthropicBaseUrl(): boolean {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
// TODO: 这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题
|
||||
if (!baseUrl) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const host = new URL(baseUrl).host
|
||||
const allowedHosts = ['api.anthropic.com']
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
allowedHosts.push('api-staging.anthropic.com')
|
||||
}
|
||||
return allowedHosts.includes(host)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个函数在多处被调用,用来判断"当前是否使用 Anthropic 官方 API"。问题在于:当用户只设了 `OPENAI_BASE_URL` 而没设 `ANTHROPIC_BASE_URL` 时,`baseUrl` 为空,函数返回 `true`。但如果 `getAPIProvider()` 返回的是 `openai`(因为 `modelType='openai'` 或 `CLAUDE_CODE_USE_OPENAI=1`),`isFirstPartyAnthropicBaseUrl()` 仍然说"是 firstParty"。
|
||||
|
||||
这个不一致可能导致 firstParty 专有的行为(比如 prompt caching 的启用逻辑)泄漏到 OpenAI 兼容路径。TODO 注释已经指出了这个坑,但至今未修复。
|
||||
|
||||
## Langfuse 追踪也依赖单一真相源
|
||||
|
||||
打开 `claude.ts:2997-2999`:
|
||||
|
||||
```ts
|
||||
// claude.ts:2997-2999
|
||||
recordLLMObservation(options.langfuseTrace ?? null, {
|
||||
model: resolvedModel,
|
||||
provider: getAPIProvider(),
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
Langfuse 的 `recordLLMObservation` 直接调用 `getAPIProvider()` 获取 provider 字段。这意味着所有可观测性数据——token 消耗、延迟、模型使用——都绑定在同一个真相源上。如果有人绕过 `getAPIProvider()` 用其他方式判断当前 Provider(比如直接读 `process.env.CLAUDE_CODE_USE_OPENAI`),Langfuse 追踪就会出现不一致。
|
||||
|
||||
## 为什么 Bedrock / Vertex / Foundry 不在调度点
|
||||
|
||||
你可能注意到,`claude.ts:1344-1382` 的调度块只处理 `openai`、`gemini`、`grok` 三个 Provider。Bedrock、Vertex、Foundry 去哪了?
|
||||
|
||||
答案是:它们在 `getAnthropicClient()`(`client.ts:84`)层面就被替换了。`claude.ts` 调用 `getAnthropicClient()` 时,如果环境变量 `CLAUDE_CODE_USE_BEDROCK=1`,拿到的 `client` 实例已经是 `BedrockClient` 了——但它的类型被伪装成 `Anthropic`。后续的 `client.messages.create()` 调用走的是 Bedrock SDK 的实现。
|
||||
|
||||
这意味着 Bedrock/Vertex/Foundry **不走调度点的兼容路径**,而是走 Anthropic 原生路径的全部逻辑——包括 betas、thinking、prompt caching。它们能这么做,是因为这三个 SDK 本来就是 Anthropic 官方发布的,接口与 `Anthropic` SDK 高度一致,不需要消息格式转换。
|
||||
|
||||
只有真正"非 Anthropic"的 Provider(OpenAI 协议、Gemini 原生 API、Grok/xAI)才需要独立的流适配器和调度分支。
|
||||
|
||||
如果不这么区分,Bedrock/Vertex/Foundry 也要经过 OpenAI 式的消息转换——但它们本来就能接受 Anthropic 原生格式,转换纯属浪费且引入额外的序列化/反序列化误差。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看流适配器如何把 OpenAI/Gemini/Grok 的流格式转成 Anthropic 的 `BetaRawMessageStreamEvent`,见 [第八章:流适配器](./08-stream-adapters.md)
|
||||
- 想看 Usage 字段映射和模型映射的四级优先级链,见 [第九章:Usage 字段映射与模型映射](./09-usage-model-mapping.md)
|
||||
- 想看 Feature Flag 如何在构建期替换 `feature()` 调用,见 [第六章:Feature Flag 系统的三个硬约束](./06-feature-flags.md)
|
||||
226
docs/outline-output/design/08-stream-adapters.md
Normal file
226
docs/outline-output/design/08-stream-adapters.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 第八章:流适配器 —— 让 OpenAI/Gemini/Grok 假装自己是 Anthropic
|
||||
|
||||
> 三个 API、三种流格式、一个统一的下游管道——全部靠 async generator 翻译
|
||||
|
||||
## async generator 作为格式翻译器
|
||||
|
||||
打开 `packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts:35`,你会看到一个函数签名:
|
||||
|
||||
```ts
|
||||
export async function* adaptOpenAIStreamToAnthropic(
|
||||
stream: AsyncIterable<ChatCompletionChunk>,
|
||||
model: string,
|
||||
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||
```
|
||||
|
||||
这不是什么中间件框架,也不是事件发射器。一个纯粹的 async generator 函数——接收 OpenAI 的 `ChatCompletionChunk` 流,`yield` 出 Anthropic 的 `BetaRawMessageStreamEvent` 流。没有依赖注入,没有 class 层次,没有状态管理库。整个"翻译"就发生在一个 `for await...of` 循环里。
|
||||
|
||||
这种选择有三个理由:
|
||||
|
||||
1. **流式翻译天然是 pull 模式**。下游消费者拉一个事件,上游才翻译一个。async generator 恰好是这个语义:`yield` 暂停,`next()` 恢复。不需要 buffer 队列,不需要背压控制——JavaScript 运行时的协程调度本身就是背压机制。
|
||||
|
||||
2. **纯函数,无副作用**。适配器不创建网络连接,不操作全局状态,不触发副作用。它唯一的输入是一个 `AsyncIterable`,唯一的输出是 `yield`。这使得 `@ant/model-provider` 包可以是一个纯粹的转换器库(打开 `packages/@ant/model-provider/src/index.ts` 可以确认——导出的全是转换函数和类型,没有一个 client 实例化)。
|
||||
|
||||
3. **调试时可以"解耦"测试**。你可以在测试中直接 `for await (const event of adaptOpenAIStreamToAnthropic(mockStream, 'gpt-4'))` 验证每个事件,不需要 mock HTTP 客户端。OpenAI 的 `ChatCompletionChunk` 只是一个普通对象,你可以手写一组 chunk 来精确测试边界条件——比如 `reasoning_content: ''`(空字符串)这种反直觉的 case。
|
||||
|
||||
反事实推演:如果用事件发射器(EventEmitter)或者回调模式,下游要么被迫订阅(耦合),要么需要一个 buffer 队列(复杂度)。如果用 Observable(RxJS),整个代码库就多了一个重量级依赖,而且 pull 语义需要额外的 `.forEach()` 适配——async generator 天然就是 pull 的。
|
||||
|
||||
## 为什么下游零分支:contentBlocks 累加器不知道上游是什么 Provider
|
||||
|
||||
打开 `src/services/api/claude.ts:1865`,你会看到 Anthropic 原生路径的流处理循环:
|
||||
|
||||
```ts
|
||||
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
|
||||
// ...
|
||||
case 'content_block_start':
|
||||
switch (part.content_block.type) {
|
||||
case 'tool_use':
|
||||
contentBlocks[part.index] = { ...part.content_block, input: '' }
|
||||
break
|
||||
```
|
||||
|
||||
现在打开 `src/services/api/openai/index.ts:394`,你会看到 OpenAI 兼容路径的几乎相同代码:
|
||||
|
||||
```ts
|
||||
const contentBlocks: Record<number, Record<string, unknown>> = {}
|
||||
// ...
|
||||
case 'content_block_start': {
|
||||
const idx = event.index
|
||||
const cb = event.content_block
|
||||
if (cb.type === 'tool_use') {
|
||||
contentBlocks[idx] = { ...cb, input: '' }
|
||||
} else if (cb.type === 'text') {
|
||||
contentBlocks[idx] = { ...cb, text: '' }
|
||||
```
|
||||
|
||||
两条路径处理的都是 `BetaRawMessageStreamEvent`——同一套事件类型、同一套 `content_block_start` / `content_block_delta` / `content_block_stop` / `message_delta` / `message_stop` 序列。差别只在于:Anthropic 路径从 SDK 流直接拿到这些事件,OpenAI/Grok 路径从适配器 generator 拿到这些事件。下游的 switch 语句一个字都不用改。
|
||||
|
||||
这是整个多 API 兼容层最关键的设计决策:**把翻译边界推到最上游,让翻译之后的所有代码只认一种"语言"**。
|
||||
|
||||
反事实推演:如果让每个下游模块都写 `if (provider === 'openai')` 分支,那 `QueryEngine.ts`、`REPL.tsx`、工具权限系统、token 计费、会话持久化——所有消费流事件的模块都要知道每个 Provider 的特殊格式。加一个新 Provider 就要改几十个文件。现在加一个新 Provider 只需要写一个 adapter generator——大约 200 行代码,零下游改动。
|
||||
|
||||
## message_stop 后兜底:零分支叙事的少数例外
|
||||
|
||||
"下游零分支"是个好故事,但故事有裂痕。打开 `src/services/api/openai/index.ts:535`:
|
||||
|
||||
```ts
|
||||
// Safety: if stream ended without message_stop, assemble and yield whatever we have
|
||||
if (partialMessage) {
|
||||
for (const output of assembleFinalAssistantOutputs({
|
||||
partialMessage,
|
||||
contentBlocks,
|
||||
tools,
|
||||
agentId: options.agentId,
|
||||
usage,
|
||||
stopReason,
|
||||
maxTokens,
|
||||
})) {
|
||||
yield output
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段 post-loop 安全回退只存在于 OpenAI 和 Grok 路径,Anthropic 原生路径不需要。原因在于适配器的架构特征:OpenAI 和 Grok 的 `adaptOpenAIStreamToAnthropic` 在 `message_stop` 之前才组装最终的 `contentBlocks`,而网络中断可能导致 `for await` 循环在 `message_stop` yield 之前就退出。适配器本身无法区分"正常结束"和"网络中断"——`AsyncIterable` 的 `done` 标志对两者返回的都是 `true`。
|
||||
|
||||
所以在 `message_stop` 正常 yield 之后,OpenAI 路径会 `partialMessage = null`(`src/services/api/openai/index.ts:490`),让 post-loop 回退跳过。如果 `partialMessage` 没被重置,说明 stream 异常中断,回退会把已累积的内容块组装出来。
|
||||
|
||||
如果没这个回退会怎样?用户看到的就是:模型明明已经返回了部分文本,但 REPL 屏幕上什么都没出现——因为 `AssistantMessage` 从未被 yield。这种"静默丢失"在交互式 CLI 里是不可接受的。
|
||||
|
||||
## @ant/model-provider 作为无副作用转换器库
|
||||
|
||||
打开 `packages/@ant/model-provider/src/index.ts`,整个包导出的内容清单如下:
|
||||
|
||||
- 转换函数:`anthropicMessagesToOpenAI`、`anthropicToolsToOpenAI`、`adaptOpenAIStreamToAnthropic`、`anthropicMessagesToGemini`、`adaptGeminiStreamToAnthropic`、`resolveOpenAIModel`、`resolveGrokModel`、`resolveGeminiModel`
|
||||
- 类型:各种 Message、Tool、Usage 类型
|
||||
- Hooks:`registerHooks`、`registerClientFactories`(依赖注入用,但默认无副作用)
|
||||
|
||||
注意这里**没有** `getOpenAIClient()`、没有 `streamGeminiGenerateContent()`、没有任何 HTTP 客户端实例化。这些在 `src/services/api/openai/client.ts` 和 `src/services/api/gemini/client.ts` 里——`src/services/api` 层才是"有副作用"的客户端实例化器。
|
||||
|
||||
为什么要拆成两层?
|
||||
|
||||
1. **`@ant/model-provider` 可以在没有网络的情况下测试**。它只是一个纯函数库,转换逻辑可以 100% 单元测试覆盖,不需要 mock HTTP。
|
||||
2. **`src/services/api` 层有 feature flag 依赖**。OpenAI 路径的 `queryModelOpenAI` 内部会检查 `isChatGPTAuthEnabled()`(`src/services/api/openai/index.ts:355`),会调用 `isSearchExtraToolsEnabled()`,这些是运行时条件,不适合放进纯转换库。
|
||||
3. **客户端缓存是有状态的**。`getOpenAIClient()` 和 `getGrokClient()`(`src/services/api/grok/client.ts:15`)都用模块级 `cachedClient` 变量缓存实例,这是为了复用 TCP 连接。这种有状态的东西不属于"纯转换"层。
|
||||
|
||||
反事实推演:如果把 HTTP 客户端和转换函数混在同一个包里,测试转换逻辑就必须要么 mock HTTP(复杂且脆弱),要么真正发网络请求(慢且不可控)。拆分后,`packages/@ant/model-provider/src/shared/__tests__/` 下的测试可以纯内存运行。
|
||||
|
||||
## DeepSeek 思维模式的三层兼容
|
||||
|
||||
打开 `src/services/api/openai/requestBody.ts:70`,你会看到一个看起来很奇怪的函数返回类型:
|
||||
|
||||
```ts
|
||||
export function buildOpenAIRequestBody(params: {
|
||||
// ...
|
||||
}): ChatCompletionCreateParamsStreaming & {
|
||||
thinking?: { type: string }
|
||||
enable_thinking?: boolean
|
||||
chat_template_kwargs?: { thinking: boolean; enable_thinking: boolean }
|
||||
}
|
||||
```
|
||||
|
||||
返回值同时包含三套互不兼容的 thinking mode 参数——`thinking`、`enable_thinking`、`chat_template_kwargs`。注释解释了原因(`src/services/api/openai/requestBody.ts:63`):
|
||||
|
||||
```ts
|
||||
// Three thinking-mode formats are sent simultaneously; each endpoint uses the
|
||||
// format it recognizes and ignores the others:
|
||||
// - Official DeepSeek API: `thinking: { type: 'enabled' }`
|
||||
// - Self-hosted DeepSeek: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
|
||||
// - MiMo (Xiaomi): `chat_template_kwargs: { enable_thinking: true }`
|
||||
```
|
||||
|
||||
OpenAI SDK 会把未知的键透传到 HTTP body。所以三套参数同时发送,每个端点各自识别自己认识的字段,忽略其余的。这不是一个优雅的设计,但它解决了一个实际的问题:DeepSeek 的思维模式参数在不同部署版本之间不兼容,用户不应该为了切换部署而改配置。
|
||||
|
||||
适配器一侧也有对应的处理。打开 `packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts:117`:
|
||||
|
||||
```ts
|
||||
// Handle reasoning_content -> Anthropic thinking block.
|
||||
// Empty string is a valid signal: DeepSeek v4 thinking mode sometimes
|
||||
// returns reasoning_content: "" when the model answers directly. The
|
||||
// empty thinking block must round-trip back to the API in subsequent
|
||||
// requests, otherwise DeepSeek rejects with 400.
|
||||
const reasoningContent = (delta as any).reasoning_content
|
||||
if (reasoningContent != null) {
|
||||
```
|
||||
|
||||
注意 `reasoningContent != null` 而不是 `reasoningContent !== ''`。空字符串是合法的——它告诉适配器"这个请求触发了 thinking mode 但模型选择直接回答"。空 thinking block 必须在下一轮对话中回传,否则 DeepSeek API 会返回 400 错误。这是反编译过程中才能发现的"坑":OpenAI 官方 API 从不返回 `reasoning_content: ''`,只有 DeepSeek 的特殊行为需要这个处理。
|
||||
|
||||
## 为什么 Grok 复用整个 OpenAI 适配器栈
|
||||
|
||||
打开 `src/services/api/grok/index.ts:51`,你会看到 Grok 查询函数 `queryModelGrok` 的 import 列表:
|
||||
|
||||
```ts
|
||||
import {
|
||||
anthropicMessagesToOpenAI,
|
||||
anthropicToolsToOpenAI,
|
||||
anthropicToolChoiceToOpenAI,
|
||||
adaptOpenAIStreamToAnthropic,
|
||||
resolveGrokModel,
|
||||
} from '@ant/model-provider'
|
||||
```
|
||||
|
||||
五个 import 里四个是 OpenAI 适配器的共享函数。只有 `resolveGrokModel` 是 Grok 特有的。整个消息转换、工具转换、流适配全是复用的。
|
||||
|
||||
原因在 `src/services/api/grok/index.ts:47` 的注释里:
|
||||
|
||||
```ts
|
||||
// Grok (xAI) query path. Grok uses an OpenAI-compatible API, so we reuse
|
||||
// the OpenAI message/tool converters and stream adapter. Only the client
|
||||
// (different base URL + API key) and model mapping are Grok-specific.
|
||||
```
|
||||
|
||||
xAI 的 Grok API 是 OpenAI Chat Completions 协议的一个实现。它返回的数据结构和 OpenAI 完全一致:`ChatCompletionChunk`,包含 `choices[0].delta.content`、`choices[0].delta.tool_calls` 等。所以消息转换逻辑、流翻译逻辑可以一字不改地复用。
|
||||
|
||||
真正"Grok 特有"的只有两处:
|
||||
|
||||
1. **模型映射**(`packages/@ant/model-provider/src/providers/grok/modelMapping.ts:51`):Anthropic 模型名到 Grok 模型名的映射,而且支持 `GROK_MODEL_MAP` 环境变量让用户自定义整个 JSON 映射表——这是 Grok 独有的功能,OpenAI 适配器没有对应设计。
|
||||
2. **客户端实例化**(`src/services/api/grok/client.ts:15`):`getGrokClient()` 用 `GROK_API_KEY`(或 `XAI_API_KEY`)和 `https://api.x.ai/v1` 作为默认 base URL,不复用 `getOpenAIClient()`。
|
||||
|
||||
注意 `getGrokClient`(`src/services/api/grok/client.ts:15`)的缓存策略和 `getOpenAIClient` 完全一样——模块级 `cachedClient` 变量,有 `clearGrokClientCache()` 清理函数。这是因为在反编译还原时,复用了同一个缓存模式。
|
||||
|
||||
反事实推演:如果为 Grok 单独写一套转换器和适配器,代码量大约翻倍(Grok 路径大约 200 行,完整的 OpenAI 路径大约 500 行)。维护两套几乎相同的代码容易产生不一致——比如 OpenAI 路径修了一个 DeepSeek thinking mode 的 bug,Grok 路径忘了同步。复用消除了这种风险。
|
||||
|
||||
## ChatGPT 订阅路径:OpenAI 内部的第二个适配器
|
||||
|
||||
打开 `src/services/api/openai/index.ts:355`,你会看到一段三元表达式:
|
||||
|
||||
```ts
|
||||
const adaptedStream = isChatGPTAuthEnabled()
|
||||
? adaptResponsesStreamToAnthropic(
|
||||
await createChatGPTResponsesStream({ ... }),
|
||||
openaiModel,
|
||||
)
|
||||
: adaptOpenAIStreamToAnthropic(
|
||||
await getOpenAIClient({ ... }).chat.completions.create(
|
||||
buildOpenAIRequestBody({ ... }),
|
||||
{ signal },
|
||||
),
|
||||
openaiModel,
|
||||
)
|
||||
```
|
||||
|
||||
同属 OpenAI 路径,但有两种完全不同的适配器:
|
||||
|
||||
- **Chat Completions 路径**:用 `adaptOpenAIStreamToAnthropic`(来自 `@ant/model-provider`),处理标准的 OpenAI Chat Completions 流。
|
||||
- **Responses API 路径**:用 `adaptResponsesStreamToAnthropic`(`src/services/api/openai/responsesAdapter.ts:1`),处理 ChatGPT 订阅的 Responses API 流。
|
||||
|
||||
Responses API 是 OpenAI 内部的新一代 API 格式,和 Chat Completions 有结构性差异。打开 `src/services/api/openai/responsesAdapter.ts:61`,你会看到消息格式完全不同——`role: "user"` 变成 `{ role: "user", content: ... }`,`role: "assistant"` 的 tool_calls 变成独立的 `{ type: "function_call", call_id: ... }` 对象,`role: "system"` 被合并到 `instructions` 字段:
|
||||
|
||||
```ts
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const text = textFromContent(record.content)
|
||||
if (text) instructions.push(text)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
流事件格式也不同。Chat Completions 用 `choices[0].delta`,Responses API 用 `response.output_text.delta`、`response.reasoning_text.delta`、`response.output_item.added`、`response.function_call_arguments.delta` 等。`adaptResponsesStreamToAnthropic`(`src/services/api/openai/responsesAdapter.ts:249`)需要把所有这些事件类型翻译成统一的 `BetaRawMessageStreamEvent`。
|
||||
|
||||
但关键的相同点是:**翻译完成后,两条路径 yield 出的事件类型完全一致**。所以 `src/services/api/openai/index.ts:407` 的 `for await (const event of adaptedStream)` 循环对两种路径都用同一套 switch 处理。这就是"下游零分支"的力量——即使上游有两个适配器,下游也只需要一份处理逻辑。
|
||||
|
||||
为什么不直接把 Responses API 的转换也放进 `@ant/model-provider`?因为 Responses API 的消息格式不是 OpenAI 官方 SDK 类型的一部分——它是一个 ChatGPT 特有的 REST API,没有对应的 TypeScript SDK 类型。`responsesAdapter.ts` 里全部使用 `Record<string, unknown>` 作为类型,因为它在类型层面就是"结构未知的 JSON"。把它留在 `src/services/api` 层更合理。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 Usage 字段映射与模型映射的优先级链,见 [第九章](./09-usage-model-mapping.md)
|
||||
- 想看 Provider 调度的完整流程(消息归一化、工具过滤、三路径分发),见 [第七章](./07-provider-dispatch.md)
|
||||
- 想看模块级 client cache 的陷阱和 clearOpenAIClientCache(),见 [第九章](./09-usage-model-mapping.md)
|
||||
338
docs/outline-output/design/09-usage-mapping.md
Normal file
338
docs/outline-output/design/09-usage-mapping.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# 第九章:Usage 字段映射与模型映射的优先级链
|
||||
|
||||
> 四级优先级链、ANSI 清理、模块级缓存陷阱——兼容层里那些不能省的"丑"代码
|
||||
|
||||
## 模型映射不是查表那么简单
|
||||
|
||||
三个兼容层(OpenAI、Gemini、Grok)各自有一个 `resolve<Model>Model` 函数,都遵循同一套四级优先级链。但"遵循"的方式有微妙分歧,正是这些分歧暴露了每个 Provider 的历史包袱和设计权衡。
|
||||
|
||||
打开 `packages/@ant/model-provider/src/providers/openai/modelMapping.ts:36`,你会看到 `resolveOpenAIModel` 的完整实现:
|
||||
|
||||
```ts
|
||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||
if (process.env.OPENAI_MODEL) {
|
||||
return process.env.OPENAI_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const openaiOverride = process.env[openaiEnvVar]
|
||||
if (openaiOverride) return openaiOverride
|
||||
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
}
|
||||
|
||||
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
|
||||
}
|
||||
```
|
||||
|
||||
优先级链:`OPENAI_MODEL` > `OPENAI_DEFAULT_{FAMILY}_MODEL` > `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` > `DEFAULT_MODEL_MAP[cleanModel]` > `cleanModel`(passthrough)。
|
||||
|
||||
注意第五级:当查表也找不到时,OpenAI 选择把模型名原样传过去。这是一个隐式契约——Ollama、vLLM 等本地端点会收到 `claude-sonnet-4-20250514` 这样的 Anthropic 模型名,它们当然不认识,但也不会崩溃(大不了返回 404)。这个 passthrough 是有意为之,让用户不需要为每个自定义端点手动配置映射。
|
||||
|
||||
### 正则推断模型家族
|
||||
|
||||
三个 Provider 共用同一个 `getModelFamily` 函数,逻辑完全一样:
|
||||
|
||||
```ts
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
用正则从模型名字符串推断家族,而不是查表。为什么?因为模型名不是静态的——`claude-sonnet-4-20250514`、`claude-sonnet-4-6`、`claude-3-5-sonnet-20241022` 全是不同的 key,但都是 sonnet。如果用精确匹配,每次新增模型版本都要更新三个映射表。正则 `/haiku|sonnet|opus/i` 是一个把"Anthropic 模型名中嵌入家族信息"这个约定利用到极致的 hack。
|
||||
|
||||
如果不这么做,每当 Anthropic 发布新模型(opUs 4.7、sonnet 5...),三个 Provider 的映射表都要同步更新。反编译重建过程中这种多处同步是最容易遗漏的地方——一个表更新了、另一个忘了,就会导致某个 Provider 下 opus 请求被错误地映射成默认模型。
|
||||
|
||||
注意检查顺序:haiku 先于 opus、opus 先于 sonnet。如果顺序反过来,一个包含 `opus` 的模型名会被错误地先匹配到 `sonnet`。但等等——为什么 `opus` 会被匹配到 `sonnet`?因为 `sonnet` 不包含 `opus` 子串。这个顺序实际上目前不会造成误匹配,但如果未来有一个叫 `super-sonnet-opus` 的模型呢?正则 `test()` 是子串匹配,不是词匹配——这个陷阱目前 dormant,但很脆弱。
|
||||
|
||||
## Gemini:唯一会硬抛异常的映射
|
||||
|
||||
打开 `packages/@ant/model-provider/src/providers/gemini/modelMapping.ts:8`,对比 OpenAI 的同一个函数:
|
||||
|
||||
```ts
|
||||
export function resolveGeminiModel(anthropicModel: string): string {
|
||||
if (process.env.GEMINI_MODEL) {
|
||||
return process.env.GEMINI_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/i, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
|
||||
if (!family) {
|
||||
return cleanModel
|
||||
}
|
||||
|
||||
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const geminiModel = process.env[geminiEnvVar]
|
||||
if (geminiModel) {
|
||||
return geminiModel
|
||||
}
|
||||
|
||||
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const resolvedModel = process.env[sharedEnvVar]
|
||||
if (resolvedModel) {
|
||||
return resolvedModel
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Gemini provider requires GEMINI_MODEL or ${geminiEnvVar} (or ${sharedEnvVar} for backward compatibility) to be configured.`,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
关键差异:Gemini 在四级优先级全部 miss 时**直接 throw Error**。OpenAI passthrough、Grok 也有 `DEFAULT_FAMILY_MAP` 兜底,只有 Gemini 拒绝猜测。
|
||||
|
||||
为什么?因为 Gemini 的模型命名空间和 Anthropic 完全不同——把 `claude-sonnet-4-20250514` 传给 Gemini API 会得到一个明确的 400 错误,而不是"用默认模型"的 graceful degradation。Gemini 团队选择 fail-fast:与其让用户困惑于一个他们没配置过的模型在 Gemini 上跑出不可预期的结果,不如直接报错,强制用户配置映射。
|
||||
|
||||
反事实推演:如果 Gemini 也做 passthrough,用户配好了 `CLAUDE_CODE_USE_GEMINI=1` 和 `GEMINI_API_KEY`,但忘了配 `GEMINI_MODEL`,请求会发送到 Google 的 API endpoint,API 返回 400 或 404,错误信息可能被 OpenAI stream adapter 捕获并包装成一个令人困惑的 "API Error: model not found"。用户会以为 Gemini 不可用,而不是"我忘了配模型映射"。显式 throw 给出了精确的错误信息,直接指向解决方案。
|
||||
|
||||
## Grok:唯一支持用户自定义 JSON 映射的 Provider
|
||||
|
||||
打开 `packages/@ant/model-provider/src/providers/grok/modelMapping.ts:34`,你会看到一个其他 Provider 都没有的特性:
|
||||
|
||||
```ts
|
||||
function getUserModelMap(): Record<string, string> | null {
|
||||
const raw = process.env.GROK_MODEL_MAP
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
通过 `GROK_MODEL_MAP` 环境变量,用户可以传入一个完整的 JSON 对象来自定义映射。在 `resolveGrokModel` 中,这个用户映射被插在 `GROK_MODEL` 全局覆盖和 `GROK_DEFAULT_{FAMILY}_MODEL` 家族覆盖之间(`modelMapping.ts:59`),形成了一个五级优先级链:
|
||||
|
||||
`GROK_MODEL` > `GROK_MODEL_MAP[family]` > `GROK_DEFAULT_{FAMILY}_MODEL` > `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` > `DEFAULT_MODEL_MAP` > `DEFAULT_FAMILY_MAP` > `cleanModel`
|
||||
|
||||
为什么只有 Grok 有这个?xAI 的 Grok 模型更新频繁(`grok-3-mini-fast` -> `grok-4.20-reasoning`),且用户经常在多个 Grok 模型之间切换做 A/B 测试。一个 JSON 环境变量比四个 `GROK_DEFAULT_*_MODEL` 变量更灵活。这是一个"用户需求驱动"的 API 设计,而不是架构统一性的产物。
|
||||
|
||||
注意 `catch` 分支的静默忽略——如果 `GROK_MODEL_MAP` 不是合法 JSON,函数返回 `null`,相当于用户没有配置映射。没有 warning、没有 log。这种静默失败在 CLI 工具中很常见:在非交互模式下向 stderr 输出 warning 可能会干扰下游脚本。
|
||||
|
||||
## 防御性清理:ANSI 加粗后缀
|
||||
|
||||
三个 `resolve` 函数开头都有同一行:
|
||||
|
||||
```ts
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
```
|
||||
|
||||
这剥离了模型名末尾的 ANSI 终端加粗转义序列 `\x1b[1m`。为什么会有人在模型名里嵌入 ANSI 代码?
|
||||
|
||||
答案在 REPL 屏幕的显示逻辑里——某些 UI 组件会把模型名渲染成粗体用于高亮显示,但如果后续代码不小心把显示值当成了数据值传进了 API 调用链,模型名就会带上 `\x1b[1m` 后缀。这行 `replace` 是一个防御性修复:它假设 bug 在上游(显示逻辑),在下游(API 调用)拦截。
|
||||
|
||||
OpenAI 的版本用的是不带 `i` flag 的 `/\[1m\]$/`,而 Gemini 用了 `/\[1m\]$/i`。大小写不敏感 vs 敏感的差异,说明这个清理逻辑是在不同时间由不同人添加的,没有统一。这正是反编译重建项目的典型特征——同一个 bug 被修了两次,修法不完全一致。
|
||||
|
||||
## 模块级 Client 缓存:改 API key 必须重启
|
||||
|
||||
打开 `src/services/api/openai/client.ts:15`,你会看到:
|
||||
|
||||
```ts
|
||||
let cachedClient: OpenAI | null = null
|
||||
|
||||
export function getOpenAIClient(options?: {
|
||||
maxRetries?: number
|
||||
fetchOverride?: typeof fetch
|
||||
source?: string
|
||||
}): OpenAI {
|
||||
if (cachedClient) return cachedClient
|
||||
// ... 创建 client ...
|
||||
if (!options?.fetchOverride) {
|
||||
cachedClient = client
|
||||
}
|
||||
return client
|
||||
}
|
||||
```
|
||||
|
||||
模块级变量 `cachedClient` 在第一次调用后持住,后续调用直接返回。问题在于:`apiKey` 和 `baseURL` 在构造时就固化在 OpenAI 实例内部了。如果用户在会话中途修改了 `OPENAI_API_KEY` 环境变量,`getOpenAIClient()` 仍然返回旧 client。
|
||||
|
||||
对比 `src/services/api/client.ts:84` 的 `getAnthropicClient`——它**每次调用都创建新实例**,不缓存。因为 Anthropic client 的构造逻辑较重(OAuth token 刷新、AWS credential 获取),但每次调用都重新读取环境变量。两种设计的根本差异是:OpenAI/Grok 用缓存换取快速启动,Anthropic 用无缓存换取配置热更新。
|
||||
|
||||
Grok 的 `src/services/api/grok/client.ts:13` 是同样的模式——`let cachedClient: OpenAI | null = null`,同样有 `clearGrokClientCache()` 导出。
|
||||
|
||||
这就是为什么大纲里有一条特别提示:会话中改 API key 必须调用 `clearOpenAIClientCache()` 或重启。`/login` 命令在写入新凭证后,内部确实调用了 `clearOpenAIClientCache()` 和 `clearGrokClientCache()`,但如果用户直接 `export OPENAI_API_KEY=xxx` 而不走 `/login`,缓存就不会被清除。
|
||||
|
||||
如果不做模块级缓存,每次 API 调用都要 `new OpenAI(...)` 重新建立 HTTP 连接池,对于流式响应(每个 turn 可能持续数十秒),连接复用的收益是真实的。但缓存带来的配置不可变副作用,是这种设计必须付出的代价。
|
||||
|
||||
## Usage 字段映射:镜像设计打破"下游零分支"叙事
|
||||
|
||||
第八章讲流适配器时强调了一个叙事:下游代码不知道上游是什么 Provider,`contentBlocks` 累加器完全零分支。但在 Usage 字段映射上,这个叙事有一个刻意设计的例外。
|
||||
|
||||
打开 `src/services/api/openai/openaiShared.ts:18`,你会看到 `updateOpenAIUsage`:
|
||||
|
||||
```ts
|
||||
export function updateOpenAIUsage(
|
||||
current: {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
},
|
||||
delta: {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
},
|
||||
): typeof current {
|
||||
return {
|
||||
input_tokens: delta.input_tokens ?? current.input_tokens,
|
||||
output_tokens: delta.output_tokens ?? current.output_tokens,
|
||||
cache_creation_input_tokens:
|
||||
delta.cache_creation_input_tokens !== undefined &&
|
||||
delta.cache_creation_input_tokens > 0
|
||||
? delta.cache_creation_input_tokens
|
||||
: current.cache_creation_input_tokens,
|
||||
cache_read_input_tokens:
|
||||
delta.cache_read_input_tokens !== undefined &&
|
||||
delta.cache_read_input_tokens > 0
|
||||
? delta.cache_read_input_tokens
|
||||
: current.cache_read_input_tokens,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
再看 `src/services/api/claude.ts:3084` 的 `updateUsage`(Anthropic 原生路径):
|
||||
|
||||
```ts
|
||||
export function updateUsage(
|
||||
usage: Readonly<NonNullableUsage>,
|
||||
partUsage: BetaMessageDeltaUsage | undefined,
|
||||
): NonNullableUsage {
|
||||
if (!partUsage) {
|
||||
return { ...usage }
|
||||
}
|
||||
return {
|
||||
input_tokens:
|
||||
partUsage.input_tokens !== null && partUsage.input_tokens > 0
|
||||
? partUsage.input_tokens
|
||||
: usage.input_tokens,
|
||||
cache_creation_input_tokens:
|
||||
partUsage.cache_creation_input_tokens !== null &&
|
||||
partUsage.cache_creation_input_tokens > 0
|
||||
? partUsage.cache_creation_input_tokens
|
||||
: usage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens:
|
||||
partUsage.cache_read_input_tokens !== null &&
|
||||
partUsage.cache_read_input_tokens > 0
|
||||
? partUsage.cache_read_input_tokens
|
||||
: usage.cache_read_input_tokens,
|
||||
output_tokens: partUsage.output_tokens ?? usage.output_tokens,
|
||||
// ... 更多字段
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
两者是**镜像函数**——`openaiShared.ts` 的注释直接说 "Mirrors updateUsage() in claude.ts"。但为什么要维护两份几乎相同的函数,而不是抽一个共享的 `mergeUsage`?
|
||||
|
||||
答案是语义差异。Anthropic 的 streaming API 返回**累积值**(`message_start` 里 input_tokens 是总量,后续 `message_delta` 里的 input_tokens 永远是 0 或同一个值)。而 OpenAI 兼容层的流适配器把 Chat Completions 的 delta usage 转换成 Anthropic 格式时,某些事件可能携带显式的 0 值。`openaiShared.ts:35` 的 `> 0` guard 确保增量 0 不会覆盖掉之前累积的真实值。
|
||||
|
||||
`claude.ts:3079` 的注释精确解释了这个设计动机:
|
||||
|
||||
> Input-related tokens (input_tokens, cache_creation_input_tokens, cache_read_input_tokens) are typically set in message_start and remain constant. message_delta events may send explicit 0 values for these fields, which should not overwrite the values from message_start.
|
||||
|
||||
这是"下游零分支"叙事里唯一需要针对性修补的点。`contentBlocks` 累加器不需要区分 Provider,但 Usage 累加必须区分——因为 Anthropic 的 `message_delta` 携带 0 值是正常行为,OpenAI 适配器如果也发 0 值,必须被正确处理。
|
||||
|
||||
如果把这个 `> 0` guard 去掉,一次 OpenAI 请求中如果 `message_delta` 携带了 `cache_creation_input_tokens: 0`,累积的缓存 token 计数就会被静默清零。用户会看到 `/cost` 报告的缓存命中数突然从数百 tokens 跳到 0,但 API 实际上已经命中了缓存。这种"数字撒谎"比报错更危险,因为用户不会主动排查一个看起来正常但偏低的数字。
|
||||
|
||||
### cache 字段保留策略的深层原因
|
||||
|
||||
`cache_creation_input_tokens` 和 `cache_read_input_tokens` 是 Anthropic 的 prompt caching 特有字段。OpenAI 和 Grok 根本没有这个概念。那为什么 OpenAI 兼容层的 usage 对象里还要有这两个字段?
|
||||
|
||||
看 `src/services/api/openai/index.ts:129`,Grok 路径的 usage 初始化就包含了这两个字段:
|
||||
|
||||
```ts
|
||||
let usage: {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
} = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}
|
||||
```
|
||||
|
||||
因为这两个字段会在 Langfuse 追踪、`/cost` 计算、token 统计等下游消费者中被引用。如果 OpenAI 路径的 usage 对象缺少这两个字段,下游代码要么需要 Provider 分支,要么在访问时 undefined。让所有 Provider 的 usage 结构保持一致,下游才能继续"零分支"。
|
||||
|
||||
这也是为什么 `openaiShared.ts` 要用 `> 0` guard 而不是简单的 `?? current`。`??` 只检查 `null` 和 `undefined`,不检查 `0`。当 OpenAI 适配器发出 `cache_creation_input_tokens: 0` 时,`??` 会用 0 覆盖累积值,`> 0` guard 则会保留累积值。这个细微的语义差异就是整个 Usage 镜像设计存在的理由。
|
||||
|
||||
## BedrockClient:针对 SDK 漏洞的运行时补丁
|
||||
|
||||
打开 `src/services/api/bedrockClient.ts:29`,你会看到一个极短的类:
|
||||
|
||||
```ts
|
||||
export class BedrockClient extends AnthropicBedrock {
|
||||
async buildRequest(options: BuildRequestArg): Promise<BuildRequestRet> {
|
||||
const req = await super.buildRequest(options)
|
||||
|
||||
const inner = (
|
||||
req as unknown as { req?: { body?: unknown; headers?: unknown } }
|
||||
)?.req
|
||||
if (!inner || typeof inner.body !== 'string' || inner.body.length === 0) {
|
||||
return req
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(inner.body) as Record<string, unknown>
|
||||
} catch {
|
||||
return req
|
||||
}
|
||||
if (!('anthropic_beta' in parsed)) {
|
||||
return req
|
||||
}
|
||||
|
||||
delete parsed.anthropic_beta
|
||||
const cleanedBody = JSON.stringify(parsed)
|
||||
inner.body = cleanedBody
|
||||
|
||||
const byteLen = String(new TextEncoder().encode(cleanedBody).length)
|
||||
const h = inner.headers
|
||||
if (typeof Headers !== 'undefined' && h instanceof Headers) {
|
||||
if (h.has('content-length')) h.set('content-length', byteLen)
|
||||
} else if (h && typeof h === 'object') {
|
||||
const asDict = h as Record<string, string>
|
||||
if ('content-length' in asDict) asDict['content-length'] = byteLen
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个类做了一件事:`super.buildRequest()` 构建完请求后,检查 body JSON 里是否包含 `anthropic_beta` 字段,如果有就删掉,然后更新 `content-length` header。
|
||||
|
||||
注释里说得很清楚(`bedrockClient.ts:4`):这是 `@anthropic-ai/bedrock-sdk` 版本 0.26.4 到 0.28.1 的一个 bug——SDK 把 `anthropic-beta` HTTP header 的值复制到了请求 body 里的 `anthropic_beta` 字段。Bedrock 的 Opus 4.7 端点会拒绝任何 body 里包含 `anthropic_beta` 的请求,返回 400 "invalid beta flag"。
|
||||
|
||||
为什么不在 SDK 修复后直接删除这个类?因为 `bedrockClient.ts:22` 的注释留了一条明确的退出路径:
|
||||
|
||||
> When upstream ships a fix, verify the probe in scripts/probe-bedrock-beta-fix.ts shows "bug reproduced: false", then delete this class.
|
||||
|
||||
这个 probe 脚本(`scripts/probe-bedrock-beta-fix.ts`)会动态 import `@anthropic-ai/bedrock-sdk`,调用 `buildRequest`,检查 body 里是否出现 `anthropic_beta`。当 SDK 修复了这个 bug,probe 报告 "bug reproduced: false",开发者就可以安全地删除 `BedrockClient`,让 `client.ts` 直接使用 `AnthropicBedrock`。
|
||||
|
||||
注意 `as unknown as` 的双重断言链(`bedrockClient.ts:33`):`req as unknown as { req?: { body?: unknown; headers?: unknown } }`。这是反编译产物的典型痕迹——原始类型信息在反编译过程中丢失了,开发者只能通过运行时观察推断内部结构。`req.req.body` 这种嵌套是 Bedrock SDK 的内部实现细节,不在公共类型里。
|
||||
|
||||
如果不做这个补丁,所有使用 Bedrock + Opus 4.7 的用户都会在每个请求上收到 400 错误。这不是"优雅降级",是"完全不可用"。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看调度点如何把三个 Provider 路径统一接入,见 [第七章:7-Provider 抽象层的单一调度点](./07-provider-dispatch.md)
|
||||
- 想看流适配器如何把 OpenAI/Grok 响应翻译成 Anthropic 格式,见 [第八章:流适配器](./08-stream-adapters.md)
|
||||
- 想看 `getAPIProvider()` 的优先级判定逻辑,见 [第七章:7-Provider 抽象层的单一调度点](./07-provider-dispatch.md) 中"Provider 路由优先级链"一节
|
||||
147
docs/outline-output/design/10-ink-framework.md
Normal file
147
docs/outline-output/design/10-ink-framework.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 第十章:自研 Fork 的 Ink 框架 —— 为什么不是 src/ink/
|
||||
|
||||
> 27,000 行纯 TypeScript 重建的终端 React 渲染器,连 Yoga 布局引擎都是自己写的。
|
||||
|
||||
## 一个不存在的目录,一个庞大的包
|
||||
|
||||
新接触这个代码库的开发者第一反应往往是去 `src/ink/` 找终端渲染相关代码。这个目录不存在。所有 Ink 代码都在 `packages/@ant/ink/` 里,总共 27,536 行 TypeScript/TSX 源码。
|
||||
|
||||
打开 `packages/@ant/ink/package.json:1` 你会看到包名是 `@anthropic/ink` —— 这是反编译重建后重新命名的结果。`@ant` 是 monorepo 里的 workspace 前缀,`@anthropic/ink` 则是原始包名的残留。
|
||||
|
||||
这不是一个简单的 fork。打开 `packages/@ant/ink/src/core/` 目录,数一数文件数量:reconciler、dom、yoga-layout、render-node-to-output、hit-test、focus、renderer、screen、selection、events(10 个事件文件)、termio、layout……这是从 react-reconciler 到 Yoga 布局引擎、从终端 I/O 到屏幕缓冲区的完整终端 UI 栈。
|
||||
|
||||
## 为什么 fork 而非用上游 Ink
|
||||
|
||||
上游 Ink(vadimdemedes/ink)是一个轻量的终端 React 渲染器,大约 5,000 行。它依赖 `yoga-layout` 的原生绑定(yoga-layout-prebuilt),用 C++ 实现的 Yoga 引擎做 flexbox 布局计算。`@ant/ink` 至少有三个上游不支持的核心需求。
|
||||
|
||||
**第一:Yoga 布局引擎的纯 TypeScript 重写。** 打开 `packages/@ant/ink/src/core/yoga-layout/index.ts:1`,文件头注释写得很清楚:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Pure-TypeScript port of yoga-layout (Meta's flexbox engine).
|
||||
*
|
||||
* This matches the `yoga-layout/load` API surface used by src/ink/layout/yoga.ts.
|
||||
* The upstream C++ source is ~2500 lines in CalculateLayout.cpp alone; this port
|
||||
* is a simplified single-pass flexbox implementation...
|
||||
*/
|
||||
```
|
||||
|
||||
这个文件 2,581 行,用纯 TypeScript 实现了 Meta 的 Yoga flexbox 布局引擎——包括 flex-direction、flex-grow/shrink、align-items、justify-content、margin/padding/border/gap、position: relative/absolute、measure functions,甚至还有 flex-wrap 和 baseline alignment 的完整实现。上游 Ink 依赖原生 C++ 绑定,而 Bun 的 FFI 生态与 Node.js 的 N-API 不完全兼容,在交叉编译和跨平台分发(macOS + Linux + Windows)上会遇到摩擦。纯 TypeScript 重写彻底消灭了原生依赖。
|
||||
|
||||
**第二:三层层级架构。** 打开 `packages/@ant/ink/src/index.ts:1`,你会看到包被明确组织成三层:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @anthropic/ink — Terminal React rendering framework
|
||||
*
|
||||
* Three-layer architecture:
|
||||
* core/ — Rendering engine (reconciler, layout, terminal I/O, screen buffer)
|
||||
* components/ — UI primitives (Box, Text, ScrollBox, App, hooks)
|
||||
* theme/ — Theme system (ThemeProvider, ThemedBox, ThemedText, design-system)
|
||||
*/
|
||||
```
|
||||
|
||||
上游 Ink 没有这个分层。`theme/` 层里有 ThemeProvider、ThemedBox、ThemedText、Dialog、FuzzyPicker、ProgressBar、Tabs、Ratchet 等高阶组件——这些是 Claude Code UI 的设计系统,跟 Ink 渲染引擎本身无关。把它们放在一起是因为 ThemeProvider 需要直接操作 Box/Text 的 props,上游 Ink 不可能内置这些东西。
|
||||
|
||||
**第三:深度定制的交互系统。** `core/events/` 目录下有 10 个事件文件(click-event、dispatcher、emitter、event-handlers、focus-event、input-event、keyboard-event、mouse-action-event、paste-event、terminal-focus-event),加上 `keybindings/` 目录的完整按键绑定系统(解析器、匹配器、上下文切换),以及 `selection.ts` 的文本选择、`hit-test.ts` 的坐标命中测试、`focus.ts` 的 DOM 级焦点管理。上游 Ink 的交互只到"键盘输入+点击",而 `@ant/ink` 有完整的捕获/冒泡事件分发、焦点栈、Tab 循环、文本选择高亮、鼠标悬停分发。这些都是 REPL 交互(工具权限确认、快捷键、FuzzyPicker、多面板切换)所必需的。
|
||||
|
||||
如果不 fork 而是在上游 Ink 上叠加这些层,会面临两个问题:上游的 `yoga-layout` 原生绑定限制(上面说了),以及上游的 DOM 节点结构不够灵活(`@ant/ink` 在 DOMElement 上挂了 scrollTop、dirty 标记、_eventHandlers 分离、debugOwnerChain 等 reconcile 渲染优化所需的自定义字段,见 `packages/@ant/ink/src/core/dom.ts:32`)。
|
||||
|
||||
## react-reconciler 自建渲染器
|
||||
|
||||
`@ant/ink` 的核心是 `packages/@ant/ink/src/core/reconciler.ts` —— 一个基于 `react-reconciler` 包的自建渲染器,523 行。
|
||||
|
||||
打开 `packages/@ant/ink/src/core/reconciler.ts:241`,你会看到 `createReconciler` 的完整调用。它把 Ink 的 DOM 节点(DOMElement / TextNode)作为 React 19 的"宿主对象",实现了完整的 Fiber 协调生命周期:
|
||||
|
||||
```typescript
|
||||
const reconciler = createReconciler<
|
||||
ElementNames,
|
||||
Props,
|
||||
DOMElement,
|
||||
DOMElement,
|
||||
TextNode,
|
||||
DOMElement,
|
||||
unknown,
|
||||
unknown,
|
||||
DOMElement,
|
||||
HostContext,
|
||||
null, // UpdatePayload - not used in React 19
|
||||
NodeJS.Timeout,
|
||||
-1,
|
||||
null
|
||||
>({
|
||||
getRootHostContext: () => ({ isInsideText: false }),
|
||||
// ... 完整生命周期实现
|
||||
})
|
||||
```
|
||||
|
||||
这不是一个"自定义渲染器"——它是"自定义宿主"。React 的 reconciler 是通用的树协调器,任何东西都可以成为"DOM"——浏览器 DOM、canvas 像素、PDF 页面、或者这里:终端字符网格。`createInstance` 创建 DOMElement,`appendChild` 挂载子节点,`commitUpdate` 差量更新 props 和 style,`removeChild` 清理 Yoga 节点并触发焦点管理回调。
|
||||
|
||||
特别值得注意的是 `commitUpdate`(第 433 行)的实现。它先做浅层 diff(只比较 key 级别的变化),再分别处理 style diff 和 props diff。style diff 会调用 `applyStyles(yogaNode, style, newProps['style'] as Styles)` 直接修改 Yoga 布局约束,然后由 `resetAfterCommit` 中的 `onComputeLayout()` 触发重新布局。这个设计让 React 的声明式更新直接映射到 Yoga 的命令式布局 API 上。
|
||||
|
||||
`resetAfterCommit`(第 264 行)是整个渲染流程的关键节点——React 完成一次 commit 后,它执行三步:(1) 调用 `rootNode.onComputeLayout()` 让 Yoga 重新计算布局;(2) 调用 `rootNode.onRender()` 生成新的屏幕缓冲区;(3) 差量写入终端。如果去掉这些步骤,React 状态变化后终端上什么都不会显示。
|
||||
|
||||
如果不做自建渲染器,而是用 react-dom + ANSI escape code overlay 的方式,会怎样?首先,浏览器 DOM 的布局引擎不能直接映射到终端的字符网格(终端的"像素"是字符单元,不支持亚像素定位);其次,浏览器 DOM 节点在 Node.js 里不存在;最后,Yoga 布局引擎的 flexbox 模型恰好匹配终端 UI 的需求(flex 行列、padding/margin、overflow: scroll)。
|
||||
|
||||
## dedupe:为什么 React 副本是致命的
|
||||
|
||||
打开 `vite.config.ts:133`,你会看到:
|
||||
|
||||
```typescript
|
||||
dedupe: ['react', 'react-reconciler', 'react-compiler-runtime'],
|
||||
```
|
||||
|
||||
这个配置强制 Vite 在打包时对这三个包使用单一副本。为什么这很重要?因为 `react-reconciler` 内部维护全局状态(当前 Fiber 树、调度队列、事件优先级系统)。如果同一个应用里存在两个 `react` 副本,reconciler 会绑定到其中一个,而组件可能从另一个 `react` 创建——导致 hooks 状态丢失、context 不可达、 Fiber 树断裂。
|
||||
|
||||
在 `@ant/ink` 这个场景下,`packages/@ant/ink/` 自带 `react` 和 `react-reconciler` 作为 dependency(见 `packages/@ant/ink/package.json:21-22`),而 `src/` 下的 149 个组件也依赖 `react`。在 monorepo 里,如果两个 workspace 各自 resolve 自己的 node_modules,就会产生两个副本。`dedupe` 配置确保 `createReconciler` 和所有 `useState` 调用共享同一个 React 实例。
|
||||
|
||||
如果不做 dedupe,最可能出现的症状是:某些组件的 `useTheme()` 返回 `undefined`(因为它从另一个 React 实例的 Provider 下面读取),或者 hooks 的 state 在 re-render 之间被重置。
|
||||
|
||||
## React Compiler 的 _c() 痕迹:已清理但类型声明还在
|
||||
|
||||
大纲里提到 `_c()` memoization 模板作为反编译产物的典型痕迹。在当前代码树中,`_c()` 调用已经被清理掉了(源码不再包含 `_c(` 模式),但类型声明文件 `src/types/react-compiler-runtime.d.ts:1` 仍然保留:
|
||||
|
||||
```typescript
|
||||
declare module 'react/compiler-runtime' {
|
||||
export function c(size: number): unknown[]
|
||||
}
|
||||
```
|
||||
|
||||
这个声明是给 `react/compiler-runtime` 模块的,对应 React Compiler 的 memoization cache 函数 `c()`(注意是 `c` 不是 `_c`)。`_c()` 是编译后的产物——React Compiler 把每个组件的 memoization 缓存编译成 `$ = _c(N)` 的形式,其中 N 是缓存槽位数。反编译后这些调用变成了直接的函数引用。
|
||||
|
||||
`src/types/global.d.ts:59-61` 有一条更相关的声明:
|
||||
|
||||
```typescript
|
||||
// T — Generic type parameter leaked from React compiler output
|
||||
// (react/compiler-runtime emits compiled JSX that loses generic type params)
|
||||
declare type T = unknown
|
||||
```
|
||||
|
||||
这是反编译的典型痕迹:React Compiler 在优化泛型组件时,会在编译后的 JSX 中丢失类型参数,最终泄漏为裸的 `T` 类型。`declare type T = unknown` 是一个通用的补丁,让所有这种泄漏的类型都能通过类型检查。
|
||||
|
||||
## global.d.ts 的 declare type T = unknown 补丁
|
||||
|
||||
这值得单独讲,因为它是一个非常反编译特有的设计决策。
|
||||
|
||||
正常手写的 TypeScript 代码不会出现一个全局的 `type T = unknown`。但在反编译场景中,React Compiler 会把泛型组件编译成非泛型形式——类型参数在编译过程中被擦除,只留下类型约束。反编译器无法恢复原始泛型签名,只能把所有 `T` 统一声明为 `unknown`。
|
||||
|
||||
打开 `src/types/global.d.ts:59`,你会看到注释已经说明了原因:`(react/compiler-runtime emits compiled JSX that loses generic type params)`。这个声明覆盖了所有组件中出现的裸 `T` 引用,确保 `tsc --strict` 能通过。
|
||||
|
||||
如果不做这个补丁,tsc 会报告 `Cannot find name 'T'`,每一个涉及 React Compiler 产物的组件都会报错。这不是一个"能绕过"的问题——在 strict 模式下它是硬错误。
|
||||
|
||||
如果用 `declare type T = any` 代替 `unknown` 呢?在 strict 模式下这本身就是一个 lint 错误(`noExplicitAny`),但即便不考虑 lint,`unknown` 也比 `any` 更安全——它迫使调用方在使用前做类型收窄,而不是让类型错误静默传播。
|
||||
|
||||
## 如果不做自建渲染器
|
||||
|
||||
回到最根本的问题:为什么不把终端 UI 做成 Web 应用(electron、Tauri、webview),而是坚持在终端里用 React?
|
||||
|
||||
首先,Claude Code 的核心用户群是命令行开发者——他们已经在终端里工作,切换到 GUI 应用是摩擦。其次,MCP、pipe 模式、shell 工具、文件操作——这些能力天然在终端环境里,GUI 化需要大量管道适配。最后,代码分割章节(第一章)展示的 35MB RSS 基线(`--version`),如果在 electron 里只能更糟(chromium 渲染进程本身就吃几百 MB)。
|
||||
|
||||
那如果用上游 Ink 加 patch 呢?上游 Ink 的 DOM 节点结构不够灵活,无法支持 `@ant/ink` 所需的 scroll state、dirty marking、event handler 分离、debug owner chain 等扩展。每次上游发版都需要 rebase 大量 patch——维护成本远大于 fork 后独立演进的成本。而且 Yoga 的纯 TypeScript 重写本身就是一个重大工程(2,581 行),上游 Ink 的发布节奏不可能接受这种规模的 PR。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看代码分割如何影响 Ink 框架的加载行为,见 [第一章 Code Splitting](./01-code-splitting.md)
|
||||
- 想看 React Compiler 产物在 performanceShim 里的影响,见 [第三章 performanceShim](./03-performance-shim.md)
|
||||
- 想看 feature flag 如何控制 devtools 的加载,见 [第五章 Feature Flag](./05-feature-flags.md)
|
||||
- 想看 AppState 的 React Context 如何与 Ink 的 reconciler 交互,见 [第十一章 三层状态管理](./11-state-management.md)
|
||||
395
docs/outline-output/design/11-state-management.md
Normal file
395
docs/outline-output/design/11-state-management.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# 第十一章:三层状态管理 —— 为什么 bootstrap/state.ts 警告 "DO NOT ADD MORE"
|
||||
|
||||
> 一个 1761 行的模块、一个 34 行的 store、一个 React Context —— 三层各司其职,边界严格到用注释威胁后来者。
|
||||
|
||||
## 为什么会有"三层",而不是一个全局 store
|
||||
|
||||
在大多数 React 应用里,状态管理是一道选择题:Redux、Zustand、Jotai、Recoil…… 选一个,然后把所有东西塞进去。但 Claude Code 没有选——它同时保留了三种完全不同的状态容器,而且彼此之间不能互相替代。打开 `src/bootstrap/state.ts`、`src/state/store.ts`、`src/state/AppState.tsx` 你会看到三段风格迥异的代码,分别服务于三种被运行时约束逼出来的需求。
|
||||
|
||||
把这三层的需求列出来,你就能看出为什么合并不了:
|
||||
|
||||
| 层 | 容器 | 谁会读它 | 何时确定 | 为什么不能放进 React |
|
||||
|---|---|---|---|---|
|
||||
| Bootstrap | 模块级 singleton `STATE` | query loop、tools、telemetry、bootstrap 阶段的早期代码 | 进程启动时 | React 树还没 mount,`useSyncExternalStore` 是个空指针 |
|
||||
| Store | 手写 zustand-style store | 任何想响应式订阅的代码 | 首次 `createStore()` 调用 | 不能依赖 React Context(headless/SDK 路径不走 React) |
|
||||
| AppState | React Context 包裹的 store | REPL 组件树 | `<AppStateProvider>` mount 时 | 需要 React 调度、需要细粒度 selector 订阅、需要禁止嵌套 |
|
||||
|
||||
反事实推演:如果项目贪图统一,把 bootstrap state 也塞进 React Context 会怎样?`src/entrypoints/cli.tsx` 的 fast-path(`--version`、`--dump-system-prompt`、MCP server 模式)根本不会 mount React 树,但它们需要读 `clientType`、`sessionId`、`cwd` 这些值。React Context 不存在的时候,所有这些读取都会拿到 `undefined`,整个 fast-path 优先级链(见第二章)会瞬间瓦解。
|
||||
|
||||
所以三层不是设计冗余,而是"不同代码阶段需要不同的状态容器"这个硬约束的直接产物。下面一层一层拆。
|
||||
|
||||
## Bootstrap state:1761 行的"罪恶" singleton
|
||||
|
||||
打开 `src/bootstrap/state.ts:31`,你会看到一行用大写字母咆哮的注释:
|
||||
|
||||
```ts
|
||||
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
|
||||
```
|
||||
|
||||
这不是装饰性警告。继续往下翻到 `src/bootstrap/state.ts:45`,你会看到一个 `type State = {...}` 的字段清单——总共有 100 多个字段,文件本身 1761 行,导出 63 个 `set*` 函数和 100 个 `get*` 函数。这是一个名副其实的全局变量大杂烩,而且作者完全清楚这一点。
|
||||
|
||||
继续翻到 `src/bootstrap/state.ts:254` 和 `src/bootstrap/state.ts:422`,警告还在加码:
|
||||
|
||||
```ts
|
||||
// ALSO HERE - THINK THRICE BEFORE MODIFYING
|
||||
function getInitialState(): State {
|
||||
// ...
|
||||
}
|
||||
|
||||
// AND ESPECIALLY HERE
|
||||
const STATE: State = getInitialState()
|
||||
```
|
||||
|
||||
三段警告("DO NOT ADD MORE"、"THINK THRICE"、"ESPECIALLY HERE")层层递进,构成一个有趣的悖论:**作者一边喊着不要再加,一边持续往里加。** 为什么?
|
||||
|
||||
答案藏在字段注释里。打开 `src/bootstrap/state.ts:45` 附近的 `type State`,每一个字段都带着一段解释为什么它必须住在这里而不是别处的故事。比如:
|
||||
|
||||
```ts
|
||||
// CLAUDE.md content cached by context.ts for the auto-mode classifier.
|
||||
// Breaks the yoloClassifier → claudemd → filesystem → permissions cycle.
|
||||
cachedClaudeMdContent: string | null
|
||||
```
|
||||
|
||||
这个字段住在 bootstrap 的唯一理由是**打破循环依赖**:`yoloClassifier` 调 `claudemd`,`claudemd` 读文件系统触发 `permissions`,`permissions` 又会回到 `yoloClassifier`。把它从 React/AppState 链条里抽出来,做成模块级 singleton,循环就断了。
|
||||
|
||||
再看一组:
|
||||
|
||||
```ts
|
||||
// Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first
|
||||
// activated, keep sending the header for the rest of the session so
|
||||
// Shift+Tab toggles don't bust the ~50-70K token prompt cache.
|
||||
afkModeHeaderLatched: boolean | null
|
||||
```
|
||||
|
||||
这个字段必须住在 bootstrap,是因为它是 **prompt cache 的粘性开关**:一旦 AFK 模式被激活过一次,整个 session 都要保持发送 beta header。如果放在会随 React 重渲染或 `/clear` 重置的容器里,Shift+Tab 来回切就会让服务端 prompt cache(50-70K token 的代价)反复 invalidate。bootstrap state 是唯一一个"进程不死就不重置"的地方。
|
||||
|
||||
类似地:
|
||||
|
||||
```ts
|
||||
// Teams created this session via TeamCreate. cleanupSessionTeams()
|
||||
// removes these on gracefulShutdown so subagent-created teams don't
|
||||
// persist on disk forever (gh-32730). TeamDelete removes entries to
|
||||
// avoid double-cleanup. Lives here (not teamHelpers.ts) so
|
||||
// resetStateForTests() clears it between tests.
|
||||
sessionCreatedTeams: Set<string>
|
||||
```
|
||||
|
||||
注释直白地说:放在这里是为了 `resetStateForTests()` 能在测试之间清空它。这不是设计美学,这是测试隔离的工程需求。
|
||||
|
||||
### 模块级 singleton 的陷阱
|
||||
|
||||
为什么模块级 singleton 这么危险,以至于要写三段警告?打开 `src/bootstrap/state.ts:913` 看 `resetStateForTests`:
|
||||
|
||||
```ts
|
||||
// Only used in tests
|
||||
export function resetStateForTests(): void {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('resetStateForTests can only be called in tests')
|
||||
}
|
||||
Object.entries(getInitialState()).forEach(([key, value]) => {
|
||||
STATE[key as keyof State] = value as never
|
||||
})
|
||||
outputTokensAtTurnStart = 0
|
||||
currentTurnTokenBudget = null
|
||||
budgetContinuationCount = 0
|
||||
sessionSwitched.clear()
|
||||
}
|
||||
```
|
||||
|
||||
注意 `if (process.env.NODE_ENV !== 'test') throw` 这一行——这是一个**运行时 guard**,防止有人在生产代码里调用这个清理函数。Bun 的 `mock.module` 是 process-global 的(详见第十四章测试策略),这意味着同一个进程里所有测试文件共享同一个 `STATE` 实例。如果某个测试改了 `STATE.sessionId` 没清理,下一个测试就会看到脏数据。
|
||||
|
||||
反事实推演:如果没有 `resetStateForTests`,每个测试都要手动 `setSessionId(randomUUID())`、`setCwdState(...)`、`setOriginalCwd(...)` —— 几十个字段。漏一个就是 flaky test。所以 `resetStateForTests` 不是便利函数,而是测试可靠性的兜底。
|
||||
|
||||
### 字段级 getter/setter:为什么不用 `STATE.field = x`
|
||||
|
||||
bootstrap state 的另一个反直觉设计是:**它不导出 `STATE` 本身**。外部代码只能通过 63 个 `set*` 和 100 个 `get*` 函数访问。打开 `src/bootstrap/state.ts:1059` 看一个典型例子:
|
||||
|
||||
```ts
|
||||
export function setIsInteractive(value: boolean): void {
|
||||
STATE.isInteractive = value
|
||||
}
|
||||
```
|
||||
|
||||
为什么不直接 `export const STATE` 然后让调用方写 `STATE.isInteractive = true`?答案有两层:
|
||||
|
||||
1. **保留写入边界**:未来某天 `isInteractive` 需要触发副作用(比如 telemetry),只需改 `setIsInteractive` 一个地方。如果直接导出 `STATE`,所有写入点散落在代码库里,重构成本指数级。
|
||||
2. **可被 mock**:测试可以 `mock.module('src/bootstrap/state.ts', ...)` 替换某个 getter 而不影响其他字段。直接导出 `STATE` 意味着整个对象要么全 mock 要么不 mock。
|
||||
|
||||
值得注意的是 `src/bootstrap/state.ts:17` 的注释:
|
||||
|
||||
```ts
|
||||
// Indirection for browser-sdk build (package.json "browser" field swaps
|
||||
// crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto —
|
||||
// zero circular-dep risk. Path-alias import bypasses bootstrap-isolation
|
||||
// (rule only checks ./ and / prefixes); explicit disable documents intent.
|
||||
// eslint-disable-next-line custom-rules/bootstrap-isolation
|
||||
import { randomUUID } from 'src/utils/crypto.js'
|
||||
```
|
||||
|
||||
项目有一条自定义 lint 规则 `custom-rules/bootstrap-isolation`,禁止 bootstrap 模块 import 任何以 `./` 或 `/` 开头的路径——**bootstrap 必须是依赖图的叶子节点**。这个 `eslint-disable` 是为了说明:`src/utils/crypto.js` 是 node:crypto 的纯叶子 re-export,import 它没有循环依赖风险。这个 lint 规则本身是 bootstrap state "不能太胖" 的结构性防线——如果 bootstrap 开始 import 业务模块,整个依赖图就会失控。
|
||||
|
||||
### `createSignal` 的出场:唯一的"可订阅"字段
|
||||
|
||||
绝大部分 bootstrap 字段是"写了就写了,没人订阅"。但有一组例外。打开 `src/bootstrap/state.ts:475`:
|
||||
|
||||
```ts
|
||||
const sessionSwitched = createSignal<[id: SessionId]>()
|
||||
// ...
|
||||
export const onSessionSwitch = sessionSwitched.subscribe
|
||||
```
|
||||
|
||||
`createSignal` 来自 `src/utils/signal.ts`,是一个手写的极简信号实现。`sessionSwitched` 是 bootstrap state 里少数能让外部代码订阅变化的字段——当 `switchSession()` 被调用(比如 `/resume` 切到另一个 session),订阅者会被通知。
|
||||
|
||||
为什么所有字段不都做成 signal?因为 99% 的 bootstrap 字段不需要订阅——它们是"写入即生效"的(比如 `sessionId` 被读的时候就是当前值,不需要响应式)。把所有字段都做成 signal 会让模块复杂度暴涨,而且引入订阅生命周期管理(清理、内存泄漏)。signal 只在最需要的少数几个字段上用,是一种克制的工程选择。
|
||||
|
||||
## 手写的 zustand:34 行的 `createStore`
|
||||
|
||||
如果说 bootstrap state 是"为了不被重置而存在的 singleton",那么 `src/state/store.ts` 就是"为了能被订阅而存在的极简 store"。整个文件 34 行,打开 `src/state/store.ts:1` 你就能看完全部:
|
||||
|
||||
```ts
|
||||
type Listener = () => void
|
||||
type OnChange<T> = (args: { newState: T; oldState: T }) => void
|
||||
|
||||
export type Store<T> = {
|
||||
getState: () => T
|
||||
setState: (updater: (prev: T) => T) => void
|
||||
subscribe: (listener: Listener) => () => void
|
||||
}
|
||||
|
||||
export function createStore<T>(
|
||||
initialState: T,
|
||||
onChange?: OnChange<T>,
|
||||
): Store<T> {
|
||||
let state = initialState
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
|
||||
setState: (updater: (prev: T) => T) => {
|
||||
const prev = state
|
||||
const next = updater(prev)
|
||||
if (Object.is(next, prev)) return
|
||||
state = next
|
||||
onChange?.({ newState: next, oldState: prev })
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这就是整个 store。三个 API:`getState`、`setState`、`subscribe`。两个细节值得拆。
|
||||
|
||||
### `Object.is` 短路:为什么是 `Object.is` 而不是 `===`
|
||||
|
||||
`setState` 里有一行 `if (Object.is(next, prev)) return`——如果 updater 返回的是同一个引用,直接 short-circuit,不通知任何订阅者。这看起来像 `===`,但 `Object.is` 比 `===` 更严格也更聪明:
|
||||
|
||||
- `Object.is(NaN, NaN)` 是 `true`(`===` 是 `false`)
|
||||
- `Object.is(-0, 0)` 是 `false`(`===` 是 `true`)
|
||||
- `Object.is({}, {})` 是 `false`(两个不同的对象引用)
|
||||
|
||||
对于 store 来说,`Object.is` 是**最佳短路判定**:当调用方 `setState(prev => prev)`(返回同一个引用),订阅者不会被惊动。这鼓励了一种风格——只在状态真的变了的时候才创建新对象。`src/state/__tests__/store.test.ts:23` 直接测了这一点:
|
||||
|
||||
```ts
|
||||
test('setState does not notify when state unchanged (Object.is)', () => {
|
||||
const store = createStore({ count: 0 })
|
||||
let notified = false
|
||||
store.subscribe(() => {
|
||||
notified = true
|
||||
})
|
||||
store.setState(prev => prev)
|
||||
expect(notified).toBe(false)
|
||||
})
|
||||
```
|
||||
|
||||
反事实推演:如果用 `JSON.stringify(next) === JSON.stringify(prev)` 做"深度比较"呢?每次 `setState` 都要序列化整个 state 树(AppState 有几十个字段),在大对象上是 O(n) 的开销。而 `Object.is` 是 O(1)。这个差异在 REPL 里每个按键、每个流式 token 都可能触发 `setState` 的场景下,是不可忽视的。
|
||||
|
||||
### `Set<Listener>`:为什么订阅者用 Set 而不是 Array
|
||||
|
||||
`listeners = new Set<Listener>()` 是另一个值得注意的选择。`subscribe` 返回一个 unsubscribe 函数 `() => listeners.delete(listener)`,这是经典的"disposable pattern"。
|
||||
|
||||
如果用 Array:unsubscribe 要 `indexOf` 找到下标再 `splice`,O(n);而且如果同一个 listener 被 subscribe 多次,Array 会有重复,Set 不会。Set 的语义刚好是"同一个订阅者只通知一次",即使你意外 subscribe 两次。
|
||||
|
||||
### 为什么不直接用 zustand
|
||||
|
||||
项目里明明有 `packages/` workspace 机制(见 CLAUDE.md),可以装 zustand 这种 1KB 的库。为什么不装?三个理由:
|
||||
|
||||
1. **零依赖**:`store.ts` 不依赖任何外部包。在反编译重建的项目里,每多一个依赖都意味着多一个潜在的安全审计面和多一个 upgrade 风险。手写 34 行换零依赖,是非常划算的交易。
|
||||
2. **完全可控**:`onChange` 回调是项目特有的扩展。zustand 有 `subscribeWithSelector` middleware 可以实现类似功能,但 API 更复杂。手写版直接把 `onChange` 焊在 `createStore` 签名里,调用方(`AppState.tsx`)不需要任何额外配置。
|
||||
3. **极简语义**:整个 store 的行为可以用一句话描述——"`setState` 用 `Object.is` 短路,变了就通知所有 listener"。zustand 的 middleware 系统(`devtools`、`persist`、`immer`)在 terminal CLI 里大部分用不上。
|
||||
|
||||
## AppState.tsx:把 store 包进 React Context
|
||||
|
||||
第三层是 `src/state/AppState.tsx`。打开 `src/state/AppState.tsx:59`,你会看到 `AppStateProvider` 函数的开头:
|
||||
|
||||
```tsx
|
||||
export function AppStateProvider({ children, initialState, onChangeAppState }: Props): React.ReactNode {
|
||||
// Don't allow nested AppStateProviders.
|
||||
const hasAppStateContext = useContext(HasAppStateContext);
|
||||
if (hasAppStateContext) {
|
||||
throw new Error('AppStateProvider can not be nested within another AppStateProvider');
|
||||
}
|
||||
|
||||
// Store is created once and never changes -- stable context value means
|
||||
// the provider never triggers re-renders. Consumers subscribe to slices
|
||||
// via useSyncExternalStore in useAppState(selector).
|
||||
const [store] = useState(() => createStore<AppState>(initialState ?? getDefaultAppState(), onChangeAppState));
|
||||
```
|
||||
|
||||
这段代码做了三件值得拆的事。
|
||||
|
||||
### `useState(() => createStore(...))`:lazy initialization
|
||||
|
||||
注意 store 不是在模块顶层创建的,而是放在 `useState` 的 lazy initializer 里。这保证了:
|
||||
|
||||
1. **每个 `<AppStateProvider>` 实例有独立的 store**:如果同一个 React 树里 mount 了两个 provider(虽然在嵌套禁令下不可能,但测试场景可能模拟),它们的 store 互不干扰。
|
||||
2. **store 引用稳定**:`useState` 的 lazy initializer 只在首次 render 时调用一次,之后 `store` 引用永远不变。这点至关重要——`AppStoreContext.Provider value={store}` 不会因为 store 引用变化而触发下游所有 consumer 重新订阅。
|
||||
|
||||
反事实推演:如果写成 `const store = createStore(...)`(模块顶层),那么所有 `<AppStateProvider>` 会共享同一个 store,破坏隔离性。如果写成 `const [store] = useState(createStore(...))`(不带 arrow function),每次 render 都会调用 `createStore`,创建新 store,丢失所有订阅者和状态。
|
||||
|
||||
### `HasAppStateContext` 主动 throw:为什么禁止嵌套
|
||||
|
||||
`HasAppStateContext` 是一个独立的 `React.createContext<boolean>(false)`,唯一目的就是检测嵌套。当某个组件树里已经有一个 `<AppStateProvider>`,再 mount 第二个就会触发 throw。
|
||||
|
||||
这个限制看起来很激进——React Context 本身是允许嵌套的,内层会 shadow 外层。为什么这里禁止?
|
||||
|
||||
打开 `src/state/AppState.tsx:90` 附近看 provider 树:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<HasAppStateContext.Provider value={true}>
|
||||
<AppStoreContext.Provider value={store}>
|
||||
<MailboxProvider>
|
||||
<VoiceProvider>{children}</VoiceProvider>
|
||||
</MailboxProvider>
|
||||
</AppStoreContext.Provider>
|
||||
</HasAppStateContext.Provider>
|
||||
)
|
||||
```
|
||||
|
||||
provider 内部还嵌套了 `MailboxProvider` 和 `VoiceProvider`——它们都依赖外层的 store。如果允许嵌套,内层 `<AppStateProvider>` 会创建一个**新的** store,但 `MailboxProvider`/`VoiceProvider` 已经绑定了外层 store。两个 store 不同步会导致 mailbox 和 voice state 与 app state 漂移。禁止嵌套是最简单的保护。
|
||||
|
||||
这也呼应了第十章"为什么 fork Ink 而不是用上游"的设计哲学:**对结构不变量主动 throw,而不是用警告日志**。throw 会让 bug 在开发阶段立刻暴露,而不是在用户环境里慢慢漂移。
|
||||
|
||||
### `useSyncExternalStore` 订阅 slice:为什么不用 `useContext` + `useMemo`
|
||||
|
||||
打开 `src/state/AppState.tsx:129` 的 `useAppState` hook:
|
||||
|
||||
```tsx
|
||||
export function useAppState<T>(selector: (state: AppState) => T): T {
|
||||
const store = useAppStore();
|
||||
|
||||
const get = () => {
|
||||
const state = store.getState();
|
||||
const selected = selector(state);
|
||||
|
||||
if (process.env.USER_TYPE === 'ant' && state === selected) {
|
||||
throw new Error(
|
||||
`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`,
|
||||
);
|
||||
}
|
||||
|
||||
return selected;
|
||||
};
|
||||
|
||||
return useSyncExternalStore(store.subscribe, get, get);
|
||||
}
|
||||
```
|
||||
|
||||
这里用的是 React 18 的 `useSyncExternalStore`——专门为"订阅外部 store"设计的 hook。它解决了 `useContext` 的一个根本问题:**Context 的细粒度订阅**。
|
||||
|
||||
如果用 `useContext(AppStoreContext)`,每个 consumer 都会在 store 变化时 re-render,哪怕它只关心 `state.verbose` 这一个字段。`useSyncExternalStore` + selector 模式让每个 consumer 只在自己关心的 slice 变了的时候才 re-render。
|
||||
|
||||
`get` 函数是 selector 的执行器,`useSyncExternalStore` 会在每次 store 通知时调用 `get`,然后用 `Object.is` 比较返回值——如果没变,跳过 re-render。这与 `store.ts` 的 `Object.is` 短路是一致的协议。
|
||||
|
||||
### `USER_TYPE === 'ant'` 时强制 selector:内部 dogfooding
|
||||
|
||||
注意 `if (process.env.USER_TYPE === 'ant' && state === selected) throw`——当运行环境是 Anthropic 内部开发模式时,如果 selector 返回了整个 state(`state === selected`),直接抛错。
|
||||
|
||||
为什么内部模式更严格?因为返回整个 state 会让 `Object.is` 永远看到"变了"(每次 setState 都创建新 state 对象),consumer 会无差别 re-render,细粒度订阅形同虚设。这是一个**性能保护**:内部开发者(ant)被强制写出正确的 selector,外部用户(community)拿到的是更宽松的 runtime——可能慢一点,但不会因为不小心 return 了整个 state 就崩溃。
|
||||
|
||||
这个 pattern 在反编译产物里特别有趣:它揭示了 Anthropic 内部对 dogfooding 的态度——**自己人用更严格的版本**。类似的内部/外部差异在项目里还出现在多处(比如 `replBridgeActive` 只在 `USER_TYPE === 'ant'` 时出现,见 `src/bootstrap/state.ts:386`)。
|
||||
|
||||
## 三层之间的边界:谁该住在哪里
|
||||
|
||||
有了三层状态容器,每个新字段都要回答一个问题:**它该住哪一层?** 项目的判断标准大致是:
|
||||
|
||||
| 字段特征 | 应该住在 |
|
||||
|---|---|
|
||||
| 进程启动时就需要、React 还没 mount | bootstrap |
|
||||
| 需要在测试之间被 `resetStateForTests()` 清空 | bootstrap |
|
||||
| 是 prompt cache 的粘性 latch(session 级不可变) | bootstrap |
|
||||
| 需要响应式订阅、UI 会消费 | AppState(经 store) |
|
||||
| 跨 turn 持久但只在 React 树里用 | AppState |
|
||||
| 是计算派生值(`getViewedTeammateTask`) | selector(`src/state/selectors.ts`) |
|
||||
|
||||
注意 selector 是第四层——`src/state/selectors.ts` 里的函数(`getViewedTeammateTask`、`getActiveAgentForInput`)是 **pure function**,不持有任何 state。它们的存在让 UI 组件不用每次都重新写派生逻辑:
|
||||
|
||||
```ts
|
||||
export function getViewedTeammateTask(
|
||||
appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>,
|
||||
): InProcessTeammateTaskState | undefined {
|
||||
```
|
||||
|
||||
接受 `Pick<AppState, ...>` 而不是完整 `AppState`,是为了让 selector 的依赖一目了然——这又是一种"显式优于隐式"的工程克制。
|
||||
|
||||
反事实推演:如果所有派生逻辑都直接写在组件里,每个组件都要 import 整个 AppState 然后自己拼。结果是组件测试时要 mock 整个 state,而且改一个派生逻辑要改 N 处。selector 抽出来,既复用又可测。
|
||||
|
||||
## `onChangeAppState`:唯一的副作用集中点
|
||||
|
||||
最后看一个跨层的设计:`onChange` 回调。打开 `src/state/onChangeAppState.ts:42`:
|
||||
|
||||
```ts
|
||||
export function onChangeAppState({
|
||||
newState,
|
||||
oldState,
|
||||
}: {
|
||||
newState: AppState
|
||||
oldState: AppState
|
||||
}) {
|
||||
// toolPermissionContext.mode — single choke point for CCR/SDK mode sync.
|
||||
//
|
||||
// Prior to this block, mode changes were relayed to CCR by only 2 of 8+
|
||||
// mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK
|
||||
// mode only) and a manual notify in the set_permission_mode handler.
|
||||
// Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest
|
||||
// dialog options, the /plan slash command, rewind, the REPL bridge's
|
||||
// onSetPermissionMode — mutated AppState without telling
|
||||
// CCR, leaving external_metadata.permission_mode stale and the web UI out
|
||||
// of sync with the CLI's actual mode.
|
||||
//
|
||||
// Hooking the diff here means ANY setAppState call that changes the mode
|
||||
// notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata)
|
||||
// and the SDK status stream (via notifyPermissionModeChanged → registered
|
||||
// in print.ts). The scattered callsites above need zero changes.
|
||||
```
|
||||
|
||||
这段注释是整个三层状态管理的精华。它讲了一个真实的故事:
|
||||
|
||||
曾经有 8+ 个地方会改 `toolPermissionContext.mode`(Shift+Tab、`/plan`、ExitPlanMode dialog、rewind、bridge 回调……),但只有 2 个地方会通知外部(CCR web UI、SDK status stream)。其他路径会改 AppState 但不通知,导致 web UI 显示的权限模式与 CLI 实际不一致。
|
||||
|
||||
修复方案不是"在每个修改点都加 notify"——那会有 N 个遗漏点。而是**在 `onChangeAppState` 这一个 choke point 做 diff**:任何 mode 变化都会触发 notify,调用方完全无感。这是一个教科书级的"集中副作用"案例。
|
||||
|
||||
这个 pattern 与 `store.ts` 的设计是配合的:`createStore` 接受 `onChange` 回调,回调在 `Object.is` 短路之后、listener 通知之前调用。所以 `onChangeAppState` 只在 state 真的变了的时候被调用,不会收到噪声通知。
|
||||
|
||||
## 反编译产物的特殊痕迹
|
||||
|
||||
这章涉及的代码里有几个值得指出的反编译痕迹:
|
||||
|
||||
1. **`src/types/utils.ts:2` 的 `DeepImmutable<T> = T` 是 stub**。`AppState` 类型用 `DeepImmutable<{...}>` 包裹(见 `src/state/AppStateStore.ts:91`),原本应该是递归 readonly 类型,但反编译产物把它退化成了 `T`。这意味着 `AppState` 实际上没有任何编译期不可变性保护——`store.ts` 的 `Object.is` 短路是唯一防线。如果哪天有人直接 `state.field = value` 而不是 `setState(prev => ({...prev, field: value}))`,TypeScript 不会报错,但所有订阅者都不会被通知。
|
||||
|
||||
2. **`USER_TYPE === 'ant'` 检查**:bootstrap state 和 AppState 都有 `USER_TYPE === 'ant'` 分支。这是 Anthropic 内部构建系统的产物——`USER_TYPE=ant` 触发内部 only 的字段(比如 `replBridgeActive`)和更严格的 runtime 检查(比如 selector 必须返回属性)。社区用户跑 `USER_TYPE=community` 或不设置时拿到的是更宽松但更脆弱的版本。
|
||||
|
||||
3. **`process.env.NODE_ENV !== 'test'` guard**:`resetStateForTests` 用运行时检查而不是编译期 DCE 来保护自己。这是因为反编译产物的 build pipeline 不一定可靠地 strip 掉测试 only 代码——运行时 guard 是最后一道防线。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 bootstrap state 的循环依赖是怎么被 `cachedClaudeMdContent` 字段打破的,见 [第十三章:CLAUDE.md 四层层级与 @include 指令](./13-claudemd.md)
|
||||
- 想看 `USER_TYPE === 'ant'` 的更多分支差异和反编译 stub 痕迹,见 [序章:一份被反编译重建的 CLI](./00-prologue.md)
|
||||
- 想看 `Object.is` 短路在流式 token 场景下的性能影响,见 [第四章:核心 Query Loop](./04-query-loop.md)
|
||||
- 想看 `onChangeAppState` 通知的 CCR/SDK 外部消费者,见 [第十二章:ACP / Bridge / Daemon](./12-acp-bridge-daemon.md)
|
||||
227
docs/outline-output/design/12-acp-bridge-daemon.md
Normal file
227
docs/outline-output/design/12-acp-bridge-daemon.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 第十二章:ACP / Bridge / Daemon —— 三个长驻模式的接线
|
||||
|
||||
> 三个 feature-gated 的长驻模式,各自用不同策略共享同一个 query loop。
|
||||
|
||||
## 为什么长驻模式是 feature-gated 的
|
||||
|
||||
ACP(Agent Client Protocol)、Bridge(Remote Control)、Daemon(supervisor)在 `cli.tsx` 的 fast-path 优先级链中各自占据一个分支,全部被 feature flag 守卫。打开 `src/entrypoints/cli.tsx`,你会看到 `if (feature('BRIDGE_MODE'))` 守着 bridge/remote-control 分支,`if (feature('DAEMON'))` 守着 daemon 子命令,`if (feature('ACP'))` 守着 `--acp` 入口。
|
||||
|
||||
这不是因为这三个模式"功能不完善所以默认关闭"——实际上它们都已经恢复并且功能完整。根本原因是**启动延迟**。每个长驻模式都需要导入重量级依赖(JWT 工具、WebSocket 传输、会话管理),如果把这些 import 放在 fast-path 链之前,`claude --version` 的启动时间会被拖慢。feature flag 让 Bun 编译器在构建时通过 DCE(Dead Code Elimination)把未启用的分支整棵剪掉。
|
||||
|
||||
**反事实推演**:如果不做 feature gate,`BRIDGE_MODE` 分支会强制每个 CLI 进程都 import `jwtUtils.ts`、`sessionRunner.ts`、`workSecret.ts` 等 bridge 模块。在第二章(Code Splitting)已经解释过,JSC 会全量解析 import 的字节码。`--version` 的 RSS 从 35MB 暴涨回去,这不是理论推演,而是已经发生过的现实。
|
||||
|
||||
## ACP Agent:把 QueryEngine 包装成协议实现
|
||||
|
||||
ACP 的核心在 `src/services/acp/agent.ts`。打开这个文件,你会看到一个 `AcpAgent` 类实现了 `@agentclientprotocol/sdk` 的 `Agent` 接口——`initialize`、`authenticate`、`newSession`、`prompt`、`cancel` 等方法一应俱全。
|
||||
|
||||
设计上最值得注意的是:**ACP 没有自己的 query loop,它直接复用 `QueryEngine`**。打开 `src/services/acp/agent.ts:585`,你会看到:
|
||||
|
||||
```ts
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
```
|
||||
|
||||
ACP 的 `prompt()` 方法(`agent.ts:308`)调用的是 `session.queryEngine.submitMessage(promptInput)`——和 REPL 屏幕、pipe 模式用的是同一个 `submitMessage`。区别在于消息消费方式:REPL 把 `SDKMessage` yield 给 Ink 组件渲染,ACP 把它们转发给 ACP 协议的 `sessionUpdate` 通知。
|
||||
|
||||
### 消息转译层:bridge.ts
|
||||
|
||||
`src/services/acp/bridge.ts` 是 ACP 最厚的文件(~1000 行),职责单一但沉重:**把 Claude Code 内部的 `SDKMessage` 类型转译成 ACP 协议的 `SessionUpdate`**。它定义了一个本地判别联合类型 `BridgeSDKMessage`(`bridge.ts:168`),覆盖 9 种消息形态:system、result、assistant、stream_event、user、progress、tool_use_summary、attachment、compact_boundary。
|
||||
|
||||
打开 `bridge.ts:191`,你会看到 `toolInfoFromToolUse` 函数根据工具名做 switch-case,把内部工具调用元数据转译成 ACP 的 `ToolCallContent` 格式。这种"内联 switch"在反编译产物中很常见——原始代码可能用的是策略模式,但反编译后退化成了 switch-case。
|
||||
|
||||
### 权限流水线:createAcpCanUseTool
|
||||
|
||||
`src/services/acp/permissions.ts` 导出的 `createAcpCanUseTool` 是整个 ACP 权限系统的接线点。打开 `permissions.ts:32`,你会看到它返回一个 `CanUseToolFn`——和 REPL 的权限回调签名完全一致。但在内部,它做了三层处理:
|
||||
|
||||
1. **本地管道优先**(`permissions.ts:79`):先跑 `hasPermissionsToUseTool`,这是 Claude Code 内置的权限规则引擎(deny rules、allow rules、tool-specific checks)。如果本地管道直接 allow 或 deny,就不打扰远程客户端。
|
||||
2. **客户端委托**(`permissions.ts:130`):如果本地管道返回 `ask`(需要用户确认),才通过 `conn.requestPermission()` 委托给 ACP 客户端。
|
||||
3. **ExitPlanMode 特殊处理**(`permissions.ts:57`):退出计划模式时不是简单的 allow/deny,而是提供多选项(auto、acceptEdits、default、plan,如果可用还包括 bypassPermissions)。
|
||||
|
||||
这种设计解决了一个关键问题:**ACP 客户端不应该为每个工具调用都弹权限对话框**。本地规则(如 `.claude/settings.json` 中的 `allow` 规则)应该静默放行,只有不确定的情况才打扰用户。如果不这么做,RCS Web UI 上每秒都会弹出几个权限确认,体验完全不可用。
|
||||
|
||||
### bypassPermissions 的三层防护
|
||||
|
||||
ACP 的 `bypassPermissions` 模式有严格的三层防护,打开 `agent.ts:1005` 你会看到:
|
||||
|
||||
```ts
|
||||
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
|
||||
return (
|
||||
isProcessBypassPermissionModeAvailable() &&
|
||||
(isAcpBypassLocallyEnabled() ||
|
||||
isSettingsBypassPermissionMode(settingsMode))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
三层分别是:进程级(非 root/sandbox 环境检测,`agent.ts:1013`)、环境变量级(`ACP_PERMISSION_MODE=bypassPermissions` 或 `CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS=1`)、配置级(`settings.json` 中 `permissions.defaultMode=bypassPermissions`)。三层全部满足才开放。
|
||||
|
||||
**反事实推演**:如果不做这三层防护,任何能连接到 ACP agent 的客户端(包括远程 RCS 用户)都能直接绕过所有权限检查,执行任意 shell 命令。在 daemon 场景下这尤其危险——daemon worker 以宿主用户身份运行。
|
||||
|
||||
### Prompt 队列化
|
||||
|
||||
`agent.ts:278` 实现了一个简单的 prompt 队列:如果当前有 prompt 正在运行,新的 prompt 会被推入 `pendingQueue`,等待当前 prompt 完成后 FIFO 消费。`agent.ts:1054` 的 `compactPendingQueue` 函数在队列头部消费超过 1024 个且 head 指针超过长度一半时做数组切片压缩。
|
||||
|
||||
这个设计解决的是**并发 prompt 竞争**:如果 ACP 客户端在 Claude 还在处理上一条消息时发了新消息,没有队列的话会导致 `QueryEngine` 的 abort controller 状态混乱(`agent.ts:302` 的 `resetAbortController` 注释解释了这个问题)。
|
||||
|
||||
### entry.ts:为什么重定向 console.log
|
||||
|
||||
打开 `src/services/acp/entry.ts:44`,你会看到一行诡异的代码:
|
||||
|
||||
```ts
|
||||
console.log = console.error
|
||||
```
|
||||
|
||||
ACP 通过 `process.stdin` / `process.stdout` 与客户端通信(`entry.ts:36` 创建 `ndJsonStream`),所以 stdout 必须完全留给 ACP 协议消息。任何 `console.log` 调用如果写到 stdout,都会被客户端解析为 ACP 消息,导致协议错误。因此所有 console 输出都被重定向到 stderr。
|
||||
|
||||
**反事实推演**:如果遗漏了这行,任何 debug 级别的 `console.log` 都会在 Zed 编辑器或 RCS Web UI 上显示为不可解析的消息,触发连接断开。
|
||||
|
||||
## acp-link:WebSocket 代理的"进程透明"
|
||||
|
||||
`packages/acp-link` 是一个独立的 npm 包,不依赖 Claude Code 源码。它的职责是:**让任何 ACP 客户端(如 Zed 编辑器)通过 WebSocket 连接到一个 ACP agent 进程**。
|
||||
|
||||
打开 `packages/acp-link/src/server.ts:279`,你会看到 acp-link 每次 WebSocket `connect` 时都会 spawn 一个 agent 子进程:
|
||||
|
||||
```ts
|
||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||
cwd: AGENT_CWD,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
})
|
||||
```
|
||||
|
||||
然后通过 Node.js 的 `Readable.toWeb` / `Writable.toWeb` 把子进程的 stdin/stdout 转成 Web Stream,交给 `@agentclientprotocol/sdk` 的 `ndJsonStream`:
|
||||
|
||||
```ts
|
||||
const input = Writable.toWeb(agentProcess.stdin!)
|
||||
const output = Readable.toWeb(agentProcess.stdout!)
|
||||
const stream = acp.ndJsonStream(input, output)
|
||||
const connection = new acp.ClientSideConnection(...)
|
||||
```
|
||||
|
||||
对 ACP 客户端来说,它以为自己在和一个原生 ACP 服务通信——它不知道中间隔了一层 WebSocket 代理和一个被 spawn 的子进程。这就是"进程透明"的含义。
|
||||
|
||||
### 权限传递:环境变量注入
|
||||
|
||||
acp-link 的 `buildAgentEnv()`(`server.ts:1031`)把 `ACP_PERMISSION_MODE` 注入到子进程的环境变量中。子进程(即 Claude Code ACP agent)在启动时读取这个环境变量来决定默认权限模式。
|
||||
|
||||
这种设计让 acp-link 可以在启动时通过 CLI 参数 `--permission-mode auto` 或环境变量 `ACP_PERMISSION_MODE` 统一设置权限模式,而不需要每个 ACP 客户端在 `newSession` 时分别指定。权限模式解析链(`server.ts:986`):客户端请求的 mode > acp-link 默认 mode(环境变量/CLI 参数)> agent 内部默认。对于 `bypassPermissions`,则强制要求 acp-link 本地已启用该模式(`server.ts:1005`),否则直接抛异常。
|
||||
|
||||
### RCS 集成:REST 注册 + WS identify 两步流程
|
||||
|
||||
打开 `packages/acp-link/src/rcs-upstream.ts:66`,你会看到 `registerViaRest` 方法先通过 REST API `POST /v1/environments/bridge` 注册,获取 `environment_id`,然后建立 WebSocket 连接发送 `identify` 消息(`rcs-upstream.ts:143`)。
|
||||
|
||||
两步流程的设计意图:REST 注册是无状态的,可以用 API token 认证;WebSocket identify 则是在已建立的 WS 连接上用 `Sec-WebSocket-Protocol` header 传递 token(`ws-auth.ts:9` 的 `encodeWebSocketAuthProtocol` 把 token 编码成 `rcs.auth.<base64url>` 格式)。两者分离的好处是 WebSocket 可以断线重连而不需要重新注册(只要 `environment_id` 还有效)。
|
||||
|
||||
打开 `ws-auth.ts:60`,你会看到 token 比较用了 `timingSafeEqual`:
|
||||
|
||||
```ts
|
||||
return timingSafeEqual(sha256(providedToken), sha256(expectedToken))
|
||||
```
|
||||
|
||||
先 SHA-256 再比较,防止 timing attack 泄漏 token 长度信息。
|
||||
|
||||
### 虚拟 WSContext:relay 的巧妙设计
|
||||
|
||||
`server.ts:120` 的 `createRelayWs()` 创建了一个假的 `WSContext` 对象——`send` 是 no-op,`readyState` 永远返回 1(OPEN)。这是因为 RCS relay 消息不需要发送到本地 WebSocket,而是通过 `rcsUpstream.send()` 发送到 RCS 服务器。虚拟 WSContext 让 relay 消息可以复用 `dispatchClientMessage` 的完整分发逻辑,而不需要为 relay 写一套独立的处理代码。
|
||||
|
||||
### 前端重连不重启进程
|
||||
|
||||
`server.ts:252` 有一个容易被忽略但很精巧的设计:
|
||||
|
||||
```ts
|
||||
if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) {
|
||||
logAgent.info('already connected, resending status')
|
||||
send(ws, 'status', { connected: true, ... })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
当 Zed 编辑器因网络波动断开 WebSocket 后重连时,acp-link 检查 agent 进程是否还活着。如果进程还健康,只重新发送 status 消息,不重启进程。这避免了每次前端重连都重启 agent 的浪费——agent 进程可能正在执行一个长时间任务。
|
||||
|
||||
## Bridge 模式:Anthropic 原版的"云端会话调度"
|
||||
|
||||
Bridge 模式(`src/bridge/`)是 Anthropic 原版 Claude Code 的 Remote Control 实现——与 ACP 不同,它是围绕 Anthropic 云端 API 设计的。ACP 是后来添加的开放协议,Bridge 则是原始的封闭实现。
|
||||
|
||||
打开 `src/bridge/bridgeMain.ts:1`,第一行就是 `import { feature } from 'bun:bundle'`——整个 bridge 目录都是 feature-gated 的。Bridge 的核心是 `runBridgeLoop` 函数(`bridgeMain.ts:140`),它实现了一个经典的 poll-dispatch 模式:
|
||||
|
||||
1. 向 Anthropic 云端 API 轮询待处理的 work item(`bridgeMain.ts` 中的 poll 循环)
|
||||
2. 收到 work 后 spawn 一个子进程执行(通过 `SessionSpawner`)
|
||||
3. 心跳活跃的 work item(`bridgeMain.ts` 的 heartbeat 循环)
|
||||
4. work 完成后 ack 回云端
|
||||
|
||||
Bridge 使用 JWT 认证(`jwtUtils.ts` 中的 `createTokenRefreshScheduler`),token 刷新调度器定期刷新 access token。Work secret(`workSecret.ts` 的 `decodeWorkSecret`)包含编码后的 JWT,用于 ack 和心跳的认证。
|
||||
|
||||
### Daemon 与 Bridge 的关系
|
||||
|
||||
Daemon(`src/daemon/`)是 Bridge 的 supervisor。打开 `src/daemon/main.ts:52`,`daemonMain` 函数处理 `claude daemon start/stop/status/bg/attach/logs/kill` 子命令。其中 `start` 子命令启动 supervisor 循环(`main.ts:230`),supervisor 的唯一默认 worker 是 `remoteControl`:
|
||||
|
||||
```ts
|
||||
const workers: WorkerState[] = [{
|
||||
kind: 'remoteControl',
|
||||
process: null,
|
||||
backoffMs: BACKOFF_INITIAL_MS,
|
||||
failureCount: 0,
|
||||
parked: false,
|
||||
...
|
||||
}]
|
||||
```
|
||||
|
||||
每个 worker 通过 `buildCliLaunch` + `spawnCli` 启动子进程,传入 `--daemon-worker=remoteControl` 参数。子进程入口在 `src/daemon/workerRegistry.ts:26`,映射到 `runRemoteControlWorker()`(`workerRegistry.ts:57`),后者调用 `runBridgeHeadless(opts, controller.signal)`——最终进入 Bridge 的 headless 循环。
|
||||
|
||||
**为什么 Daemon 不直接跑 Bridge 循环?** 因为 Daemon 需要监控 worker 进程的健康状态,在崩溃时重启。如果 Bridge 循环直接在 supervisor 进程里跑,supervisor 崩溃时没有更高层的恢复机制。worker 进程隔离让 supervisor 可以通过退出码判断是永久错误(`EXIT_CODE_PERMANENT = 78`,来自 `sysexits.h` 的 `EX_CONFIG`)还是可重试的临时错误。
|
||||
|
||||
### Worker 崩溃的指数退避与快速失败 park
|
||||
|
||||
打开 `src/daemon/main.ts:377`,你会看到 worker 退出处理逻辑。快速失败检测(`main.ts:394`):如果 worker 在启动后 10 秒内退出,计入 `failureCount`,连续 5 次快速失败后 park 该 worker(不再重启)。正常退出的 worker 则重置 `failureCount` 和 `backoffMs`。
|
||||
|
||||
退避策略是标准的指数退避(`main.ts:423`):初始 2 秒,倍数 2,上限 120 秒。加上随机 jitter 防止多个 worker 同时重启。
|
||||
|
||||
**反事实推演**:如果没有 park 机制,一个配置错误的 worker(比如 CWD 不存在)会无限循环 spawn-crash-restart,持续消耗 CPU 和日志空间。`EXIT_CODE_PERMANENT` 让 worker 可以主动声明"别重启我了"。
|
||||
|
||||
### Daemon 状态持久化
|
||||
|
||||
`src/daemon/state.ts` 把 daemon 的 PID、CWD、启动时间、worker 类型写入 `~/.claude/daemon/remote-control.json`。另一个 CLI 进程(比如 `claude daemon status`)通过读取这个文件并用 `process.kill(pid, 0)` 检测进程是否存活来查询状态。如果 PID 已死但文件还在,自动清理(`state.ts:99` 的 stale 检测)。
|
||||
|
||||
## 自托管 RCS:三层架构的交汇点
|
||||
|
||||
`packages/remote-control-server/`(简称 RCS)是自托管的 Remote Control Server,提供了完整的 Web UI 控制面板。打开 `packages/remote-control-server/src/index.ts:1`,你会看到一个 Hono 应用注册了四组路由:
|
||||
|
||||
- **v1 路由**:`/v1/environments/bridge`——REST 注册端点,供 acp-link 调用
|
||||
- **v2 路由**:`/v2/code-sessions`、`/v2/worker`——Worker API,供 Bridge 模式使用
|
||||
- **acp 路由**:`/acp/ws`——ACP WebSocket 端点,供 acp-link 连接
|
||||
- **web 路由**:`/web/*`——React 19 + Vite + Radix UI 构建的 Web UI
|
||||
|
||||
RCS 的核心传输层在 `packages/remote-control-server/src/transport/`,有三个 WebSocket handler:`ws-handler.ts`(原始 Bridge WS)、`acp-ws-handler.ts`(ACP 协议 WS)、`acp-relay-handler.ts`(ACP relay,转发现有 ACP 连接的消息)。这三个 handler 共享同一个 `event-bus.ts` 事件总线。
|
||||
|
||||
RCS 是三个长驻模式的交汇点:
|
||||
- **Bridge 模式**通过 v2 Worker API 注册和通信
|
||||
- **ACP 模式**通过 acp-link 代理注册和通信
|
||||
- **Daemon**可以管理运行 RCS 或 Bridge worker 的进程
|
||||
|
||||
## 三个模式的横向对比
|
||||
|
||||
| 维度 | ACP | Bridge | Daemon |
|
||||
|------|-----|--------|--------|
|
||||
| 协议 | 开放 ACP 协议(ndjson over stdio) | Anthropic 私有 REST+WS API | 进程管理(spawn + SIGTERM) |
|
||||
| 入口 | `--acp` flag | `BRIDGE_MODE` feature | `DAEMON` feature |
|
||||
| 通信方式 | stdin/stdout ndjson | HTTP REST + WebSocket | 环境变量 + stdio pipe |
|
||||
| 认证 | 无(自托管) | JWT + OAuth | 本地文件状态 |
|
||||
| query 复用 | 直接 new QueryEngine | spawn 子进程跑 REPL/bridge | spawn 子进程跑 worker |
|
||||
| 超时管理 | prompt 队列 + cancelGeneration | session timeout + work secret | 快速失败 park + 退避 |
|
||||
|
||||
### 为什么 ACP 不 spawn 子进程
|
||||
|
||||
Bridge 和 Daemon 都通过 spawn 子进程来隔离工作负载,但 ACP 直接在同一进程内创建 `QueryEngine` 实例。原因是:ACP 的通信通道是 stdin/stdout——它本身就是被设计为"被某个 IDE 或代理 spawn 的子进程"。如果 ACP 再 spawn 子进程,就变成了两层子进程嵌套,通信复杂度倍增。
|
||||
|
||||
### 为什么 Bridge 需要 poll 循环而不是 push
|
||||
|
||||
Bridge 的设计受制于 Anthropic 云端 API 的架构——work item 存在云端队列中,本地 bridge 需要主动轮询。这不是技术选择而是架构约束。ACP 模式则不同,客户端直接 push prompt 给 agent,不需要云端中间层。
|
||||
|
||||
## environment-runner:三条线的交汇点
|
||||
|
||||
`claude environment-runner` / `claude self-hosted-runner`(BYOC runner)是产品大纲第十一章提到的功能。它是 ACP、Bridge、CI 三条线的交汇点:在 CI 环境中,runner 可以用 ACP 协议暴露 Claude Code 能力,也可以通过 Bridge 模式连接 Anthropic 云端。三者共享的底层是同一个 `QueryEngine`,区别只在于谁发起 prompt(CI 脚本、IDE 客户端、云端调度器)和权限如何传递(环境变量、JWT、ACP permission 回调)。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 QueryEngine 的完整 API,见 [第四章:核心 Query Loop](./04-query-loop.md)
|
||||
- 想看 feature flag 的 DCE 机制如何让这三个模式在构建时被剪掉,见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看权限系统的完整规则引擎,见 [第十一章:三层状态管理](./11-state-management.md) 中的 `AppState.toolPermissionContext` 段
|
||||
- 想看 Code Splitting 如何让长驻模式的开销不影响快速路径,见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md)
|
||||
206
docs/outline-output/design/13-claudemd.md
Normal file
206
docs/outline-output/design/13-claudemd.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 第十三章:CLAUDE.md 四层层级与 @include 指令
|
||||
|
||||
> 一份"配置文件"被设计成有优先级的文件系统爬取协议,原因是 LLM 的注意力分布天然偏向上下文尾部。
|
||||
|
||||
## 逆序加载:为什么"离你最近"的指令优先级最高
|
||||
|
||||
打开 `src/utils/claudemd.ts` 的头部注释(第 1-26 行),你会看到整个记忆系统的契约声明:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Files are loaded in the following order:
|
||||
*
|
||||
* 1. Managed memory (eg. /etc/claude-code/CLAUDE.md) - Global instructions for all users
|
||||
* 2. User memory (~/.claude/CLAUDE.md) - Private global instructions for all projects
|
||||
* 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, and .claude/rules/*.md in project roots)
|
||||
* 4. Local memory (CLAUDE.local.md in project roots) - Private project-specific instructions
|
||||
*
|
||||
* Files are loaded in reverse order of priority, i.e. the latest files are highest priority
|
||||
* with the model paying more attention to them.
|
||||
*/
|
||||
```
|
||||
|
||||
四层层级:Managed -> User -> Project -> Local。先加载的先拼接,后加载的后拼接。这个顺序不是随意选的——它利用了 LLM 的一个已知特性:**模型对上下文尾部的注意力天然更高**(lost-in-the-middle 效应)。所以 `CLAUDE.local.md`(个人私有项目指令)出现在拼接字符串的最末尾,`/etc/claude-code/CLAUDE.md`(组织管理员策略)出现在最开头。
|
||||
|
||||
如果不这么做——比如按"先 Local 后 Managed"拼接——那组织级的"禁止将凭证写入日志"这类安全策略会被埋在上下文深处,模型更可能忽略它。这个逆序设计把最高优先级的指令放在模型注意力最集中的位置。
|
||||
|
||||
实现上,`getMemoryFiles()`(`claudemd.ts:789`)严格按 Managed -> User -> Project -> Local 顺序 push 结果数组。注释说的"reverse order of priority"指的是**加载顺序与优先级相反**:最先加载(Managed)优先级最低,最后加载(Local)优先级最高。
|
||||
|
||||
## 向上爬取:从 CWD 到根的目录遍历
|
||||
|
||||
`getMemoryFiles()` 在处理 Project 和 Local 层级时(`claudemd.ts:848-933`),从 CWD 开始向上遍历到文件系统根:
|
||||
|
||||
```typescript
|
||||
const dirs: string[] = []
|
||||
const originalCwd = getOriginalCwd()
|
||||
let currentDir = originalCwd
|
||||
|
||||
while (currentDir !== parse(currentDir).root) {
|
||||
dirs.push(currentDir)
|
||||
currentDir = dirname(currentDir)
|
||||
}
|
||||
// Process from root downward to CWD
|
||||
for (const dir of dirs.reverse()) {
|
||||
```
|
||||
|
||||
注意那个 `.reverse()`:先收集路径(CWD -> root),然后反转成 root -> CWD 的顺序遍历。这样做的效果是:离 CWD 最近的 `CLAUDE.md` 最后被 push 到结果数组,自然获得最高优先级。
|
||||
|
||||
一个可能被忽略的细节:每一层目录会同时尝试读取三个位置——`CLAUDE.md`、`.claude/CLAUDE.md`、`.claude/rules/*.md`(Project),以及 `CLAUDE.local.md`(Local)。同一个目录可以同时贡献一个 Project 级和多个 rules 文件。
|
||||
|
||||
如果不做向上遍历而是只读 CWD 一层,那 monorepo 子目录就无法继承仓库根目录的全局指令。一个 Go 项目根目录的 CLAUDE.md 说"用 gofmt 格式化",`cmd/server/` 子目录的 CLAUDE.md 补充"这个子模块用 Go 1.22",两层都需要生效。
|
||||
|
||||
## `@include` 指令:四种路径形式与 AST 安全
|
||||
|
||||
`@include` 的路径解析在 `extractIncludePathsFromTokens()`(`claudemd.ts:450`)中实现。支持四种前缀:
|
||||
|
||||
- `@./relative/path` — 相对于当前文件
|
||||
- `@path`(无前缀) — 等同于 `@./path`
|
||||
- `@~/home/path` — 用户主目录
|
||||
- `@/absolute/path` — 绝对路径
|
||||
|
||||
路径解析委托给 `expandPath()`(`src/utils/path.ts:40`),这个函数处理 `~` 展开、POSIX/Windows 路径互转、null 字节安全检查。
|
||||
|
||||
关键的边界约束在 `extractIncludePathsFromTokens` 内部:
|
||||
|
||||
```typescript
|
||||
if (element.type === 'code' || element.type === 'codespan') {
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
**代码块和行内代码内的 `@path` 会被跳过**。这不是字符串匹配能做到的——实现上用了 `marked` 的 `Lexer`(`claudemd.ts:31`)将 Markdown 解析成 AST token 树,只从"叶子文本节点"中提取 `@` 路径。`gfm: false` 是必须的(`claudemd.ts:364`),因为 GFM 模式下 `~` 会被解析为删除线 token,导致 `@~/path` 中的 `~` 被吞掉。
|
||||
|
||||
如果不走 AST 而用正则暴力匹配,`@include` 在代码块示例中也会被解析——比如 CLAUDE.md 里写着"可以用 `@./config.yaml` 引入配置"这段说明文字,里面的示例路径就会被当成真正的 include 指令执行。
|
||||
|
||||
## 防循环与静默忽略
|
||||
|
||||
`processMemoryFile()`(`claudemd.ts:617`)用 `processedPaths: Set<string>` 追踪已处理文件,遇到重复路径直接返回空数组。同时在 `depth >= MAX_INCLUDE_DEPTH`(`claudemd.ts:629`,最大深度 5)时截断。
|
||||
|
||||
```typescript
|
||||
const normalizedPath = normalizePathForComparison(filePath)
|
||||
if (processedPaths.has(normalizedPath) || depth >= MAX_INCLUDE_DEPTH) {
|
||||
return []
|
||||
}
|
||||
```
|
||||
|
||||
对符号链接做了双重追踪(`claudemd.ts:644-647`)——同时记录原始路径和解析后的路径,防止通过 symlink 绕过去重。
|
||||
|
||||
文件不存在(ENOENT)不会报错——`handleMemoryFileReadError`(`claudemd.ts:401`)对 ENOENT 和 EISDIR 直接 return。这个设计是有意为之的:CLAUDE.md 经常在仓库间复制粘贴,`@include` 引用的路径在另一个项目里可能不存在。如果每次遇到不存在的文件就抛异常,CLAUDE.md 就失去了可移植性。
|
||||
|
||||
如果不静默忽略而是抛错,用户从别人的项目模板复制 CLAUDE.md 后就会因为一个缺失的 include 路径而无法启动。`ENOENT` 静默处理是可移植性换安全性的典型取舍。
|
||||
|
||||
## 60+ 种扩展名:为什么不是只有 .md
|
||||
|
||||
`TEXT_FILE_EXTENSIONS`(`claudemd.ts:95`)是一个包含 60+ 种扩展名的 Set。不仅有 `.md`、`.txt`,还有 `.ts`、`.py`、`.rs`、`.swift`、`.sql`、`.graphql`、`.proto`、`.vue`、`.svelte`...
|
||||
|
||||
这个列表的存在是因为 `@include` 被设计为**项目知识的引用机制**,不只是 Markdown 的 include。你可以在 CLAUDE.md 里写 `@./src/types/api.ts` 把 API 类型定义直接喂给模型,让模型理解项目的类型系统。也可以写 `@./schema.graphql` 引入 GraphQL schema。
|
||||
|
||||
在 `parseMemoryFileContent()`(`claudemd.ts:342`)中,非文本扩展名的文件会被跳过:
|
||||
|
||||
```typescript
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
if (ext && !TEXT_FILE_EXTENSIONS.has(ext)) {
|
||||
logForDebugging(`Skipping non-text file in @include: ${filePath}`)
|
||||
return { info: null, includePaths: [] }
|
||||
}
|
||||
```
|
||||
|
||||
注意判断逻辑:**有扩展名但不在白名单里才跳过**。无扩展名文件(如 `Makefile`、`Dockerfile`)不会被拦截——因为很多经典的项目配置文件没有扩展名。这是一个可能让人意外的边界:你可以 `@./Makefile`,但不能 `@./image.png`。
|
||||
|
||||
如果不限制扩展名,用户(或模型自己)的 `@include` 可能意外引入二进制文件(`.png`、`.pdf`、`.zip`),这些二进制数据会直接注入系统提示的 token 流,不仅浪费 token,还可能导致模型解析错误。
|
||||
|
||||
## 40,000 字符上限与 HTML 注释剥离
|
||||
|
||||
`MAX_MEMORY_CHARACTER_COUNT = 40000`(`claudemd.ts:91`)限制了单个记忆文件的推荐最大长度。超过这个值的文件不会被截断(这个常量名说的是"推荐"),但在 `getLargeMemoryFiles()`(`claudemd.ts:1131`)中会被标记为"大文件"。
|
||||
|
||||
另一个处理是 `stripHtmlComments()`(`claudemd.ts:291`)——块级 HTML 注释 `<!-- ... -->` 会被剥离。这用的是 `marked` 的 Lexer 来识别块级 HTML token,保留行内注释和代码块内的注释不动。未闭合的 `<!--` 也不会被处理——防止一个打字错误吞掉整个文件。
|
||||
|
||||
为什么 HTML 注释要剥离?因为 CLAUDE.md 的作者经常用 `<!-- 内部笔记 -->` 写给自己看的备注,这些内容不应该进入模型的上下文——它们是给人读的元信息,不是给模型的指令。
|
||||
|
||||
## `@include` 的外部文件安全警告
|
||||
|
||||
`@include` 允许引用 CWD 之外的文件,但这会触发一个安全机制。`getExternalClaudeMdIncludes()`(`claudemd.ts:1403`)会扫描所有已加载的记忆文件,找出"非 User 类型且有 parent 且路径在 CWD 之外"的 include。
|
||||
|
||||
```typescript
|
||||
export function getExternalClaudeMdIncludes(
|
||||
files: MemoryFileInfo[],
|
||||
): ExternalClaudeMdInclude[] {
|
||||
const externals: ExternalClaudeMdInclude[] = []
|
||||
for (const file of files) {
|
||||
if (file.type !== 'User' && file.parent && !pathInOriginalCwd(file.path)) {
|
||||
externals.push({ path: file.path, parent: file.parent })
|
||||
}
|
||||
}
|
||||
return externals
|
||||
}
|
||||
```
|
||||
|
||||
注意 `file.type !== 'User'`:User 级别的 CLAUDE.md 可以自由 include 任何路径(`claudemd.ts:832` 传 `includeExternal: true`),这是用户的私有全局配置。但 Project 级别的 include 只在用户明确批准后才允许引用外部文件(`config.hasClaudeMdExternalIncludesApproved`)。
|
||||
|
||||
这个区分的合理性在于:User 级 CLAUDE.md 在 `~/.claude/` 下,是用户完全控制的私有空间。而 Project 级 CLAUDE.md 是签入代码仓库的,如果它 `@include` 引用了 `/etc/shadow` 之类的敏感路径,就构成了一个通过代码仓库投毒的攻击面。
|
||||
|
||||
## `.claude/rules/` 的 frontmatter 路径匹配
|
||||
|
||||
`.claude/rules/*.md` 下的文件支持 frontmatter 中的 `paths:` 字段来做条件匹配(`claudemd.ts:248-278`)。这种文件只在处理特定路径的文件时才被加载,而不是一开始就全部注入上下文。
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/services/api/**"
|
||||
- "src/utils/model/**"
|
||||
---
|
||||
这里写与 Provider 系统相关的指令...
|
||||
```
|
||||
|
||||
解析逻辑在 `parseFrontmatterPaths()`(`claudemd.ts:253`):提取 `paths` 字段,去掉 `/**` 后缀(因为 `ignore` 库会自动匹配子目录),然后用 `picomatch` 做 glob 匹配(`claudemd.ts:571`,调用 `ignore().add(globs).ignores(relativePath)`)。
|
||||
|
||||
这个设计的意图是节省 token:一个大型项目可能有几十个 rules 文件,但如果每次对话都全部注入,就是巨大的 token 浪费。frontmatter paths 让 rules 文件只在"相关"时才被加载——当模型正在编辑 `src/services/api/claude.ts` 时,`paths: ["src/services/api/**"]` 的规则才会生效。
|
||||
|
||||
## 从记忆文件到系统提示:context.ts 的装配线
|
||||
|
||||
`src/context.ts` 是最终的装配车间。`getSystemContext()`(`context.ts:116`)负责 git status、日期、缓存断点注入;`getUserContext()`(`context.ts:155`)负责 CLAUDE.md 的加载和拼接。
|
||||
|
||||
```typescript
|
||||
const claudeMd = shouldDisableClaudeMd
|
||||
? null
|
||||
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
|
||||
```
|
||||
|
||||
注意调用链:`getMemoryFiles()` 返回 `MemoryFileInfo[]` -> `filterInjectedMemoryFiles()` 根据 GrowthBook feature flag 过滤 AutoMem/TeamMem -> `getClaudeMds()` 拼接成最终字符串。
|
||||
|
||||
`getClaudeMds()`(`claudemd.ts:1152`)给每种类型的文件加了描述性后缀:
|
||||
|
||||
- Project: `" (project instructions, checked into the codebase)"`
|
||||
- Local: `" (user's private project instructions, not checked in)"`
|
||||
- User/Managed: `" (user's private global instructions for all projects)"`
|
||||
- TeamMem: `" (shared team memory, synced across the organization)"`
|
||||
|
||||
这些后缀直接出现在模型的系统提示中,帮助模型区分哪些指令是团队共享的、哪些是个人私有的。
|
||||
|
||||
最终,`getMemoryFiles` 被 `lodash.memoize` 缓存(`claudemd.ts:789`),整个会话期间只执行一次文件系统遍历。缓存失效通过 `clearMemoryFileCaches()` 和 `resetGetMemoryFilesCache()` 控制——后者额外设置一个标记让 InstructionsLoaded hook 在下次加载时触发。
|
||||
|
||||
## worktree 嵌套的处理:一个容易被忽略的边界
|
||||
|
||||
`getMemoryFiles()` 有一个专门处理 git worktree 嵌套的逻辑(`claudemd.ts:858-883`)。当你在 worktree 内运行 Claude Code 时,向上遍历会经过 worktree root 和 main repo root,两个目录都可能有 `CLAUDE.md`。如果不做特殊处理,同一份签入文件会被加载两次。
|
||||
|
||||
```typescript
|
||||
const gitRoot = findGitRoot(originalCwd)
|
||||
const canonicalRoot = findCanonicalGitRoot(originalCwd)
|
||||
const isNestedWorktree =
|
||||
gitRoot !== null &&
|
||||
canonicalRoot !== null &&
|
||||
normalizePathForComparison(gitRoot) !==
|
||||
normalizePathForComparison(canonicalRoot) &&
|
||||
pathInWorkingPath(gitRoot, canonicalRoot)
|
||||
```
|
||||
|
||||
当检测到嵌套 worktree 时,main repo root 范围内的 Project 文件会被跳过(`skipProject` 标记),但 `CLAUDE.local.md` 仍然被加载——因为它是 gitignored 的,只在 main repo 中存在。
|
||||
|
||||
这个边界处理的触发条件非常特殊:你必须在 worktree 目录内启动 Claude Code,且 worktree 嵌套在 main repo 的工作树中。大多数用户永远不会遇到,但如果遇到"为什么我的 CLAUDE.md 指令重复了",答案就在这里。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看系统提示的完整装配过程(git status / date / CLAUDE.md / memory files 如何组装),见 [第十五章:测试策略](./15-testing-strategy.md) 中关于 `getSystemContext` / `getUserContext` memoize 缓存与测试 mock 的讨论
|
||||
- 想看 `context.ts` 的 memoize 缓存如何与 `query.ts` 的流式响应交互,见 [第四章:核心 Query Loop](./04-query-loop.md)
|
||||
- 想看 feature flag 如何控制 AutoMem / TeamMem 的加载,见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看 `.claude/rules/` 的 conditional rules 在嵌套目录遍历中的加载策略,见 [第六章:工具系统的延迟加载与 CORE_TOOLS 白名单](./06-tools-deferred.md) 中关于 token 预算管理的讨论
|
||||
197
docs/outline-output/design/14-testing-strategy.md
Normal file
197
docs/outline-output/design/14-testing-strategy.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# 第十四章:测试策略 —— 为什么 mock 必须从底层 HTTP 开始
|
||||
|
||||
> Bun 的 mock.module 是进程全局的,一个测试文件的 mock 会让整个进程中毒
|
||||
|
||||
## mock.module 不是 Jest 的 jest.mock
|
||||
|
||||
大多数从 Jest/Vitest 迁移到 `bun:test` 的开发者会自然地假设 `mock.module` 和 `jest.mock` 一样——per-file 隔离,每个测试文件有自己独立的 mock 命名空间。Bun 打破了这个假设。
|
||||
|
||||
打开 `tests/mocks/axios.ts:1`,文件顶部的注释直接点出了这个问题的本质:
|
||||
|
||||
```
|
||||
Each call to `setupAxiosMock()` registers its own `mock.module('axios', ...)`
|
||||
that only knows about the handle returned to that call. No shared state between
|
||||
test files — eliminates cross-file mock pollution.
|
||||
```
|
||||
|
||||
这句话暗示了一个残酷的事实:`mock.module` 在 Bun 中是 **process-global last-write-wins**。你在测试文件 A 里调用 `mock.module('src/utils/log.ts', fakeLog)`,同进程里任何后续 `require('src/utils/log.ts')` 或 `import ... from 'src/utils/log.ts'` 都会拿到 `fakeLog`——无论调用方用的是什么路径字符串,无论它写在哪个文件里。`require()` 和 `import()` 共享同一张模块注册表。
|
||||
|
||||
这意味着:如果你在 `launchSchedule.test.ts` 里 mock 了 `triggersApi.ts`(上层业务模块),同目录的 `api.test.ts`(回归测试)再 `import('../triggersApi.js')` 时拿到的已经是 mock 版本——它本来要测试的"真实 HTTP 方法/URL/错误处理逻辑"全部消失了。
|
||||
|
||||
这就是 CLAUDE.md 里那条铁律的来源:
|
||||
|
||||
> **不要 mock 被测模块的上层业务模块。**
|
||||
|
||||
## 副作用链:为什么 log.ts 和 debug.ts 是必须 mock 的根
|
||||
|
||||
测试中 mock 的唯一合法动机是"被 mock 的模块有副作用,阻止它在测试环境正常加载"。
|
||||
|
||||
打开 `src/bootstrap/state.ts:7`,你会看到文件顶部有两个 import:
|
||||
|
||||
```ts
|
||||
import { realpathSync } from 'fs'
|
||||
```
|
||||
|
||||
`bootstrap/state.ts` 在模块加载时调用 `realpathSync` 去解析当前工作目录(`state.ts:266`),同时用 `randomUUID` 生成 session ID(`state.ts:326`)。这俩都是真正的 I/O 副作用——在测试进程里,工作目录可能不存在,或者你不想要真实的 session ID。
|
||||
|
||||
`log.ts` 和 `debug.ts` 都依赖 `bootstrap/state.ts`。打开 `tests/mocks/log.ts:4`,注释写得一清二楚:
|
||||
|
||||
```
|
||||
Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID).
|
||||
Must be called via mock.module("src/utils/log.ts", logMock) BEFORE any import that
|
||||
transitively depends on log.ts.
|
||||
```
|
||||
|
||||
所以依赖链是这样的:
|
||||
|
||||
```
|
||||
log.ts → bootstrap/state.ts → realpathSync (I/O 副作用)
|
||||
debug.ts → bootstrap/state.ts → randomUUID (I/O 副作用)
|
||||
```
|
||||
|
||||
必须 mock `log.ts` / `debug.ts` 才能安全地导入任何依赖它们的模块。但这引出了一个问题:为什么不直接 mock `bootstrap/state.ts` 呢?
|
||||
|
||||
打开 `tests/mocks/state.ts:1`,答案是:**两者都 mock 了**。`stateMock` 存在,但 `log.ts` / `debug.ts` 的共享 mock 优先被使用,因为它们更轻量——大多数测试只需要 "log 别崩溃",不需要一个完整的 90 行 state mock。
|
||||
|
||||
`logMock` 本身只有 23 行(`tests/mocks/log.ts:10-24`),把所有导出替换成 noop。`debugMock` 也只有 25 行(`tests/mocks/debug.ts:10-25`),所有函数返回 false/null/noop。两者都是 **factory 函数**(`export function logMock() { return { ... } }`),因为 `mock.module` 要求每次调用返回一个新对象——这是 Bun 的约束,不是设计选择。
|
||||
|
||||
如果不这么做会怎样?如果某个测试文件直接 mock `bootstrap/state.ts` 而其他文件通过 `log.ts` 间接依赖它,后者的 mock 会被前者的 `mock.module` 覆盖(last-write-wins)。共享 mock 文件确保了 "log 在所有测试里都是同一个 mock"。
|
||||
|
||||
## launch*.test.ts 和 api.test.ts 的共生关系
|
||||
|
||||
打开 `src/commands/schedule/__tests__/` 目录,你会看到两个文件并排:
|
||||
|
||||
- `launchSchedule.test.ts` — 集成测试,测 `callSchedule()` 的完整调用链
|
||||
- `api.test.ts` — 回归测试,测 `triggersApi.ts` 的 HTTP 方法/URL/重试逻辑
|
||||
|
||||
`api.test.ts` 的测试目标很具体(`api.test.ts:6` 的注释):
|
||||
|
||||
```
|
||||
Key invariants under test:
|
||||
- updateTrigger MUST use POST, not PATCH
|
||||
- All CRUD endpoints hit /v1/code/triggers (not /v1/agents)
|
||||
- 401/403/404/429/5xx classified correctly
|
||||
- withRetry retries only 5xx, not 4xx
|
||||
```
|
||||
|
||||
这些不变量测试的是 `triggersApi.ts` **真实的 HTTP 行为**。如果你在 `launchSchedule.test.ts` 里 mock 了 `triggersApi.ts`,`api.test.ts` 导入的 `triggersApi` 就变成了一个空壳——POST/PATCH 区分、URL 路径、错误分类逻辑全丢了。
|
||||
|
||||
所以铁律是:**`launch*.test.ts` mock axios(底层 HTTP 层),`api.test.ts` 让真实的 `triggersApi` 跑在 mock 的 axios 之上**。两个测试文件共享同一个 `setupAxiosMock()` 基础设施,但互不干扰。
|
||||
|
||||
打开 `launchSchedule.test.ts:1-9`,策略声明很明确:
|
||||
|
||||
```
|
||||
Strategy per feedback_mock_dependency_not_subject:
|
||||
- DO NOT mock triggersApi.ts itself (would pollute api.test.ts)
|
||||
- Mock axios (the underlying HTTP layer) to control API responses
|
||||
- Mock auth dependencies so real triggersApi functions can build headers
|
||||
- Let real triggersApi functions run real code paths
|
||||
```
|
||||
|
||||
`launchVault.test.ts:4` 和 `launchSkillStore.test.ts:8` 也用了同样的策略注释。这不是临时约定,而是整个项目的统一规范。
|
||||
|
||||
## setupAxiosMock:为什么它不是普通的 shared mock
|
||||
|
||||
打开 `tests/mocks/axios.ts:61-121`,`setupAxiosMock()` 的实现很有意思。它不是普通的 "返回一组 stub 函数"——它注册了一个 `mock.module('axios', ...)`,但这个 mock **只在 handle.useStubs 为 true 时生效**:
|
||||
|
||||
```ts
|
||||
export function setupAxiosMock(): AxiosMockHandle {
|
||||
const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
|
||||
|
||||
mock.module('axios', () => {
|
||||
const route = (method: keyof AxiosMethodStubs): AnyFn => {
|
||||
const realFn = _realDefault[method] as AnyFn | undefined
|
||||
return (...args: unknown[]) => {
|
||||
if (handle.useStubs) {
|
||||
const stub = handle.stubs[method] as AnyFn | undefined
|
||||
if (stub) return stub(...args)
|
||||
}
|
||||
if (typeof realFn === 'function') return realFn(...args)
|
||||
throw new Error(`axios.${method} is not available on real axios`)
|
||||
}
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
注意第 30 行:`const _realAxios = require('axios')`。它在 mock 注册**之前**就拿到了真实的 axios 模块引用。这意味着即使 mock 激活后,`route` 函数内部仍然可以 fall through 到真实的 axios 方法。`useStubs` 开关控制的是 "用 stub 还是用真实的 axios"。
|
||||
|
||||
这种设计的巧妙之处在于:**不需要恢复 mock**。`afterAll(() => { axiosHandle.useStubs = false })` 就足够了——mock 仍然存在,但所有请求都 fall through 到真实 axios。后续测试文件如果也调用 `setupAxiosMock()`,Bun 的 last-write-wins 会用新 mock 替换旧的(但这正是预期的行为——每个测试文件拿到自己的 handle)。
|
||||
|
||||
如果不这么做会怎样?如果 `setupAxiosMock` 在 `afterAll` 里调用 `mock.module('axios', () => realAxios)` 来恢复,那么第二个测试文件的 `setupAxiosMock()` 注册的 mock 会在第一个文件的 `afterAll` 执行后被**覆盖回真实 axios**。这种时序依赖正是 Bun 的 process-global mock 带来的根本问题——`useStubs` 开关巧妙地绕开了它。
|
||||
|
||||
## node:fs/promises 的 require() 逃逸技巧
|
||||
|
||||
`launchSkillStore.test.ts:87-114` 展示了一个更极端的防御措施。它需要 mock `node:fs/promises` 的 `mkdir` 和 `writeFile`,但 `node:fs/promises` 有几十个导出(readFile、readdir、unlink、chmod...)。如果只 mock 这两个,同进程里其他测试的 `readFile` 调用全部会崩溃。
|
||||
|
||||
解决方案:**在 mock factory 内部用 `require()` 拿到真实的 fs/promises 模块,然后 spread 它**:
|
||||
|
||||
```ts
|
||||
mock.module('node:fs/promises', () => {
|
||||
const real = require('node:fs/promises') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
mkdir: (...args: unknown[]) =>
|
||||
useSkillStoreFsStubs
|
||||
? mkdirMock(...args)
|
||||
: (real.mkdir as (...a: unknown[]) => Promise<unknown>)(...args),
|
||||
writeFile: (...args: unknown[]) =>
|
||||
useSkillStoreFsStubs
|
||||
? writeFileMock(...args)
|
||||
: (real.writeFile as (...a: unknown[]) => Promise<unknown>)(...args),
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
注释(第 88-91 行)解释了为什么这是必要的:
|
||||
|
||||
```
|
||||
Bun's mock.module is global per-process and last-write-wins. Replacing
|
||||
node:fs/promises with only mkdir + writeFile breaks every other test in
|
||||
the same `bun test` run that imports readFile / readdir / unlink / chmod /
|
||||
etc.
|
||||
```
|
||||
|
||||
注意 `require('node:fs/promises')` 写在 factory 函数**内部**——`mock.module` 的 factory 是惰性求值的,每次模块被 require/import 时才执行。这意味着 `require()` 在 factory 内部能绕过 mock 注册表,拿到真正的原始模块。
|
||||
|
||||
如果没有这个技巧,要么每次 `bun test` 只跑一个文件(丧失并行效率),要么为 `node:fs/promises` 维护一个包含所有导出的巨型 mock(维护噩梦)。
|
||||
|
||||
## 排查 mock 污染的四步法
|
||||
|
||||
CLAUDE.md 里记录的排查方法值得逐条拆解:
|
||||
|
||||
**第 1 步:单独运行确认通过。** `bun test path/to/suspect.test.ts`。如果单独跑就失败,问题不在 mock 污染,在测试本身。
|
||||
|
||||
**第 2 步:同目录一起跑定位污染源。** `bun test path/to/__tests__/`。如果同目录的文件一起跑时 `api.test.ts` 开始失败,而单独跑时通过,说明同目录某个文件在 mock 被测模块的上层。
|
||||
|
||||
**第 3 步:console.error milestone 追踪顺序。** 在两个文件头部各加 `console.error('[filename] milestone')`。因为 Bun 的测试文件执行顺序不是严格的字母序,你不能假设 `api.test.ts` 一定在 `launchSchedule.test.ts` 之后执行。实际的执行顺序取决于 `bun test` 的内部文件遍历策略。
|
||||
|
||||
**第 4 步:检查 specifier 解析。** 即使两个测试文件写的是不同的路径字符串(一个写 `'../triggersApi.js'`,另一个写 `'src/commands/schedule/triggersApi.js'`),如果 Bun 把它们解析到同一个模块 ID,`mock.module` 仍然会污染。这是 Bun 模块解析的特性——路径别名(`src/*`)和相对路径可能指向同一个文件。
|
||||
|
||||
## 为什么不切换到 Vitest 或 Jest
|
||||
|
||||
看到这里你可能在想:既然 `bun:test` 的 mock 这么坑,为什么不用 Vitest 的 `vi.mock`(per-file 隔离)或 Jest 的 `jest.mock`(同样 per-file 隔离)?
|
||||
|
||||
答案是 **运行时一致性**。这个项目在 Bun 运行时上构建(`build.ts` 用 `Bun.build()`,`scripts/dev.ts` 用 `bun -d` 注入 MACRO),测试需要在相同运行时执行才能覆盖 `bun:bundle`、`bun:ffi`、Bun 特有的 `import.meta` 行为。Vitest 底层用的是 Vite(Node.js),无法还原这些运行时特性。
|
||||
|
||||
`bun:test` 的 `mock.module` 是 process-global 这一事实,是 "用 Bun 的测试框架就得接受 Bun 的约束" 的又一个例证——跟第一章(Code Splitting 生存需求)、第三章(performanceShim JSC 补丁)的叙事主线一致:**每一个看似奇怪的决定背后都有一个具体的运行时约束**。
|
||||
|
||||
## 共享 mock 的维护纪律
|
||||
|
||||
回到 `tests/mocks/` 目录。打开任一 mock 文件,你会看到统一的模式:factory 函数 + 注释说明为什么要 mock。`stateMock`(`tests/mocks/state.ts`)是最重量级的,90 行,覆盖了 `bootstrap/state.ts` 的所有导出。但它不是默认使用的——只有直接测试 state 相关逻辑时才引入。
|
||||
|
||||
核心原则:**mock 的表面应该和被 mock 模块的导出表保持同步**。源文件新增导出时,如果某个测试因此报错,应该更新 `tests/mocks/` 下的对应文件——而不是在测试文件内联 mock。这样所有依赖同一个 mock 的测试文件都自动受益。
|
||||
|
||||
CLAUDE.md 把这条写成了硬规则:
|
||||
|
||||
```
|
||||
源文件导出变更时只需更新 tests/mocks/ 下的对应文件,不需要逐个修改测试。
|
||||
```
|
||||
|
||||
如果没有这条规则和共享 mock 机制,每个测试文件都会内联自己的 log mock / debug mock / state mock。一旦 `log.ts` 新增一个导出,你需要在几十个文件里同步修改。这不仅是维护噩梦,还容易出现版本漂移——有的测试 mock 了旧版本的导出表,有的 mock 了新版本的,导致不可预测的测试行为。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看依赖 `bootstrap/state.ts` 模块级副作用的根本原因(为什么 `realpathSync` 和 `randomUUID` 在 import 时执行),见 [第十一章:三层状态管理](./11-state-management.md)
|
||||
- 想看 `bun:test` 的 process-global mock 如何影响了 `node:fs/promises` 的测试隔离(require 逃逸技巧),见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md) 中关于 Bun 运行时约束的讨论
|
||||
- 想看 `setupAxiosMock` 的 mock 开关机制与 `triggersApi.ts` 中 `withRetry` 重试逻辑的交互,见 [第九章:Usage 字段映射与模型映射的优先级链](./09-usage-mapping.md) 中关于 429/5xx 错误分类的部分
|
||||
208
docs/outline-output/design/15-biome-config.md
Normal file
208
docs/outline-output/design/15-biome-config.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# 第十五章:biome.json 的 42 条规则关闭 —— 反编译产物的指纹
|
||||
|
||||
> 42 条 lint 规则被关闭不是偷懒,是反编译代码对 linter 提出的最后通牒
|
||||
|
||||
## 一份任何现代项目都不敢提交的 biome 配置
|
||||
|
||||
打开 `biome.json:24`,你会看到一个让大多数 linter 爱好者血压升高的配置。`suspicious` 组关了 12 条,`style` 组关了 9 条,`complexity` 组关了 12 条,`correctness` 组关了 9 条。加上 `a11y` 和 `nursery` 两个 recommended 集整体关闭,总共 44 处 `"off"`。CLAUDE.md 说的"42 条"是其中 42 个具名规则(不算 a11y/nursery 的 recommended 整体关闭)。
|
||||
|
||||
```json
|
||||
// biome.json:26-38
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noRedeclare": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noGlobalIsNan": "off",
|
||||
"noFallthroughSwitchClause": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noConsole": "off",
|
||||
"noConfusingLabels": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
}
|
||||
```
|
||||
|
||||
如果你在一个全新项目中提交这样的配置,code review 的第一条评论一定是:"你确定要关 `noExplicitAny`?`noDoubleEquals`?`noConsole`?" 在正常项目中,这些是底线中的底线。
|
||||
|
||||
但这个项目不是正常项目。这是一个反编译重建的 CLI,几十万行 TypeScript 的每一行都经过 decompiler 的洗礼,变量名是合成的,类型信息是推断的,控制流是还原的。逐行修复 42 条规则意味着重写整个代码库——这恰好是反编译重建工作要避免的。
|
||||
|
||||
## 关闭的每一条规则背后都有一个反编译的必然
|
||||
|
||||
关掉的 42 条规则可以分成四个阵营,每个阵营对应反编译产物的一个系统性特征。
|
||||
|
||||
### suspicious 组:decompiler 不生成的代码
|
||||
|
||||
`noExplicitAny`(`biome.json:27`)—— 反编译器在无法还原类型标注时,默认产出 `any`。`src/services/api/` 下的流适配器满是 `any`,因为原始代码的类型在编译为 JavaScript 后被擦除。decompiler 只能从运行时行为推断,推断不出来就给 `any`。
|
||||
|
||||
`noDoubleEquals`(`biome.json:29`)—— decompiler 还原比较表达式时偶尔产出 `==` 而非 `===`,因为原始 JavaScript 中的 `==` 和 `===` 编译到同一份字节码后,decompiler 无法区分原始意图。全局搜索项目中的 `==`,你会发现它们集中在 decompiler 输出的早期模块中。
|
||||
|
||||
`noRedeclare`(`biome.json:30`)—— decompiler 有时会为同一个变量生成多个声明(来自不同作用域的合并或 switch-case 的变量提升)。这不是你手写的代码会犯的错误,但 decompiler 的控制流重建算法不可避免。
|
||||
|
||||
`noFallthroughSwitchClause`(`biome.json:33`)—— 原始代码可能利用了 switch fallthrough,decompiler 如实还原。手写代码不应该用 fallthrough,但反编译产物必须忠实于原始行为。
|
||||
|
||||
`noConsole`(`biome.json:36`)—— 29 个文件在文件顶部声明 `biome-ignore-all lint/suspicious/noConsole`。打开 `src/utils/claudeInChrome/chromeNativeHost.ts:1`:
|
||||
|
||||
```ts
|
||||
// biome-ignore-all lint/suspicious/noConsole: file uses console intentionally
|
||||
```
|
||||
|
||||
这个文件作为 Chrome Native Host 运行,`console.log` 是它与宿主通信的标准通道。反编译产物中大量 `console.log` 用于调试桥接层,关掉规则比逐个审查每一条 console 调用的意图更务实。
|
||||
|
||||
### style 组:decompiler 的代码风格不是你的代码风格
|
||||
|
||||
`useConst`(`biome.json:41`)—— decompiler 统一产出 `let`,即使在语义上应该是 `const`。这因为 JavaScript 运行时不区分 `let` 和 `const`(除了 TDZ),字节码中只有一个变量声明指令。decompiler 不知道原始源码用的是 `let` 还是 `const`,保守地全部输出 `let`。
|
||||
|
||||
`useTemplate`(`biome.json:46`)—— 字符串拼接 vs 模板字面量的选择在编译后完全消失。decompiler 还原时,有时输出 `'hello' + name`,有时输出 `` `hello${name}` ``,取决于它如何重建 AST。这不是一个可以在不改变语义的情况下批量修复的问题。
|
||||
|
||||
`useImportType`(`biome.json:49`)—— `import type { X }` vs `import { X }` 在编译后都是同样的 `require` 调用。decompiler 无法判断一个导入是否只在类型位置使用,所以统一生成普通 import。
|
||||
|
||||
### complexity 组:decompiler 的 AST 还原策略
|
||||
|
||||
`noForEach`(`biome.json:52`)—— decompiler 将 `for...of` 和 `.forEach()` 互相转换没有固定偏好。原始代码用 `for...of` 的地方可能被还原成 `.forEach()`,反之亦然。批量统一风格的工作量与收益不成比例。
|
||||
|
||||
`useArrowFunction`(`biome.json:62`)—— 同理。`function` 和箭头函数在编译后只有微妙的 `this` 绑定差异,decompiler 不一定能正确还原。全局搜索你会发现项目里两种风格并存——反编译产物中 `this` 绑定的原始上下文已经丢失。
|
||||
|
||||
`noBannedTypes`(`biome.json:53`)—— `Function`、`Object`、`{}` 这些 banned types 在反编译产物的类型声明中大量出现,因为 decompiler 的类型推断粒度就是 `Object`。
|
||||
|
||||
### correctness 组:死代码与 unreachable 的诚实保留
|
||||
|
||||
`noUnreachable`(`biome.json:70`)—— 反编译产物中有大量 feature-gated 的不可达代码。当 `feature('X')` 被 Bun 编译器 DCE 后变成 `if (false)` 时,分支内的代码变成 unreachable。但 source 层面它们仍然存在——你需要它们存在,因为 dev 模式下 `feature()` 返回 `true`。
|
||||
|
||||
`noConstantCondition`(`biome.json:73`)—— 同理。`if ('production' === 'development')` 是 MACRO 替换后的永假比较。这个判断在 `build.ts` 中通过 `Bun.build({ define })` 把 `'production'` 注入为字面量,dev 模式下注入 `'development'`。tsc 不理解 define 注入,报错——只能用 `@ts-expect-error` 压制。
|
||||
|
||||
`noUnusedVariables`(`biome.json:66`)和 `noUnusedImports`(`biome.json:67`)—— 反编译产物的变量使用模式经常是"先声明后使用在另一个 switch-case 分支中",decompiler 的作用域重建不一定能正确识别跨分支的引用关系。
|
||||
|
||||
`useExhaustiveDependencies`(`biome.json:68`)—— React hooks 的依赖数组在编译后完全消失。decompiler 无法还原 `useEffect` / `useMemo` 的原始依赖数组,只能产出空数组或不完整的数组。这是 React Compiler 的 `_c()` memoization 模板出现后尤其明显的问题(参见第十章)。
|
||||
|
||||
## .tsx 的特权:lineWidth 120 + 强制分号
|
||||
|
||||
`biome.json:102-113` 的 overrides 区域有一条令人好奇的规则:
|
||||
|
||||
```json
|
||||
// biome.json:102-113
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.tsx"],
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
所有 `.tsx` 文件享有 120 字符行宽(其他文件 80)和强制分号(其他文件 `asNeeded`)。这不是拍脑袋的决定。
|
||||
|
||||
120 字符行宽是因为 JSX 的嵌套结构天然占宽度。一个包含 `className`、`onClick`、`condition && <Component />` 的 JSX 表达式,80 字符行宽下几乎必然被格式化器断成碎片——每个属性一行、每个嵌套标签一行。120 字符让一个完整的组件调用能留在同一行,可读性显著提升。
|
||||
|
||||
强制分号的原因更微妙。`.tsx` 文件使用 React Compiler 输出(`_c()` memoization 调用),这些调用在 decompiler 还原时已经定型。`asNeeded` 模式下 Biome 可能删除某些 ASI(Automatic Semicolon Insertion)安全位置的分号,但 React Compiler 的 `_c()` 模板假设分号存在——去掉分号可能改变 ASI 边界的行为。`always` 是最安全的选择。
|
||||
|
||||
## 52 个 biome-ignore-all:ANT-ONLY 标记的禁区
|
||||
|
||||
全局搜索 `biome-ignore-all`,你会发现 `src/` 下有 30 个文件、`packages/` 下也有若干文件在文件顶部声明了这个指令。其中最常见的一条是:
|
||||
|
||||
```ts
|
||||
// src/commands.ts:1
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
```
|
||||
|
||||
29 个文件使用完全相同的 `ANT-ONLY import markers must not be reordered` 理由。这些文件的 import 语句中混入了特殊标记——`ANT-ONLY` 注释标记了只有内部版本才会编译进去的 import 路径。Biome 的 `organizeImports` assist 功能会重排 import 语句,但这些标记的位置和顺序不能被打乱,否则 `bun:bundle` 的编译期处理会出错。
|
||||
|
||||
打开 `src/commands.ts:1`,紧跟着 import 标记注释的就是一大段命令注册代码——每个命令都是一个独立的 import。反编译产物的 import 顺序不是按字母序的,而是按原始模块的注册顺序。`organizeImports` 会把它们重排成字母序,破坏隐含的初始化顺序依赖。
|
||||
|
||||
`biome-ignore-all` 在这些文件中是 `//` 行级注释——整文件生效,不分具体规则。这说明"不要碰这个文件的 import"是一条不可妥协的红线。
|
||||
|
||||
## tsc vs biome 的零和博弈
|
||||
|
||||
`biome.json` 关了 42 条规则,但有一条它没关:`noUnusedPrivateClassMembers`(`correctness/recommended` 默认启用)。这条规则与 TypeScript 的严格模式产生了一个有趣的两难。
|
||||
|
||||
打开 `src/native-ts/file-index/index.ts:51`:
|
||||
|
||||
```ts
|
||||
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: used via destructuring in search()
|
||||
```
|
||||
|
||||
tsc 在 strict 模式下要求类属性必须有类型声明。某些情况下,一个类属性只在赋值时使用(通过解构赋值读取),tsc 要求声明但不读取——biome 则报告"声明了但从未读取"。两个工具的语义不兼容:tsc 要求声明是为了类型完整性,biome 报 unused 是因为它只看读取行为。
|
||||
|
||||
解决方案是 `biome-ignore` 注释——逐个压制。这不是一个能通过改 biome 配置解决的问题,因为关掉这条规则会让真正未使用的私有成员溜过去。CLAUDE.md 里的指导原则是:
|
||||
|
||||
> 用 `// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>` 抑制 lint 警告,保留类型声明。
|
||||
|
||||
每个 `biome-ignore` 必须附带原因——这是防止"关规则变成文化"的最后防线。
|
||||
|
||||
## `@ts-expect-error` 的维护纪律
|
||||
|
||||
`@ts-expect-error` 在反编译代码中有两类用途,维护纪律截然不同。
|
||||
|
||||
第一类是 MACRO 替换产生的永假比较。`scripts/defines.ts:18` 定义了 `MACRO.VERSION` 等编译期常量,`build.ts` 和 `scripts/dev.ts` 分别用 `Bun.build({ define })` 和 `bun -d` 注入。当 `NODE_ENV` 被替换为 `'production'` 时,`'production' === 'development'` 永假——tsc 不知道 define 注入,会报 TS2578。这个 `@ts-expect-error` 必须永久保留。
|
||||
|
||||
第二类是类型系统更新后变为多余的 directive。当 TypeScript 版本升级或类型声明补全后,原来需要 `@ts-expect-error` 的代码可能不再有类型错误。此时 tsc 报 TS2578(Unused '@ts-expect-error' directive),意味着 directive 本身变成了错误。CLAUDE.md 的规则是:
|
||||
|
||||
> 如果类型系统已更新导致 directive 变为 unused(TS2578),直接移除注释。
|
||||
|
||||
这是 `bun run precheck` 能通过的前提——`precheck` 同时跑 tsc 和 biome,任何多余的 `@ts-expect-error` 或不足的 `biome-ignore` 都会导致 CI 失败。
|
||||
|
||||
## CI 的 `biome ci .` 零容忍
|
||||
|
||||
`biome.json` 关了 42 条规则,但 CI 的 `ci.yml` 仍然跑 `bunx biome ci .`。这不是矛盾——42 条关闭之外,所有 `recommended` 规则仍然生效。
|
||||
|
||||
`ci.yml` 的工作流是:先安装依赖,然后 lint,再 typecheck,最后 build 和 test。`biome ci` 如果发现任何 warning,CI 就失败。这意味着:
|
||||
|
||||
1. 新代码不能引入新的 `any`(除非你也在 `biome.json` 里关掉 `noExplicitAny`,而它已经关了)。
|
||||
2. 新代码不能引入新的 `console.log`(除非文件顶部有 `biome-ignore-all`)。
|
||||
3. 每个局部 `biome-ignore` 必须附带原因注释,否则 PR review 会打回。
|
||||
|
||||
42 条规则关闭是"历史债"的合法化。`biome ci` 零容忍是"不再积累新债"的纪律。两者并存,构成一个有趣的平衡:承认过去无法重写,但也不允许未来继续退化。
|
||||
|
||||
如果不这么做——如果不关这 42 条规则——你有两个选择:(A) 逐行重构几十万行反编译代码(工程量相当于重写),或者 (B) 不用 biome(lint 基线完全丧失)。A 不现实,B 不可接受。所以 42 条关闭是唯一的可行路径。
|
||||
|
||||
## `using _` 的脆弱 transpile
|
||||
|
||||
`biome.json` 本身不涉及 transpile,但整个 lint 配置的生存依赖于一条脆弱的构建期替换。
|
||||
|
||||
打开 `scripts/vite-plugin-feature-flags.ts:68-74`:
|
||||
|
||||
```ts
|
||||
// 2. Transpile `using _ = expr;` to `const _ = expr;` for Node.js compat.
|
||||
// Node.js v22 does not support `using` declarations (Explicit Resource Management).
|
||||
// Safe because: SLOW_OPERATION_LOGGING is not enabled, so slowLogging returns
|
||||
// a no-op disposable whose [Symbol.dispose]() is empty.
|
||||
if (transformed.includes('using _')) {
|
||||
transformed = transformed.replace(/\busing\s+(_\w*)\s*=/g, 'const $1 =')
|
||||
modified = true
|
||||
}
|
||||
```
|
||||
|
||||
Vite 构建插件把所有 `using _ = slowLogging\`...\`` 正则替换为 `const _ = slowLogging\`...\``。这是因为 Node.js v22 不支持 `using` 声明(Explicit Resource Management 提案),而构建产物必须兼容 Node.js 运行。
|
||||
|
||||
打开 `src/utils/slowOperations.ts:191`,你会看到源码中使用 `using` 的典型模式:
|
||||
|
||||
```ts
|
||||
using _ = slowLogging`JSON.stringify(${value})`
|
||||
return JSON.stringify(value, replacer as Parameters<typeof JSON.stringify>[1], space)
|
||||
```
|
||||
|
||||
`slowLogging` 是一个 tagged template,返回 `Disposable`(`slowOperations.ts:155-160`)。当 `SLOW_OPERATION_LOGGING` 未启用时(默认情况),它返回一个 no-op disposable(`slowOperations.ts:126`),`[Symbol.dispose]()` 是空函数。正则替换把 `using _` 换成 `const _` 后,这个 no-op 对象被赋值给 `_` 然后立刻丢弃——行为等价,但不再依赖 ESM Explicit Resource Management。
|
||||
|
||||
这条 transpile 的安全性依赖于一个前提:`SLOW_OPERATION_LOGGING` 未启用。如果启用了,`slowLogging` 返回 `AntSlowLogger`(`slowOperations.ts:95`),它的 `[Symbol.dispose]()` 真正执行计时和日志——替换成 `const` 后 dispose 永远不会被调用,慢操作检测静默失效。`DEFAULT_BUILD_FEATURES` 列表(`scripts/defines.ts:39`)里没有 `SLOW_OPERATION_LOGGING`,所以当前构建安全。但这是一种隐式契约——如果将来有人把 `SLOW_OPERATION_LOGGING` 加到默认 features 里,`biome ci .` 仍然通过(因为 `using` 已被 transpile 掉),但慢操作检测会静默失效。没有编译期或运行时的机制阻止这种错误。
|
||||
|
||||
## 如果不这么做会怎样
|
||||
|
||||
假设你决定不关这 42 条规则——逐行修复反编译产物。你面对的第一个问题是 `noExplicitAny`:`src/services/api/` 下的流适配器有数百个 `any`,每个都需要手动推断原始类型。由于类型在编译时被擦除,你的推断只有"合理猜测"的精度。猜错了,运行时行为就变了——反编译产物最脆弱的地方就是"看起来对但行为不同"的代码。
|
||||
|
||||
第二个问题是 `noUnusedVariables` 和 `noUnusedImports`。decompiler 产出的变量使用模式中,跨 switch-case 分支的引用、feature-gated 的条件使用、React Compiler `_c()` 的隐式引用——这些都不是简单的"声明了但没用",而是"在反编译器的控制流重建中,使用点被放到了 lint 工具看不到的地方"。批量删除这些"unused"变量,你会破坏运行时逻辑。
|
||||
|
||||
第三个问题是工程成本。几十万行代码逐条修复 42 类 lint 问题,保守估计需要数人月。而反编译重建工作的核心目标是恢复功能,不是美化代码。42 条关闭是一个理性的资源分配决策:把有限的人力放在功能恢复和测试覆盖上,而不是放在让 linter 满意上。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 feature flag 编译期替换如何与 linter 交互(`if (false)` 产生 unreachable),见 [第五章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看 React Compiler 的 `_c()` 模板如何在反编译产物中大量出现并与 lint 规则冲突,见 [第十章:自研 Fork 的 Ink 框架](./10-ink-framework.md)
|
||||
- 想看 `using _` transpile 所在的 Vite 构建管线,见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md)
|
||||
- 想看测试如何与 mock 污染共存(另一个"承认现状、守住底线"的案例),见 [第十四章:测试策略](./14-testing-strategy.md)
|
||||
153
docs/outline-output/design/16-epilogue.md
Normal file
153
docs/outline-output/design/16-epilogue.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 尾声:哪些坑我们没踩 -- 读者可以继续挖掘的方向
|
||||
|
||||
> 反编译重建的边界之内,还有一片我们没来得及丈量的区域
|
||||
|
||||
前面十五章把核心子系统从头到尾走了一遍,但这个代码库太大了。有些子系统我们在探索过程中只触及了表面,有些陷阱只看到了线索却没来得及深挖。这一章不做总结,而是列出一组"还值得继续挖的方向"——每个方向都附带真实锚点,你可以打开编辑器直接对照。
|
||||
|
||||
## ConsoleOAuthFlow 的中国 LLM 表单
|
||||
|
||||
打开 `src/components/ConsoleOAuthFlow.tsx:1294`,你会看到一个 `china_provider_select` 分支。这是一个完整的交互式表单流程:用户先选 Provider(DeepSeek、Zhipu GLM、通义千问等),再选计费模式(Pay-as-you-go vs Coding Plan),最后填 API Key。
|
||||
|
||||
表单的数据源是 `src/utils/chinaLlmProviders.ts:44` 导出的 `CHINA_LLM_PROVIDERS` 数组。每个 Provider 预设包含 `baseURL`、`apiKeyPage`、`models`(含 `inputPricePerMTok` / `outputPricePerMTok` / `contextWindow`)、甚至可选的 `codingPlan`(含 `tiers` 数组,描述不同订阅档位的额度与价格)。
|
||||
|
||||
这个子系统的设计决策值得追问:为什么中国 LLM 的引导式登录是纯终端 UI 表单,而 ChatGPT 订阅走的是 OAuth 设备码流程?一个合理的推测是——这些中国 Provider 都是 OpenAI 兼容协议,用户只需要提供一个 API Key,不需要 OAuth 握手。但表单里 `codingPlan` 分支的存在暗示某些 Provider 有专门的 Coding Plan 端点(如 Zhipu GLM 的 `open.bigmodel.cn/api/coding/paas/v4`),这意味着 Provider 预设不仅是静态数据,还隐含了路由逻辑。深入追踪 `codingPlan.baseURL` 在哪里被实际使用,可以揭示更多。
|
||||
|
||||
## ChatGPT 订阅路径与 Codex CLI 的凭证共享
|
||||
|
||||
`src/services/api/openai/chatgptAuth.ts` 是整个 ChatGPT 订阅路径的核心。打开 `chatgptAuth.ts:327`,你会看到 `isChatGPTAuthEnabled()` 的实现极其简短:
|
||||
|
||||
```typescript
|
||||
export function isChatGPTAuthEnabled(): boolean {
|
||||
return process.env.OPENAI_AUTH_MODE === 'chatgpt'
|
||||
}
|
||||
```
|
||||
|
||||
整条链路的流程是:OAuth 设备码握手 -> 轮询授权码 -> 换取 token -> 存储到 `~/.claude/openai-chatgpt-auth.json`。但更有意思的是 `getValidChatGPTAuth()` 函数(`chatgptAuth.ts:339`),它在找不到自己的凭证文件时,会 fallback 到 Codex CLI 的凭证文件:
|
||||
|
||||
```typescript
|
||||
function codexAuthFilePath(): string {
|
||||
return join(
|
||||
process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'),
|
||||
'auth.json',
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
这是一个跨工具凭证共享的设计——Claude Code 和 Codex CLI 读同一份 `~/.codex/auth.json`。`chatgptAuth.ts:344` 的 debug 日志直接证实了这一点:`'[OpenAI] Using ChatGPT auth from Codex auth.json'`。
|
||||
|
||||
这个设计决策有两个值得深挖的后果。第一,`REFRESH_SKEW_MS = 5 * 60 * 1000`(5 分钟偏差窗口,`chatgptAuth.ts:9`)意味着 token 过期前 5 分钟就会触发刷新——如果两个工具同时运行,它们可能会竞争写入同一个凭证文件。第二,`CODEX_HOME` 环境变量让用户可以把 Codex 的 home 目录指向别处,但 `getValidChatGPTAuth()` 的 fallback 顺序是"先找 Claude 的文件,再找 Codex 的文件",如果两个文件同时存在且内容不同,行为是什么?这些问题都需要实际运行才能确认。
|
||||
|
||||
## poorMode 的跨兼容层传播
|
||||
|
||||
`src/commands/poor/poorMode.ts` 的整个实现只有 28 行。打开这个文件,你会看到一个极简的模块级缓存模式:
|
||||
|
||||
```typescript
|
||||
let poorModeActive: boolean | null = null
|
||||
|
||||
export function isPoorModeActive(): boolean {
|
||||
if (poorModeActive === null) {
|
||||
poorModeActive = getInitialSettings().poorMode === true
|
||||
}
|
||||
return poorModeActive
|
||||
}
|
||||
```
|
||||
|
||||
启用穷鬼模式后,系统跳过 `extract_memories`、`prompt_suggestion`、`verification_agent`。状态持久化到 `settings.json` 的 `poorMode` 字段(`poorMode.ts:24`:`updateSettingsForSource('userSettings', { poorMode: active || undefined })`)。
|
||||
|
||||
但这个模块级缓存的设计有一个微妙之处:`poorModeActive` 只在首次调用时从 settings 读取,之后整个会话期间都走内存缓存。如果在另一个终端修改了 `settings.json`(比如 `claude config set poorMode false`),正在运行的 Claude Code 实例不会感知到变化——必须重启。这在长驻模式(daemon / bridge / background session)下尤其值得注意。
|
||||
|
||||
更值得追问的是:穷鬼模式跳过的三个功能(`extract_memories`、`prompt_suggestion`、`verification_agent`)具体在代码的哪些位置检查 `isPoorModeActive()`?它们是否真的跨所有兼容层(OpenAI / Gemini / Grok)都生效?追踪 `isPoorModeActive` 的调用点可以画出一幅"穷鬼模式的传播图"。
|
||||
|
||||
## isFirstPartyAnthropicBaseUrl 的 TODO 陷阱
|
||||
|
||||
打开 `src/utils/model/providers.ts:43`,你会看到一段注释很诚实的代码:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Check if ANTHROPIC_BASE_URL is a first-party Anthropic API URL.
|
||||
* Returns true if not set (default API) or points to api.anthropic.com
|
||||
* (or api-staging.anthropic.com for ant users).
|
||||
*/
|
||||
export function isFirstPartyAnthropicBaseUrl(): boolean {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
// TODO: 这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题
|
||||
if (!baseUrl) {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
TODO 注释说的是:如果用户没有设置 `ANTHROPIC_BASE_URL`,函数返回 `true`——认为当前是 first-party 环境。但用户可能只设置了 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY` 来使用 OpenAI 兼容层,完全没碰过 `ANTHROPIC_BASE_URL`。此时 `isFirstPartyAnthropicBaseUrl()` 会错误地返回 `true`。
|
||||
|
||||
这个 `true` 值被用于至少 6 个判断点:`client.ts:367` 的 `injectClientRequestId` 逻辑、`claude.ts:1916` 的 beta 头注入、`betas.ts:186` 的 beta 特性开关、`modelCapabilities.ts:52` 的能力检测、`syncCache.ts:58` 的远程设置同步、`policyLimits/index.ts:174` 的策略限流。如果 `isFirstPartyAnthropicBaseUrl()` 在 OpenAI 兼容层下错误返回 `true`,这些逻辑都会按 first-party 路径执行——可能注入不兼容的请求头、启用不可用的 beta 特性、或触发需要 Anthropic 认证才能访问的远程服务调用。
|
||||
|
||||
同样的陷阱也存在于 `clearOpenAIClientCache` 的模块级缓存。打开 `src/services/api/openai/client.ts:39`:
|
||||
|
||||
```typescript
|
||||
export function getOpenAIClient(options?: {
|
||||
maxRetries?: number
|
||||
fetchOverride?: typeof fetch
|
||||
source?: string
|
||||
}): OpenAI {
|
||||
if (cachedClient) return cachedClient
|
||||
// ...
|
||||
if (!options?.fetchOverride) {
|
||||
cachedClient = client
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
/** Clear the cached client (useful when env vars change). */
|
||||
export function clearOpenAIClientCache(): void {
|
||||
cachedClient = null
|
||||
}
|
||||
```
|
||||
|
||||
`getOpenAIClient()` 在首次调用时把客户端实例缓存到模块级变量 `cachedClient`(`client.ts:69`),后续调用直接返回缓存。如果用户在对话中途通过 `/login` 重新配置了 API Key,缓存的客户端仍然使用旧 Key。对比 `getAnthropicClient()`(`client.ts:84`)——它每次调用都重新创建客户端实例,不缓存。这个不对称的设计差异值得追问:OpenAI SDK 的客户端构造为什么比 Anthropic SDK 更重?是否因为 OpenAI SDK 在构造时做了更多初始化工作?
|
||||
|
||||
## vendor/ripgrep 的平台二进制缺失问题
|
||||
|
||||
`src/utils/vendor/ripgrep/` 目录下只有 `arm64-darwin/rg` 一个平台二进制(4.3MB 的 statically compiled ripgrep)。打开 `src/utils/ripgrep.ts:56`,你会看到路径解析逻辑:
|
||||
|
||||
```typescript
|
||||
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe`)
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
```
|
||||
|
||||
如果当前平台是 `x64-linux`,路径会解析为 `vendor/ripgrep/x64-linux/rg`——但这个文件不存在。ripgrep.ts:382 把 `ENOENT` 列为"关键错误"(`CRITICAL_ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM']`),意味着在缺失二进制的平台上 Grep 工具会直接报错,不会 fallback 到任何替代方案。
|
||||
|
||||
`build.ts:91-93` 解决了一半问题——构建时会把 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/ripgrep/`。但这只保证构建产物携带了已有平台二进制,不解决其他平台缺失的问题。`distRoot.ts` 的 `lastIndexOf('dist')` / `lastIndexOf('src')` 逻辑确保了 vendor 路径在不同构建布局下都能正确定位,但前提是目标平台的二进制确实存在。
|
||||
|
||||
在反编译重建的语境下,这暗示原始项目可能针对所有目标平台都预编译了 ripgrep 二进制,但反编译过程只保留了 macOS arm64 这一个。其他平台的用户要么需要从源码编译 ripgrep(`cargo build --release --target x86_64-unknown-linux-musl`),要么设置 `USE_BUILTIN_RIPGREP=0` 回退到系统安装的 `rg`。`ripgrep.ts:47` 还有第三条路径——`isInBundledMode()` 时使用 Bun 内嵌的 ripgrep(`process.execPath` with `argv0: 'rg'`),但这只在使用官方 Bun 构建的产物时才可用。
|
||||
|
||||
## 反编译工作的诚实边界
|
||||
|
||||
贯穿全书,我们已经看到了两类禁用的 feature flag。现在值得把它们清晰地分开。
|
||||
|
||||
第一类是**反编译丢失导致的 stub**:`CONTEXT_COLLAPSE`、`HISTORY_SNIP`、`FORK_SUBAGENT`、`UDS_INBOX`、`LAN_PIPES`、`REVIEW_ARTIFACT`。这些功能的原始实现依赖了反编译无法恢复的内部协议、原生模块或编译时嵌入的资源。如果强行启用,不会"什么都不做"——它们引用的模块根本不存在,会导致 import 失败或运行时崩溃。CLAUDE.md 在"已禁用"列表中明确标注了这些。
|
||||
|
||||
第二类是**功能原本就 stubbed 的**:`SKILL_LEARNING`、`TEAMMEM`。这些在原始代码中也是实验性的、未完成的功能,反编译产物忠实地保留了它们的 stub 状态。启用它们不会崩溃,但也不会产生有意义的输出。
|
||||
|
||||
区分这两类的实际意义在于:第一类是"永远无法恢复的损失",第二类是"原始代码也还没做完,你可以自己补完"。对于想参与开发的读者来说,第二类才是可以动手的方向—— stub 给出了接口签名和调用点,只缺实现。
|
||||
|
||||
## 带上编辑器,继续挖
|
||||
|
||||
前面列出的每个方向都是开放式的。我们没有给出"正确答案",因为我们确实没走到那一步。但每个锚点都是真实可验证的——打开文件,跳到行号,代码就在那里。
|
||||
|
||||
如果你想动手,建议的切入顺序是:
|
||||
|
||||
1. **poorMode 传播图**最容易入手——在代码库里全文搜索 `isPoorModeActive`,画出调用关系图,检查每个调用点在 OpenAI/Gemini/Grok 兼容层下是否真的生效。
|
||||
2. **isFirstPartyAnthropicBaseUrl 泄漏**影响面最广——在 `OPENAI_AUTH_MODE=chatgpt` 或 `CLAUDE_CODE_USE_OPENAI=1` 的环境下,手动在关键判断点打印 `isFirstPartyAnthropicBaseUrl()` 的返回值,观察哪些路径被错误地走了 first-party 分支。
|
||||
3. **ripgrep 平台覆盖**是最直接的贡献——为 x64-linux、aarch64-linux 等缺失平台编译 ripgrep 二进制并提交 PR。
|
||||
|
||||
这些方向的共同点是:它们都不是"要不要做"的问题,而是"什么时候做"的问题。代码库已经把线索留在了注释、TODO 和 fallback 路径里,等着有人来捡。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 想看 Provider 调度点的完整分析,见 [第七章:7-Provider 抽象层的单一调度点](./07-provider-dispatch.md)
|
||||
- 想看 Feature Flag 的编译器约束,见 [第六章:Feature Flag 系统的三个硬约束](./05-feature-flags.md)
|
||||
- 想看 Bun mock.module 的进程全局陷阱,见 [第十四章:测试策略](./14-testing-strategy.md)
|
||||
- 想看 code splitting 的生存动机,见 [第一章:Code Splitting 不是优化,是生存需求](./01-code-splitting.md)
|
||||
- 想看流适配器的零分支设计,见 [第八章:流适配器](./08-stream-adapters.md)
|
||||
Reference in New Issue
Block a user