diff --git a/docs-outline-draft.md b/docs-outline-draft.md new file mode 100644 index 000000000..7b79caa69 --- /dev/null +++ b/docs-outline-draft.md @@ -0,0 +1,688 @@ +# Claude Code(反编译重建版)文档大纲 + +这份文档分两个视角并行展开:**产品文档**面向"想让工具跑起来并融入日常工作流"的使用者,按用户旅程组织;**开发者设计探秘**面向想理解内部原理、挖掘决策背后动机的工程师,按"被约束逼出的设计链"组织。两者覆盖同一套代码,但章节切分、措辞、锚点指向各不相同,让不同读者按自己的路径进入。 + +--- + +## 第一部分:产品文档大纲(使用者视角) + +按"安装 → 配置 → 日常 → 扩展 → 进阶 → 排错"线性旅程组织。每章标题呼应用户想做什么,而非工具有什么。 + +### 1. 第一章:从零开始 —— 安装、首次启动与环境要求 + +章节摘要:把工具装到本机,跑通第一次对话。覆盖 Bun 运行时、Node.js 兼容产物、dev/build 两种使用方式,以及首次启动的信任对话框与初始化流程。 + +子章节: + +- 我需要先装什么?Bun 与 Node.js 的取舍 +- 三种安装方式:`bun run dev`、构建产物 `dist/cli.js`、Vite 构建链 +- 第一次启动会发生什么:trust dialog、init 流程、telemetry 询问 +- 快速路径命令一览(`--version` / `-v` / `--help`) +- 把 `claude` 设为全局命令:`cli-bun.js` 与 `cli-node.js` 双入口 +- 环境自检:`bun run health` 与 `claude doctor` + +锚点: +- `docs/getting-started/installation.mdx` +- `docs/getting-started/quickstart.mdx` +- `src/entrypoints/cli.tsx`、`src/entrypoints/init.ts` +- `build.ts`、`scripts/dev.ts` +- 命令:`bun run dev` / `bun run build` / `bun run health` / `claude doctor` / `claude --version` + +### 2. 第二章:让 Claude 听你的 —— 配置 Provider 与模型 + +章节摘要:回答"我用哪家 API?"这个最高频问题。覆盖 7 个 Provider 的切换方式、引导式登录、环境变量清单,以及"为什么我切了 Provider 没生效"和"我改了 key 为什么没生效"两个高频排错。 + +子章节: + +- 一张表看懂 7 个 Provider:Anthropic / OpenAI 兼容 / Gemini / Grok / Bedrock / Vertex / Foundry +- 三种切换方式:`/provider` 命令、`/login` 引导式登录、`CLAUDE_CODE_USE_*` 环境变量 +- 中国 LLM 引导式登录:DeepSeek / 智谱 GLM / 通义千问 / Moonshot / Cerebras / Groq +- 用 ChatGPT 订阅当后端:`OPENAI_AUTH_MODE=chatgpt` 的设备码流程、`~/.claude/openai-chatgpt-auth.json` 凭证存储、与 Codex CLI 跨工具共享 `~/.codex/auth.json`、5 分钟刷新偏差窗口 +- 每个 Provider 的 key 配置清单(`OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 或 `XAI_API_KEY` / `AWS_REGION` / `ANTHROPIC_VERTEX_PROJECT_ID` / `ANTHROPIC_FOUNDRY_*`) +- 模型映射是怎么决定的:`PROVIDER_MODEL` > `PROVIDER_DEFAULT_{FAMILY}_MODEL` > `ANTHROPIC_DEFAULT_*` > 默认表 +- 为什么切了 Provider 没生效?`modelType` 优先级、`/provider unset` 只清 Provider 不清 key、`isFirstPartyAnthropicBaseUrl()` TODO 陷阱(只设 `OPENAI_BASE_URL` 没设 `ANTHROPIC_BASE_URL` 会让 firstParty 行为泄漏) +- **我改了 API key 但没生效?** —— 模块级 client cache 陷阱:`getOpenAIClient()`/`getGrokClient()` 会话级缓存客户端实例,中途改 key 必须重启或调用 `clearOpenAIClientCache()` +- 本地模型与自托管端点:Ollama / vLLM / DeepSeek 自托管 +- DeepSeek 思维模式自动检测与三格式注入;为什么必须回显 `reasoning_content: ''`(空字符串),否则下一次请求会被 400 拒绝 +- `/effort` 与 `CLAUDE_CODE_EFFORT_LEVEL` 的取值语义:`low` / `medium` / `high` / `xhigh` 四档,以及它在 ChatGPT Responses API 上如何落地为 `reasoning.effort` 参数 + +锚点: +- `docs/getting-started/model-providers.mdx` +- `src/commands/provider.ts`、`src/commands/login/login.tsx` +- `src/components/ConsoleOAuthFlow.tsx`、`src/utils/chinaLlmProviders.ts` +- `src/utils/model/providers.ts` +- `src/services/api/openai/`、`src/services/api/gemini/`、`src/services/api/grok/` +- `src/services/api/openai/client.ts:39`(`getOpenAIClient` 模块级缓存) +- `src/services/api/openai/responsesAdapter.ts`(Responses API 适配器) +- `src/services/api/client.ts`(`isFirstPartyAnthropicBaseUrl` 陷阱) +- `src/services/providerUsage/adapters/openai.ts:62`(限流响应头解析) +- 命令:`/provider ` / `/provider unset` / `/login` / `/model` / `/effort` + +### 3. 第三章:日常对话 —— 交互式 REPL 怎么用 + +章节摘要:装好之后每天打开 `claude` 会做什么。覆盖发消息、看流式回复、中断、恢复会话、切模型、切权限模式、查看 token 消耗等高频日常操作。 + +子章节: + +- 发消息、看流式回复、Esc 中断、Ctrl+C 退出 +- 会话怎么持久化:恢复上一次对话(`/resume`)、查看历史(`/history`)、清空上下文(`/clear`) +- 切换模型与思考强度:`/model`、`/effort`(low/medium/high/xhigh)、ultrathink 触发词 +- 权限模式:默认询问 / 自动批准 / 全部拒绝 / sandbox 切换 +- 看 token 与费用:`/cost`、`/usage`、`/stats`、状态栏显示 +- 上下文管理与自动压缩:`/compact`、自动 compact 触发条件、`/force-snip` 强制剪裁 +- 把对话导出与分享:`/export`、`/share`、`/summary`,各自的产物格式与隐私边界(谁会看到什么、是否包含凭证) +- 更换主题、输出风格、语言:`/theme`、`/output-style`、`/lang` +- 配置项目记忆:CLAUDE.md 与 `@include` 指令、`/memory` 命令 + +锚点: +- `src/screens/REPL.tsx`、`src/query.ts`、`src/QueryEngine.ts`、`src/context.ts`、`src/utils/claudemd.ts` +- `src/commands/clear/`、`compact/`、`cost/`、`usage/`、`history/`、`resume/`、`model/`、`effort/`、`mode/`、`memory/`、`export/`、`share/`、`theme/` +- 命令:`claude` / `claude -p '...'` / `claude --resume` + +### 4. 第四章:slash 命令速查 —— 不用记全部,按场景找 + +章节摘要:把上百个 slash 命令按"我想做什么"分类,让用户能快速找到自己需要的那一个,而不是背诵命令清单。 + +子章节: + +- 会话与上下文类:`/clear` `/compact` `/resume` `/history` `/context` `/rewind` `/force-snip` +- 模型与 Provider 类:`/model` `/provider` `/effort` `/login` `/logout` +- 费用与限额类:`/cost` `/usage` `/stats` `/rate-limit-options`(待核实是否存在) `/reset-limits`(待核实是否存在);实际机制是通过响应头 `x-ratelimit-*-requests/tokens` 与 `Reset-After` 自动追踪限流 +- 配置与个性化类:`/theme` `/output-style` `/lang` `/keybindings` `/config` `/env` +- 项目与文件类:`/add-dir` `/files` `/diff` `/context` `/ctx_viz` +- 插件与扩展类:`/plugin` `/skills` `/skill-store` `/reload-plugins` `/hooks` +- 工作流自动化类:`/commit` `/commit-push-pr` `/review` `/plan` `/schedule` `/loop` +- 诊断与帮助类:`/help` `/doctor` `/status` `/version` `/feedback` +- 隐藏与实验类:`/bughunter` `/advisor` `/insights` `/thinkback` `/torch` + +锚点: +- `src/commands/`、`src/commands/help/`、`doctor/`、`config/`、`env/` +- 命令:`/help` / `claude --help` +- 注意:`/rate-limit-options` 与 `/reset-limits` 在 findings 中没有对应锚点,应标记为"待核实是否存在",或替换为已验证的"通过响应头追踪限流"机制 + +### 5. 第五章:扩展 Claude 的能力 —— MCP Server、插件、Skill + +章节摘要:当内置工具不够用时怎么办。覆盖接入现成 MCP server、自己写一个、安装社区插件、用 Skill 沉淀工作流。 + +子章节: + +- MCP 是什么?什么时候应该用 MCP 而不是普通工具 +- 用 `claude mcp add` 接入现成 MCP server(stdio / SSE / HTTP) +- 管理已接入的 server:`claude mcp list` / `remove` / `serve` +- MCP OAuth 简化流程与认证(`/mcp-auth`) +- 自己写一个 MCP server 的最小骨架 +- Computer Use / Chrome 控制 / 语音输入这些内置 MCP 怎么开 +- 插件系统:`/plugin` 浏览、安装、启用、禁用、卸载 +- Marketplace 浏览与插件市场 +- Skill 是什么?`/skills` 与 `/skill-store` 的区别 +- 怎么写一个自己的 Skill 并复用 +- Skill 搜索与延迟工具加载:SearchExtraTools 与 ExecuteExtraTool + +锚点: +- `docs/features/tools/` +- `docs/features/external/chrome-control.md`、`computer-use.md`、`voice-mode.md`、`web-browser-tool.md` +- `src/commands/mcp/`、`plugin/`、`skills/`、`skill-store/`、`skill-search/` +- `src/services/searchExtraTools/` +- `packages/@ant/computer-use-mcp/`、`packages/@ant/claude-for-chrome-mcp/` +- 命令:`claude mcp add/list/remove/serve` / `/plugin` / `/skills` / `/skill-store` + +### 6. 第六章:让 Claude 帮你跑大任务 —— 子代理、Plan 模式、Task 系统 + +章节摘要:当任务超过单次对话、需要并行或分阶段执行时怎么办。覆盖 Agent 工具、Task 系统、Plan 模式、worktree 隔离。 + +子章节: + +- 什么时候该派子代理?单线程 vs 并行 vs 分阶段 +- Agent 工具:在对话里 spawn 一个子代理处理子任务 +- Task 系统:TaskCreate / TaskUpdate / TaskList / TaskGet 管理任务清单 +- Plan 模式:先想清楚再动手(`/plan`、EnterPlanMode、ExitPlanModeV2、VerifyPlanExecution) +- Goal 命令:给定目标后让 Claude 自主推进(`/goal`) +- Worktree 隔离:在独立 git worktree 里跑实验性改动 +- Coordinator 模式:多 worker 协作(`COORDINATOR_MODE` feature) +- Workflow 脚本:把多步工作流固化成可重放脚本(`/workflows`) +- Ultra-batch 与 dispatching-parallel-agents Skill 的取舍 + +锚点: +- `docs/features/agents/` +- `packages/agent-tools/` +- `packages/builtin-tools/src/tools/AgentTool/`、`TaskCreateTool/`、`EnterPlanModeTool/`、`EnterWorktreeTool/` +- `src/commands/plan/`、`goal/`、`workflows/`、`coordinator.ts` +- Skill:ultra-batch / dispatching-parallel-agents / experiment-driven-research + +### 7. 第七章:让 Claude 长时间帮你干活 —— Daemon、Background Sessions、Schedule + +章节摘要:当任务需要小时级持续运行、定时触发、或后台并行多个会话时怎么办。覆盖 daemon 模式、bg sessions、cron/schedule、loop。 + +子章节: + +- Daemon 是什么?跟普通 REPL 的区别(长驻 supervisor + worker) +- 启停 daemon:`claude daemon start/stop/bg/attach/logs/kill/status` +- `--daemon-worker=` 精简 worker 的用途 +- Background Sessions:`claude --bg` / `claude ps` / `claude attach` / `claude kill` +- Template Jobs:`claude job new/list/reply` 模板化任务 +- 定时调度:`/schedule` 创建远程 cron 触发器、`/loop` 本地循环、`cron-list` / `cron-delete` +- 用 `/loop` 让 Claude 每 N 分钟自动跑一次任务 +- Schedule 触发器与 RCS 的关系 +- 什么时候该用 daemon,什么时候用 background session,什么时候用 schedule + +锚点: +- `src/daemon/`、`src/commands/daemon/`、`attach/`、`tasks/`、`job/`、`schedule/`、`loop` +- Skill:loop / cron-list / cron-delete / schedule +- 命令:`claude daemon ` / `claude --bg` / `claude ps` / `claude attach` / `claude kill` + +### 8. 第八章:跨机器与跨团队协作 —— Bridge、Remote Control、ACP + +章节摘要:当 Claude 需要跑在远程机器、被外部客户端调用、或接入 IDE/团队工具时怎么办。覆盖 Bridge 模式、自托管 RCS、ACP 协议、IDE 桥接。 + +子章节: + +- Bridge 模式是什么?什么时候启用(`BRIDGE_MODE` feature) +- Remote Control 快速路径:`claude remote-control` / `rc` / `remote` / `sync` / `bridge` +- 自托管 RCS:Docker 部署、Web UI 控制面板、`bun run rcs` +- RCS Web UI:会话管理、ACP agent 接入、SSE 事件流 +- ACP 协议:把 Claude Code 暴露成 ACP agent(`claude --acp`) +- ACP 权限管道与 `session/update` plan 可视化 +- acp-link:WebSocket 客户端桥接到 ACP agent +- IDE 桥接:VS Code 集成(`vscode-ide-bridge/`、`/ide` 命令) +- SSH 远程模式:`SSH_REMOTE` feature 与 `/remote-setup`、`/remote-env` +- 与 Codex CLI 跨工具凭证共享(`~/.codex/auth.json`、`~/.claude/openai-chatgpt-auth.json`) + +锚点: +- `docs/features/modes/remote-control-self-hosting.md` +- `docs/features/agents/acp.md`、`pipes-and-lan.md` +- `src/bridge/`、`src/services/acp/` +- `packages/remote-control-server/`、`packages/acp-link/`、`vscode-ide-bridge/` +- `src/commands/bridge/`、`remoteControlServer/`、`remote-setup/`、`remote-env/`、`ide/` +- 命令:`claude remote-control` / `claude rc` / `claude bridge` / `claude --acp` / `bun run rcs` + +### 9. 第九章:省钱、提速、定制 —— 穷鬼模式、缓存、Hooks、配置文件 + +章节摘要:当 token 账单偏高、响应偏慢、或想让 Claude 自动响应某些事件时怎么办。覆盖穷鬼模式、prompt 缓存、hooks、settings.json、keybindings,以及权限规则写作指南。 + +子章节: + +- 穷鬼模式(`/poor`):跳过 `extract_memories` / `prompt_suggestion` / `verification_agent`,对各 Provider 都生效(含兼容层),持久化到 `settings.json` +- Prompt 缓存怎么工作?缓存断点检测(`PROMPT_CACHE_BREAK_DETECTION`) +- Token 预算管理:`TOKEN_BUDGET` feature 与 `/cost` 联动 +- Hooks:在 `settings.json` 里写"每次 X 发生就执行 Y" +- `settings.json` vs `settings.local.json`:团队共享 vs 个人覆盖 +- CLAUDE.md 四层层级与优先级:Managed / User / Project / Local +- `@include` 指令:在 CLAUDE.md 里引用其他文件 +- `keybindings.json`:自定义快捷键与 chord +- **权限规则配置指南**:`allow` / `deny` 规则的具体语法(含工具名匹配、glob 模式、规则优先级)、`/permissions` 命令、沙箱模式与 `bypassPermissions` 在非 root/sandbox 环境的可用性检测 +- Feature flag 运行时开关:`FEATURE_=1`,以及已知禁用清单(`CONTEXT_COLLAPSE` / `HISTORY_SNIP` / `FORK_SUBAGENT` / `UDS_INBOX` / `LAN_PIPES` / `REVIEW_ARTIFACT` / `SKILL_LEARNING` / `TEAMMEM`)与启用后果 + +锚点: +- `src/commands/poor/poorMode.ts` +- `src/commands/hooks/`、`permissions/`、`config/`、`keybindings/` +- `src/utils/claudemd.ts`、`src/context.ts` +- Skill:update-config / keybindings-help +- 命令:`/poor` / `/hooks` / `/config` / `/permissions` / `/env` + +### 10. 第十章:可观测性与排错 —— 卡住了怎么办 + +章节摘要:当 Claude 报错、卡住、行为异常或想理解它在做什么时怎么办。覆盖 doctor、debug、日志、Langfuse 追踪、常见错误对照表。 + +子章节: + +- 第一步永远先跑:`claude doctor` 与 `bun run health` +- **Provider 报错对照表**:401(key 无效) / 403(地区限制) / 429(限流,看 `x-ratelimit-*` 头与 `Reset-After`) / `overloaded_error`(1305 / 上游过载) / 模型不存在 +- OpenAI/Gemini/Grok 兼容层特有坑:模型映射失败(Gemini 硬抛异常)、`reasoning_content` 缺失导致 DeepSeek 400、限流响应头解析 +- Bedrock Opus 4.7 的 400 错误与 `anthropic_beta` 体剥离补丁:何时打、SDK 升级后如何通过 `scripts/probe-bedrock-beta-fix.ts` 检测是否还需要 +- MCP server 连不上:stdio 路径、SSE 超时、OAuth 失败排查清单 +- 权限被拒、工具被禁用、deferred tool 没加载 +- 内存膨胀与长会话:`performanceShim`、`clearMarks`、`/compact`、`/force-snip` +- 调试模式:`BUN_INSPECT=`、`--dump-system-prompt`、`/debug-tool-call` +- Langfuse 追踪:每次查询的 `provider` 字段(`openai` / `gemini` / `grok` / `getAPIProvider()`)与 `recordLLMObservation` +- 导出会话给同事看:`/export`、`/share`、`/recap` 的产物格式与隐私边界 +- 反馈与上报 bug:`/feedback`、`/perf-issue`、`/bughunter` +- 已知禁用的 feature flag 清单与启用后果 + +锚点: +- `docs/features/tools/langfuse-monitoring.md` +- `src/commands/doctor/`、`debug-tool-call/`、`feedback/`、`perf-issue/`、`heapdump/` +- `src/utils/performanceShim.ts` +- `src/services/api/bedrockClient.ts:29` +- `src/services/providerUsage/adapters/openai.ts:62` +- `scripts/probe-bedrock-beta-fix.ts` +- 命令:`claude doctor` / `bun run health` / `BUN_INSPECT=9229 bun run dev:inspect` / `claude --dump-system-prompt` + +### 11. 第十一章:自动化与 CI 集成 —— 把 Claude 嵌入流水线 + +章节摘要:当想在 CI、脚本、cron、容器里无交互调用 Claude 时怎么办。覆盖 pipe 模式、headless、BYOC runner、容器环境变量、与 ACP/Bridge 的交汇点。 + +子章节: + +- Pipe 模式:`echo '...' | claude -p` 一次性调用 +- Headless 模式:无 TTY 环境下的行为差异 +- **BYOC runner**:`claude environment-runner` / `claude self-hosted-runner`(与第八章 ACP、Bridge 的交汇点) +- 容器环境:`CLAUDE_CODE_REMOTE=true` 自动调内存上限(`--max-old-space-size=8192`) +- `CLAUDE_CODE_FORCE_INTERACTIVE`:嵌套 bun 启动的 TTY 欺骗 +- `CLAUDE_CODE_ABLATION_BASELINE`:L0 消融基线的用途 +- 在 GitHub Actions 里跑 claude(`install-github-app`、`subscribe-pr`、`commit-push-pr`) +- 定时任务:用 `/schedule` 或 cron + pipe 实现巡检 +- 退出码与 `pipe-status`:脚本里判断成功失败 + +锚点: +- `src/entrypoints/cli.tsx` +- `src/commands/pipe-status/`、`install-github-app/`、`subscribe-pr/`、`commit-push-pr.ts` +- 命令:`claude -p` / `claude environment-runner` / `claude self-hosted-runner` / `claude --bg` + +### 12. 第十二章:进阶实验性能力与社区生态 + +章节摘要:给愿意折腾的用户一张"还能玩什么"的地图。覆盖实验 feature、buddy、监控、advisor、teleport 等小众但强大的命令。 + +子章节: + +- 实验性 feature flag 速览:`BUDDY` / `KAIROS` / `LODESTONE` / `ULTRAPLAN` / `MONITOR_TOOL` +- Skill 搜索实验:`EXPERIMENTAL_SKILL_SEARCH` / `EXPERIMENTAL_SEARCH_EXTRA_TOOLS`(编译进 build,运行时默认 OFF,`SKILL_SEARCH_ENABLED=1` 开启) +- Buddy 协作与 `/buddy` 命令 +- Kairos 简报与 `/brief`、Away Summary、`/recap` +- Advisor、insights、thinkback:让 Claude 反思自己的输出 +- Teleport 与 pipes:跨会话消息传递 +- Local vault 与 memory stores:长期记忆的多后端 +- TUI 实验、stickers、output-style 自定义 +- 贡献者生态:`/feedback`、GitHub issues、`bun run docs:dev` 本地起文档站 + +锚点: +- `src/commands/buddy/`、`brief.ts`、`recap/`、`advisor.ts`、`insights.ts`、`thinkback/`、`teleport/`、`pipes/`、`local-vault/`、`memory-stores/`、`tui/`、`stickers/`、`output-style/` +- 命令:`bun run docs:dev` / `FEATURE_=1 bun run dev` + +### 13. 第十三章:安全 —— 凭证、权限、刷新、共享(交叉补充) + +章节摘要:当前两份大纲都没有连贯的安全章节。把凭证存储、权限模式、OAuth 刷新、跨工具凭证共享集中讲清楚,让用户知道自己的密钥和令牌去了哪里。 + +子章节: + +- 凭证存储位置清单:`~/.claude/`、`~/.claude/openai-chatgpt-auth.json`、`~/.codex/auth.json`、`~/.claude.json`、`settings.json` / `settings.local.json` +- OAuth 设备码流程:ChatGPT 订阅路径与 Anthropic OAuth 各自的设备码握手 +- OAuth 令牌自动刷新的 5 分钟偏差窗口 +- 权限模式语义:默认询问 / 自动批准 / 全部拒绝 / sandbox / `bypassPermissions`(非 root/sandbox 环境检测) +- JWT 认证(Bridge 模式):token 签发、传输、回收 +- `/share` 与 `/export` 的隐私边界:哪些字段会泄漏、是否包含凭证、给同事前要做什么 +- 跨工具凭证共享的隐私影响:Codex CLI 共享 `~/.codex/auth.json` 的含义 + +锚点: +- `src/commands/login/login.tsx` +- `src/services/api/openai/chatgptAuth.ts:327` +- `src/components/ConsoleOAuthFlow.tsx:1294` +- `src/commands/permissions/`、`share/`、`export/` +- `src/services/acp/permissions.ts` + +--- + +## 第二部分:开发者设计探秘大纲(开发者视角) + +按"被约束逼出的决策链"组织:从最戏剧性的设计动机(JSC 内存暴涨)出发,逐层剥开入口、核心循环、工具系统、Provider 抽象、UI 框架、状态管理、运行时补丁、Feature Flag、特殊模式、测试策略、反编译指纹。每章都回答"为什么这么设计?"。 + +### 1. 序章:一份被反编译重建的 CLI,为什么处处是"约束的印记" + +章节摘要:开篇先回答整个项目最根本的好奇心——这不是 Anthropic 原版,而是反编译产物在 Bun/JSC 约束下的重建。点明全书主线:每一个看似奇怪的设计背后,都藏着一个具体的运行时约束或反编译痕迹。 + +子章节: + +- 反编译的语义:为什么 stub 模块、feature-gated 代码、React Compiler 的 `_c()` 是正常的 +- 全书的叙事主线:约束(JSC 内存、Bun DCE、运行时类型补丁)如何驱动架构 +- 如何阅读本书:每章锚点都指向真实 `文件:行号`,请打开编辑器对照 +- 两类禁用 feature 的诚实区分:反编译丢失导致的 stub(`CONTEXT_COLLAPSE` / `HISTORY_SNIP` / `FORK_SUBAGENT`)vs 功能原本就 stubbed 的(`SKILL_LEARNING` / `TEAMMEM`)—— 这两类经常被混淆 + +锚点: +- `src/types/react-compiler-runtime.d.ts:1` +- `src/types/global.d.ts:9`、`global.d.ts:59` +- `CLAUDE.md` + +### 2. 第一章:Code Splitting 不是优化,是生存需求 + +章节摘要:全书最戏剧性的设计动机——单文件 17MB 产物让 Bun/JSC 全量解析导致 RSS 暴涨到 ~1GB,而 Node/V8 懒解析仅需 ~220MB。项目因此被迫切成 600+ chunks,`--version` 的 RSS 从 966MB 骤降到 35MB。 + +子章节: + +- JSC 的贪婪解析 vs V8 懒解析:实验数据(17MB → 1GB vs 220MB) +- 为什么 Vite 必须代码分割而不是单文件:Bun 按需加载 chunks 的原理 +- 双构建管线:`Bun.build()` vs Vite,各自的 chunk 布局(`dist/` vs `dist/chunks/`) +- post-build 阶段为什么必须 patch `globalThis.Bun` 解构(`@anthropic-ai/sandbox-runtime` 在 Node.js 启动会崩) +- 构建产物同时兼容 bun/node:`import.meta.require` → `createRequire` 的运行时探测 + +锚点: +- `build.ts:23`、`build.ts:43`、`build.ts:62` +- `vite.config.ts:94` +- `scripts/post-build.ts` +- `src/utils/distRoot.ts:15` + +### 3. 第二章:入口的 Fast-Path 优先级链 —— 为什么 --version 必须零模块加载 + +章节摘要:`cli.tsx` 的 `main()` 函数按优先级串起十几条快速路径,最极端的是 `--version` / `-v` 零模块加载。背后的设计哲学:CLI 启动延迟是用户体验第一杀手,每个子命令都应该尽可能晚地加载它真正需要的代码。 + +子章节: + +- Fast-Path 优先级链:`--version` → `--dump-system-prompt` → MCP servers → `daemon-worker` → bridge → BG sessions → 默认 `main.tsx` +- **为什么 `CLAUDE_CODE_ABLATION_BASELINE` 必须 inline 在 cli.tsx 顶层**:BashTool / AgentTool / PowerShellTool 在 import 时就把 `DISABLE_BACKGROUND_TASKS` 等环境变量捕获进模块级 `const`,`init()` 跑得太晚无法影响它们 —— 这是一条脆弱但必要的初始化顺序依赖 +- MACRO 编译期注入的三层防线:dev 模式 `-d` flag、build `Bun.build define`、运行时 fallback `globalThis.MACRO` +- 为什么版本号单一来源在 `package.json` 而不是 hardcoded(避免漂移) +- 双入口 `cli-bun.js` / `cli-node.js`:同一份产物被两个运行时执行 + +锚点: +- `src/entrypoints/cli.tsx:5`、`cli.tsx:11`、`cli.tsx:56`、`cli.tsx:76`、`cli.tsx:79` +- `scripts/defines.ts:18`、`defines.ts:39` +- `scripts/dev.ts:17` + +### 4. 第三章:performanceShim —— JSC 内存泄漏的运行时补丁 + +章节摘要:`src/utils/performanceShim.ts` 必须是 `cli.tsx` 的第一行 import。JSC 的原生 Performance 把 marks/measures 存进永不收缩的 C++ Vector,长会话累积数百 MB 死容量。这个 shim 在 React/OTel 捕获原生引用之前劫持全局 performance。 + +子章节: + +- JSC 原生 Performance 的陷阱:C++ Vector 永不收缩 +- 为什么保留 `performance.now()` 走原生,只劫持 `mark` / `measure` / `getEntries` +- 为什么必须最先 import:React reconciler 和 OTel 会捕获原生引用 +- `query.ts` 的 finally 块兜底 `clearMarks` / `clearMeasures` —— 防 sub-agent 直接 import query 时 shim 没装上 +- 为什么 dev 模式 `NODE_ENV='production'`:避免 6,889+ `_debugStack` Error 对象(12MB) + +锚点: +- `src/utils/performanceShim.ts:1`、`performanceShim.ts:18`、`performanceShim.ts:162` +- `src/query.ts:460` + +### 5. 第四章:核心 Query Loop —— 为什么 query() 是 async generator + +章节摘要:`src/query.ts` 的 `query()` 是 `async function*`,yield `StreamEvent` / `Message` / `TombstoneMessage` / `ToolUseSummaryMessage`,最终 return `Terminal`。背后的设计:流式响应必须能够把"结果"与"副作用"解耦,调用方可以选择性消费。 + +子章节: + +- async generator vs callback:为什么用 yield 而不是事件发射器 +- `queryLoop()` 的委托模式:thinking 块的 3 条硬约束(`max_thinking_length>0`、不能是最后一块、跨工具轨迹保留) +- `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT=3`:`max_output_tokens` 错误为什么会对调用方扣留(yield 会终止会话) +- `QueryEngine` 作为 `query()` 之上的会话编排器:messages / fileCache / usage 跨 turn 持久 +- `snipReplay` 回调:让 feature-gated 字符串留在 gated 模块外,`QueryEngine` 在 `bun test` 下仍可测 + +锚点: +- `src/query.ts:181`、`query.ts:276`、`query.ts:367`、`query.ts:393`、`query.ts:460` +- `src/QueryEngine.ts:138`、`QueryEngine.ts:192`、`QueryEngine.ts:217` + +### 6. 第五章:Feature Flag 系统的三个硬约束 + +章节摘要:`feature()` 不是普通的运行时函数——它有 Bun 编译器强加的三个硬约束:(1) 只能出现在 `if` 条件或三元表达式(DCE 限制);(2) 不能赋值给变量;(3) vite 插件必须在 transform 阶段替换为字面量,否则 bundler 会尝试解析不存在的 import。 + +子章节: + +- 为什么 `feature()` 不是布尔变量:Bun 编译器 DCE 的 AST 模式匹配限制 +- `vite-plugin-feature-flags.ts` 的 transform 时机:import 解析之前的字面量替换 +- `REVIEW_ARTIFACT` 内的 `hunter.js` 根本不存在:为什么 `if(false)` 必须在 parse 阶段可见 +- Build 默认 65+ feature vs Dev 全开 vs 运行时 `FEATURE_=1`:三层切换机制 +- 反编译产物的 stub 陷阱:明确区分反编译丢失的 stub(`CONTEXT_COLLAPSE` / `HISTORY_SNIP` / `FORK_SUBAGENT`,启用会破坏核心功能)vs 功能原本就 stubbed 的(`SKILL_LEARNING` / `TEAMMEM`) + +锚点: +- `scripts/vite-plugin-feature-flags.ts:29` +- `src/types/internal-modules.d.ts:10` + +### 7. 第六章:工具系统的延迟加载与 CORE_TOOLS 白名单 + +章节摘要:60 个工具不会一次性全部加载——`CORE_TOOLS` 38 个白名单是"always-available"核心,其余通过 `SearchExtraToolsTool` 按需 TF-IDF 搜索。背后的设计:tool schema 本身会消耗 token,必须按对话需求动态展开。 + +子章节: + +- `CORE_TOOLS` 白名单制:`isDeferredTool` 的判定逻辑 +- `SearchExtraToolsTool`:用 TF-IDF 语义搜索延迟工具(复用 `localSearch.ts` 的 `computeWeightedTf` / `computeIdf` / `cosineSimilarity`) +- `toolIndex.ts` 的共享算法:为什么 skill prefetch 和 tool prefetch 用独立的去重 Set(`discoveredToolsThisSession` 互不影响) +- feature-gated 工具:`feature()` 条件加载模式 `const x = feature('X') ? require('./x.js') : null` +- `SyntheticOutput`:`CORE_TOOLS` 中用于延迟工具按需加载的特殊工具 + +锚点: +- `src/constants/tools.ts` +- `src/tools.ts` +- `src/services/searchExtraTools/toolIndex.ts`、`prefetch.ts` +- `packages/builtin-tools/src/tools/` + +### 8. 第七章:7-Provider 抽象层的单一调度点 + +章节摘要:`claude.ts:1344` 是整个 Provider 系统的心脏——在共享预处理(消息归一化、工具过滤、媒体剔除)之后、Anthropic 特定逻辑(betas/thinking/caching)之前动态导入 Provider 路径。兼容层因此自然跳过 Prompt 缓存/beta 功能,无需 feature flag。 + +子章节: + +- Provider 路由优先级链:`modelType` 参数 > `CLAUDE_CODE_USE_*` 环境变量 > firstParty 默认 +- 为什么调度点位置这么精确:兼容层"结构性跳过"betas/thinking 的优雅 +- **调度点的不对称:给 OpenAI 路径传 `tools`(全池)但给 gemini/grok 传 `filteredTools`(裁剪后)**—— 因为 OpenAI 路径在内部模拟 Anthropic 延迟工具加载给 `SearchExtraToolsTool`,需要访问完整池。这恰恰是"调度点位置精确"论点的最强证据 +- `getAPIProvider()` 是单一真相源:`/provider` 命令、Langfuse 追踪、模型映射都依赖它 +- Provider 切换的原子性:`/provider` 命令同时清除所有 `CLAUDE_CODE_USE_*` 再 `applyConfigEnvironmentVariables` +- Anthropic 内部 4 Provider 统一伪装成 `Anthropic` SDK 类型——代码注释承认的"类型谎言" +- `isFirstPartyAnthropicBaseUrl()` 的 TODO 陷阱:firstParty 行为可能泄漏到兼容层 + +锚点: +- `src/utils/model/providers.ts:15` +- `src/services/api/claude.ts:1344`(调度点 + tools/filteredTools 不对称) +- `src/services/api/client.ts:84` +- `src/services/api/claude.ts:2999` +- `src/commands/provider.ts:39` + +### 9. 第八章:流适配器 —— 让 OpenAI/Gemini/Grok 假装自己是 Anthropic + +章节摘要:`adaptOpenAIStreamToAnthropic` / `adaptGeminiStreamToAnthropic` 是纯 async generator,把第三方流格式转换成 `BetaRawMessageStreamEvent`。下游 `claude.ts` 的 `contentBlocks` 累加器与原生 Anthropic 路径完全一致——零分支。这是整个多 API 兼容层最巧妙的设计。 + +子章节: + +- 流适配器模式:async generator 作为格式翻译器 +- 为什么下游零分支:`contentBlocks` 累加器不知道上游是什么 Provider +- **`message_stop` 后兜底:OpenAI/Grok 适配器在内存累积 `contentBlocks` 仅在 `message_stop` 时组装,网络中断时存在重复发射风险;post-loop 安全回退在 `partialMessage` 未重置时重发** —— 这是"下游零分支"叙事里少数有针对性修补的点 +- `@ant/model-provider` 作为无副作用转换器库 vs `src/services/api` 作为客户端实例化器 +- DeepSeek 思维模式的三层兼容:官方 `thinking` / 自托管 `enable_thinking` / 小米 `chat_template_kwargs` +- 为什么 Grok 复用整个 OpenAI 适配器栈:只有 client 和 `resolveGrokModel` 是 Grok 特有 +- ChatGPT 订阅路径:Responses API 是 OpenAI 内部的第二个适配器(`input_text` / `input_image` / `role` messages 转换 + `adaptResponsesStreamToAnthropic` vs Chat Completions 流适配器) + +锚点: +- `packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts:35` +- `packages/@ant/model-provider/src/shared/openaiConvertMessages.ts:32` +- `src/services/api/openai/index.ts:214` +- `src/services/api/openai/requestBody.ts:70` +- `src/services/api/openai/responsesAdapter.ts:1` +- `src/services/api/gemini/client.ts:26` +- `src/services/api/grok/index.ts:51` + +### 10. 第九章:Usage 字段映射与模型映射的优先级链 + +章节摘要:三个兼容层的模型映射都用四级优先级链:`PROVIDER_MODEL` 环境变量 > `PROVIDER_DEFAULT_{FAMILY}_MODEL` > `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` > `DEFAULT_MODEL_MAP` 查找表。但 Gemini 是唯一在都缺失时抛异常的。Usage 字段映射则有镜像设计 + cache 字段保留策略,是"下游零分支"叙事里唯一一个有针对性修补的例外。 + +子章节: + +- 正则 `/haiku|sonnet|opus/i` 推断模型系列的设计权衡 +- `GROK_MODEL_MAP` JSON:为什么 Grok 唯一支持用户自定义 JSON 映射 +- 防御性清理:`replace(/\[1m\]$/, '')` 剥离终端加粗 ANSI 后缀 +- `getOpenAIClient` / `getGrokClient` 的模块级缓存:会话中改 API key 必须 `clearOpenAIClientCache()`;对比 `getAnthropicClient()` 按 model/region 参数化的设计差异 +- **Usage 字段映射兼容性**:`updateOpenAIUsage` 与 `claude.ts:updateUsage` 的镜像设计;`cache_creation_input_tokens` / `cache_read_input_tokens` 在增量省略时保留,防止适配器差异导致缓存计数器被静默清零 —— 值得专门讲,因为它是"下游零分支"的唯一例外 +- BedrockClient 的针对性变通:剥离 `anthropic_beta` 体(SDK 0.26.4-0.28.1 漏洞)+ probe 脚本检测修复 + +锚点: +- `packages/@ant/model-provider/src/providers/openai/modelMapping.ts:36` +- `packages/@ant/model-provider/src/providers/gemini/modelMapping.ts:8` +- `packages/@ant/model-provider/src/providers/grok/modelMapping.ts:51` +- `src/services/api/openai/shared.ts`(`updateOpenAIUsage`) +- `src/services/api/claude.ts`(`updateUsage` 镜像) +- `src/services/api/bedrockClient.ts:29` +- `src/services/api/openai/client.ts:39` +- `src/services/api/grok/client.ts:15` + +### 11. 第十章:自研 Fork 的 Ink 框架 —— 为什么不是 src/ink/ + +章节摘要:`packages/@ant/ink/`(package.json name: `@anthropic/ink`)是基于 `react-reconciler` 自建的终端 React 渲染器。`core/` 目录有完整的 `reconciler.ts`、`dom.ts`、`yoga-layout/`、`render-node-to-output.ts`、`hit-test.ts`、`focus.ts`——这是一个完整的终端 DOM + 布局引擎,不是上游 Ink 库。 + +子章节: + +- 为什么 fork 而非用上游 Ink:完整终端 DOM + Yoga 布局引擎的掌控需求 +- react-reconciler 自建渲染器:`reconciler.ts` / `dom.ts` / `yoga-layout` / `render-node-to-output` / `hit-test` +- `vite.config.ts` 的 `dedupe: ['react', 'react-reconciler', 'react-compiler-runtime']` —— 为什么必须保证单副本 +- React Compiler 输出的 `_c()` memoization 模板 —— 为什么这是正常的 +- `global.d.ts` 的 `declare type T = unknown` —— 反编译产物特有的类型补丁(编译 JSX 丢失泛型) + +锚点: +- `packages/@ant/ink/package.json:1` +- `packages/@ant/ink/src/core/reconciler.ts:1` +- `vite.config.ts:94` +- `src/types/react-compiler-runtime.d.ts:1` +- `src/types/global.d.ts:9`、`global.d.ts:59` + +### 12. 第十一章:三层状态管理 —— 为什么 bootstrap/state.ts 警告 "DO NOT ADD MORE" + +章节摘要:`src/bootstrap/state.ts` 是模块级 singleton(sessionId、cwd、projectRoot、token counters),文件顶部警告不要再加。`src/state/store.ts` 是手写 33 行 zustand-style store。`src/state/AppState.tsx` 用 React Context 包裹 store——三层各司其职,边界严格。 + +子章节: + +- Bootstrap state:模块级 singleton 的诱惑与陷阱("DO NOT ADD MORE STATE HERE") +- 手写 zustand-style store:33 行代码(`createStore` 返回 `getState` / `setState` / `subscribe`,`Object.is` 短路、`Set`) +- `AppState.tsx` 的 React Context 包裹:`useSyncExternalStore` 订阅 slice +- `USER_TYPE==='ant'` 时返回根 state 会抛错:强制细粒度订阅避免全量 re-render +- `HasAppStateContext` 主动 throw 防嵌套:"AppStateProvider can not be nested" + +锚点: +- `src/bootstrap/state.ts:31`、`state.ts:45` +- `src/state/store.ts:1` +- `src/state/AppState.tsx:59`、`AppState.tsx:129` +- `src/state/AppStateStore.ts:42` + +### 13. 第十二章:ACP / Bridge / Daemon —— 三个长驻模式的接线 + +章节摘要:ACP(Agent Client Protocol)、Bridge(Remote Control)、Daemon(supervisor)是三种长驻运行模式。共同特征:feature-gated、独立 entry、跨进程通信。这一章揭示它们如何共享底层 query loop 又各自增加编排层,并与产品大纲第十一章(CI / BYOC runner)形成交叉。 + +子章节: + +- ACP agent 实现:`agent.ts` / `bridge.ts` / `permissions.ts` / `entry.ts` + `createAcpCanUseTool` 统一权限流水线 +- `acp-link` 包:WebSocket 客户端桥接到 ACP agent(REST 注册 + WS identify 两步流程) +- Bridge 模式:JWT 认证、消息传输、权限回调(feature `BRIDGE_MODE`) +- Daemon 模式:`workerRegistry.ts` 管 worker,`--daemon-worker=` 派生精简 worker(无 analytics sink) +- 自托管 RCS:`packages/remote-control-server/` Docker 部署 + Web UI(React 19 + Vite + Radix UI) +- **交叉点**:`claude environment-runner` / `self-hosted-runner` BYOC runner 正是 ACP/Bridge/CI 三条线的交汇点,产品大纲第十一章与此章应建立交叉引用 + +锚点: +- `src/services/acp/` +- `packages/acp-link/` +- `src/bridge/bridgeMain.ts` +- `src/daemon/main.ts`、`workerRegistry.ts` +- `packages/remote-control-server/` + +### 14. 第十三章:CLAUDE.md 四层层级与 @include 指令 + +章节摘要:CLAUDE.md 不是单个文件,而是四层层级:Managed → User → Project → Local,后加载的优先级更高(模型更关注)。`@include` 指令支持 60+ 种文本扩展名,防循环、不存在静默忽略,`MAX_MEMORY_CHARACTER_COUNT=40000`。 + +子章节: + +- 为什么逆序优先:离当前目录越近的文件越晚加载,模型关注度越高 +- `@include` 的四种路径形式:`@path` / `@./rel` / `@~/home` / `@/abs` +- `@include` 的边界:仅限叶子文本节点(非代码块内),防循环,不存在静默忽略 +- 为什么支持 60+ 种扩展名(`.md` / `.ts` / `.py` / `.rs` / `.swift` / `.sql` / `.graphql` ...) +- `context.ts` 如何把 git status / date / CLAUDE.md / memory files 组装成系统提示 + +锚点: +- `src/utils/claudemd.ts:1`、`claudemd.ts:88`、`claudemd.ts:95` +- `src/context.ts:36`、`context.ts:116` + +### 15. 第十四章:测试策略 —— 为什么 mock 必须从底层 HTTP 开始 + +章节摘要:Bun 的 `mock.module` 是 process-global 的(last-write-wins),不是 per-file 隔离。一个测试文件的 mock 会污染同进程所有 require/import。所以项目立下铁律:只 mock 有副作用的依赖链(log.ts / debug.ts / bun:bundle / axios),不 mock 纯函数。 + +子章节: + +- Bun `mock.module` 的进程全局陷阱:last-write-wins,测试文件执行顺序不保证字母序 +- 为什么不能 mock 被测模块的上层业务模块:`launch*.test.ts` 必须 mock axios 而非 `triggersApi` +- 共享 mock 文件 `tests/mocks/log.ts` 和 `tests/mocks/debug.ts`:源文件导出变更只需改一处 +- 集成测试 vs 回归测试的目录布局:`launch*.test.ts` 和 `api.test.ts` 同目录的判断标准 +- 排查 mock 污染的 4 步法:单独运行 / 同目录运行 / `console.error` milestone / specifier 解析 + +锚点: +- `tests/mocks/log.ts`、`debug.ts`、`axios.ts` +- `tests/integration/` + +### 16. 第十五章:biome.json 的 42 条规则关闭 —— 反编译产物的指纹 + +章节摘要:biome.json 关掉了 42 条 lint 规则——suspicious 关 `noExplicitAny` / `noConsole`,style 关 `useConst` / `useTemplate`,complexity 关 `noForEach` / `useArrowFunction`,correctness 关 `noUnusedVariables` / `useExhaustiveDependencies`。这不是偷懒,而是反编译产物的必然:decompiled 代码无法逐行重构,只能保留 recommended 基线。 + +子章节: + +- 42 条规则关闭的分类与原因:suspicious / style / complexity / correctness +- 为什么 `.tsx` 特殊:`lineWidth 120` + 强制分号(其他文件 80 + asNeeded) +- tsc vs biome 的冲突:`noUnusedPrivateClassMembers` 与声明属性的两难,`biome-ignore` 注释保留类型 +- `@ts-expect-error` 的维护纪律:MACRO 永真比较保留,类型系统更新后 directive 变 unused 必须移除 +- CI 的 `biome ci .` 必须 zero warnings —— 42 条关闭之外仍守底线 +- Node.js v22 不支持 `using` 声明的脆弱 transpile:vite 插件把 `using _x =` 正则替换成 `const _x =`,安全前提是 `SLOW_OPERATION_LOGGING` 未启用 —— 一条脆弱的 transpile 依赖 + +锚点: +- `biome.json:24`、`biome.json:102` +- `.editorconfig` + +### 17. 尾声:哪些坑我们没踩 —— 读者可以继续挖掘的方向 + +章节摘要:本章列出探索过程中因模型过载未能深挖的子系统,邀请读者沿着锚点继续挖掘。同时也诚实交代反编译重建工作的边界。 + +子章节: + +- 未深挖:`ConsoleOAuthFlow.tsx` 的 `china_provider_select` 表单 + `CHINA_LLM_PROVIDERS` 预设表 +- 未深挖:ChatGPT 订阅路径与 Codex CLI 跨工具凭证共享(`~/.codex/auth.json`) +- 未深挖:`poorMode`(`/poor` 命令)持久化到 `settings.json` + 跨所有兼容层复用 +- 未深挖:`isFirstPartyAnthropicBaseUrl()` TODO 陷阱与 `clearOpenAIClientCache` 模块级缓存陷阱 —— 给读者可追踪的线索 +- 未深挖:`vendor/ripgrep/arm64-darwin` 二进制缺失的实际后果(Grep 工具 spawn 该路径 ENOENT,`distRoot.ts` vendor 复制逻辑就是为了解决这个) +- 反编译工作的诚实边界:哪些 stub 是因为反编译丢失,哪些是因为功能原本就 stubbed +- 邀请读者:带上编辑器,沿着锚点继续探索 + +锚点: +- `src/components/ConsoleOAuthFlow.tsx:1294` +- `src/utils/chinaLlmProviders.ts:44` +- `src/services/api/openai/chatgptAuth.ts:327` +- `src/commands/poor/poorMode.ts` +- `src/services/api/client.ts`(`isFirstPartyAnthropicBaseUrl`) +- `src/services/api/openai/client.ts:39`(`clearOpenAIClientCache`) +- `src/utils/distRoot.ts`、`src/utils/vendor/ripgrep/` + +--- + +## 第三部分:交叉主题(两个视角都需要覆盖) + +下列主题在产品与设计两个视角下都需要覆盖,但写法、深度、锚点指向各不相同。 + +### 1. 排错与错误对照 + +- 产品视角:作为第十章主体。给一张"Provider 报错对照表"(401 / 403 / 429 / `overloaded_error` 1305 / 模型不存在),配兼容层特有坑(DeepSeek `reasoning_content` 400、Bedrock `anthropic_beta` 400、Gemini 硬抛异常、OpenAI 限流头解析)。措辞用"我遇到了 X,怎么办?" +- 设计视角:当前设计大纲**完全没有排错章**,是最大缺口。建议补一节"排错的工程化":为什么 Bedrock 补丁必须配 probe 脚本(`scripts/probe-bedrock-beta-fix.ts`)、为什么 DeepSeek 必须回显空 `reasoning_content`、`isFirstPartyAnthropicBaseUrl` TODO 为什么泄漏。措辞用"这个错误的根因是 Y 设计决策"。 + +### 2. 性能与内存 + +- 产品视角:第十章一笔带过即可。给"长会话变卡怎么办"的解决路径:`/compact` → `/force-snip` → 重启。RSS 数据用一句话引用。 +- 设计视角:第一、三、四章是深水区。给完整数据链(17MB → 1GB vs 220MB;`--version` RSS 966MB → 35MB;6,889 `_debugStack` Error 12MB;`performanceShim` 兜底)。讲清 JSC C++ Vector 永不收缩的根因。 + +### 3. 安全 + +- 产品视角:新增第十三章(当前完全缺失)。措辞用"我的密钥去了哪里"。覆盖凭证存储路径清单、OAuth 刷新窗口、`/share` / `/export` 隐私边界、跨工具凭证共享的隐私影响。 +- 设计视角:作为"反编译重建的安全约束"穿插在相关章节。措辞用"为什么这么存"。讲 `bypassPermissions` 在非 root/sandbox 的可用性检测、JWT 在 Bridge 的设计、`HasAppStateContext` 主动 throw 防嵌套的安全含义。 + +### 4. 升级与版本管理 + +- 产品视角:第十章的 `claude doctor` 子章节展开。给"我该怎么升级"工作流:`claude doctor` 版本检查 → `bun run update` → 重启。 +- 设计视角:第二章的"版本号单一来源 `package.json`"展开。讲 MACRO 三层注入、`scripts/probe-bedrock-beta-fix.ts` 作为"SDK 漏洞 probe 模式"的工程实践示范(如何检测上游 SDK 修复后安全删除针对性补丁)。 + +### 5. 与其他工具集成 + +- 产品视角:第八章(ACP/Bridge/IDE)+ 第十一章(GitHub Actions)。给"我能在 X 里用 Claude 吗"的清单式回答。 +- 设计视角:当前设计大纲**完全没有跨工具集成视角**,是第二大缺口。建议在第十二章(ACP/Bridge/Daemon)补一节"集成边界":acp-link 与 Codex CLI 凭证共享、`vscode-ide-bridge` 的协议设计、`install-github-app` / `subscribe-pr` / `commit-push-pr` 的工作流契约。 + +### 6. 可观测性 + +- 产品视角:第十章子章节。措辞用"我想知道 Claude 在做什么"。覆盖 Langfuse 追踪、`--dump-system-prompt`、`/debug-tool-call`、`BUN_INSPECT` 调试。 +- 设计视角:当前设计大纲仅第七章锚点提到 `claude.ts:2999`。建议补一节"观测的注入点":`recordLLMObservation` 的 `provider` 字段如何从 `getAPIProvider()` 取值、为什么 Langfuse 追踪必须用单一真相源、`performanceShim` 与 OTel 的耦合关系。 + +### 7. 凭证与认证生命周期 + +- 产品视角:第二章 + 第十三章交叉。措辞用"我的令牌怎么刷新、什么时候过期"。覆盖 OAuth 设备码、ChatGPT 订阅 5 分钟刷新偏差、China LLM 表单写入流程、`/login` 与 `/logout` 副作用、`/provider unset` 只清 Provider 不清 key。 +- 设计视角:在第七、八章穿插。措辞用"为什么 token 这样存"。讲模块级 client cache 的设计权衡(`getAnthropicClient` 参数化 vs `getOpenAIClient` 模块级缓存)、ChatGPT 订阅路径为何读 `~/.codex/auth.json`(与 Codex CLI 复用凭证的设计决策)、5 分钟刷新偏差窗口的容错考量。 + +--- + +## 下一步建议 + +### 建议先写的章节(价值最高) + +1. **产品第二章 + 第十章排错对照表**(含"我改了 API key 但没生效"与"为什么切了 Provider 没生效"两个高频困惑)—— 这是用户最高频的痛点,写完立竿见影降低 issue 量。 +2. **设计第一章(Code Splitting 是生存需求)+ 第三章(performanceShim)**—— 这两章是全书的叙事引擎,"为什么这么设计"的最戏剧性证据,先写好它们能定调整本书的好奇心基调。 +3. **交叉主题"安全"章(产品第十三章)**—— 当前两份大纲都完全缺失,是最显眼的空白;凭证存储、权限模式、OAuth 刷新一旦写清楚,能避免大量误用。 +4. **设计第七章(单一调度点)补 tools/filteredTools 不对称段 + 第九章(Usage 字段映射)新增**—— 这两段是"下游零分支"叙事的核心证据与唯一例外,写好了能让设计大纲的 Provider 章节真正立住。 +5. **产品第四章(slash 命令速查)按场景分类表**—— 用户最常翻的一章,写好就是一张长期参考表,ROI 极高。 + +### 会因图示或代码示例受益的章节 + +1. **设计第一章 Code Splitting**——RSS 数据柱状图(17MB 单文件 1GB / 切分后 35MB / Node 220MB)一张图胜千言。 +2. **设计第七/八章 Provider 调度点 + 流适配器**——一张调度流程图:消息归一化 → 工具过滤(tools vs filteredTools 分叉)→ 调度点 → 三条 Provider 路径(Anthropic 原生 / OpenAI/Grok 流适配器 / Gemini 流适配器)→ 统一 `contentBlocks` 累加器。 +3. **产品第十章 Provider 报错对照表 + 产品第十三章凭证存储**——前者是表格,后者是 `~/.claude/` 与 `~/.codex/` 的目录树图,直观显示哪些文件含密钥。 \ No newline at end of file diff --git a/docs/outline-output/README.md b/docs/outline-output/README.md new file mode 100644 index 000000000..07b396ec3 --- /dev/null +++ b/docs/outline-output/README.md @@ -0,0 +1,69 @@ +# Claude Code(反编译重建版)文档 + +本目录是基于 [`docs-outline-draft.md`](../../docs-outline-draft.md) 大纲生成的完整文档,分三个视角: + +- **[user/](./user/)** — 产品文档(使用者视角):按"安装 → 配置 → 日常 → 进阶 → 排错"用户旅程组织 +- **[design/](./design/)** — 开发者设计探秘:按"被约束逼出的决策链"组织,每章回答"为什么这么设计" +- **[cross/](./cross/)** — 交叉主题:两个视角都需要覆盖的横切主题 + +--- + +## 第一部分:产品文档(user/) + +| # | 章节 | 文件 | +|---|------|------| +| 1 | 从零开始 —— 安装、首次启动与环境要求 | [01-installation.md](./user/01-installation.md) | +| 2 | 让 Claude 听你的 —— 配置 Provider 与模型 | [02-providers.md](./user/02-providers.md) | +| 3 | 日常对话 —— 交互式 REPL 怎么用 | [03-repl-daily.md](./user/03-repl-daily.md) | +| 4 | slash 命令速查 —— 按场景找 | [04-slash-commands.md](./user/04-slash-commands.md) | +| 5 | 扩展 Claude 的能力 —— MCP、插件、Skill | [05-mcp-plugins-skills.md](./user/05-mcp-plugins-skills.md) | +| 6 | 让 Claude 跑大任务 —— 子代理、Plan、Task | [06-agents-plan-tasks.md](./user/06-agents-plan-tasks.md) | +| 7 | 让 Claude 长时间干活 —— Daemon、BG、Schedule | [07-daemon-bg-schedule.md](./user/07-daemon-bg-schedule.md) | +| 8 | 跨机器与团队协作 —— Bridge、RCS、ACP | [08-bridge-rcs-acp.md](./user/08-bridge-rcs-acp.md) | +| 9 | 省钱、提速、定制 —— 穷鬼模式、Hooks、配置 | [09-budget-hooks-config.md](./user/09-budget-hooks-config.md) | +| 10 | 可观测性与排错 —— 卡住了怎么办 | [10-observability-troubleshooting.md](./user/10-observability-troubleshooting.md) | +| 11 | 自动化与 CI 集成 —— 嵌入流水线 | [11-ci-integration.md](./user/11-ci-integration.md) | +| 12 | 进阶实验性能力与社区生态 | [12-experimental-community.md](./user/12-experimental-community.md) | +| 13 | 安全 —— 凭证、权限、刷新、共享 | [13-security.md](./user/13-security.md) | + +## 第二部分:开发者设计探秘(design/) + +| # | 章节 | 文件 | +|---|------|------| +| 0 | 序章:被反编译重建的 CLI 处处是"约束的印记" | [00-prologue.md](./design/00-prologue.md) | +| 1 | Code Splitting 不是优化,是生存需求 | [01-code-splitting.md](./design/01-code-splitting.md) | +| 2 | 入口 Fast-Path 优先级链 —— --version 零模块加载 | [02-fast-path.md](./design/02-fast-path.md) | +| 3 | performanceShim —— JSC 内存泄漏的运行时补丁 | [03-performance-shim.md](./design/03-performance-shim.md) | +| 4 | 核心 Query Loop —— 为什么 query() 是 async generator | [04-query-loop.md](./design/04-query-loop.md) | +| 5 | Feature Flag 系统的三个硬约束 | [05-feature-flags.md](./design/05-feature-flags.md) | +| 6 | 工具系统的延迟加载与 CORE_TOOLS 白名单 | [06-tools-deferred.md](./design/06-tools-deferred.md) | +| 7 | 7-Provider 抽象层的单一调度点 | [07-provider-dispatch.md](./design/07-provider-dispatch.md) | +| 8 | 流适配器 —— OpenAI/Gemini/Grok 假装是 Anthropic | [08-stream-adapters.md](./design/08-stream-adapters.md) | +| 9 | Usage 字段映射与模型映射的优先级链 | [09-usage-mapping.md](./design/09-usage-mapping.md) | +| 10 | 自研 Fork 的 Ink 框架 —— 为什么不是 src/ink/ | [10-ink-framework.md](./design/10-ink-framework.md) | +| 11 | 三层状态管理 —— bootstrap/state.ts 警告 "DO NOT ADD MORE" | [11-state-management.md](./design/11-state-management.md) | +| 12 | ACP / Bridge / Daemon —— 三个长驻模式的接线 | [12-acp-bridge-daemon.md](./design/12-acp-bridge-daemon.md) | +| 13 | CLAUDE.md 四层层级与 @include 指令 | [13-claudemd.md](./design/13-claudemd.md) | +| 14 | 测试策略 —— 为什么 mock 必须从底层 HTTP 开始 | [14-testing-strategy.md](./design/14-testing-strategy.md) | +| 15 | biome.json 的 42 条规则关闭 —— 反编译产物的指纹 | [15-biome-config.md](./design/15-biome-config.md) | +| 16 | 尾声:哪些坑我们没踩 —— 读者可继续挖掘的方向 | [16-epilogue.md](./design/16-epilogue.md) | + +## 第三部分:交叉主题(cross/) + +| # | 主题 | 文件 | +|---|------|------| +| 1 | 排错与错误对照 | [01-troubleshooting.md](./cross/01-troubleshooting.md) | +| 2 | 性能与内存 | [02-performance-memory.md](./cross/02-performance-memory.md) | +| 3 | 安全 | [03-security.md](./cross/03-security.md) | +| 4 | 升级与版本管理 | [04-upgrade-versioning.md](./cross/04-upgrade-versioning.md) | +| 5 | 与其他工具集成 | [05-tool-integration.md](./cross/05-tool-integration.md) | +| 6 | 可观测性 | [06-observability.md](./cross/06-observability.md) | +| 7 | 凭证与认证生命周期 | [07-credentials-auth.md](./cross/07-credentials-auth.md) | + +--- + +## 阅读建议 + +- **想用工具**:直接看 [user/](./user/),从 [01-installation.md](./user/01-installation.md) 开始 +- **想理解架构**:从 [design/00-prologue.md](./design/00-prologue.md) 序章开始 +- **遇到问题**:先看 [cross/01-troubleshooting.md](./cross/01-troubleshooting.md) 排错对照表 diff --git a/docs/outline-output/cross/01-troubleshooting.md b/docs/outline-output/cross/01-troubleshooting.md new file mode 100644 index 000000000..4f1d29c96 --- /dev/null +++ b/docs/outline-output/cross/01-troubleshooting.md @@ -0,0 +1,230 @@ +# 排错与错误对照 + +> 同一条 429 在使用者眼里是"我流量打太多了吗?",在开发者眼里是"响应头里那串 `x-ratelimit-*` 该被哪个适配器解析";同一份 Bedrock 400 在使用者眼里是"为什么 Opus 4.7 调不通",在开发者眼里是"SDK 0.28.1 那个 `anthropic_beta` 体重植漏洞还要打补丁打多久"。排错天生是双视角主题,所以单独成章。 + +## 产品视角(写给使用者) + +这一节回答两个问题:**当 Claude 报错时第一步该做什么**,以及**看到具体错误码该怎么自救**。读完之后,你不需要去翻源码,就能把九成的常见问题处理掉。 + +### 第一步永远先跑两条命令 + +当 Claude 报错、卡住、行为异常时,按下面顺序排查。两条命令分工很明确: + +- `claude doctor` —— 一张屏幕显示版本信息(含远端 npm/GCS 上的 stable 与 latest 版本号)、配置文件路径、settings 校验错误、keybindings 警告、MCP 解析警告、沙箱状态、安装锁文件状态。它的源码在 `src/screens/Doctor.tsx`(命令注册在 `src/commands/doctor/doctor.tsx`),相当于一次"全身体检"。 +- `bun run health` —— 跑 `scripts/health-check.ts`,更偏工程化自检(依赖完整性、构建产物完整性等)。开发模式下比 `claude doctor` 更底层,适合"刚 clone 下来跑不起来"的场景。 + +90% 的"莫名其妙不工作"在这两条命令的输出里都能看到线索——版本落后、settings.json 写错字段、keybindings 语法错、MCP 配置文件 JSON 解析失败。**先看这两条输出再问别人**,能省掉一大半来回。 + +### Provider 报错对照表 + +下面这张表覆盖最常见的 API 报错。Provider 切换方式详见产品第二章;这里只讲"切完之后出错了怎么办"。 + +| HTTP 状态 / 错误类型 | 含义 | 用户侧怎么办 | +| --- | --- | --- | +| **401**(`authentication_error`) | API key 无效或已过期 | 跑 `/login` 重新登录;OpenAI 兼容层检查 `OPENAI_API_KEY`,Anthropic 直连检查 OAuth 令牌或 `ANTHROPIC_API_KEY`。**注意**:OpenAI/Grok 客户端是会话级缓存的(详见下文"我改了 key 但没生效") | +| **403** | 地区限制 / 权限不足 | 中国大陆直连 Anthropic 通常会 403;用 OpenAI 兼容层(DeepSeek / 智谱 / 通义 / Moonshot 等)或 Bedrock / Vertex 中转 | +| **429** | 限流 | 看状态栏的限流指示;如果用 Claude.ai 订阅,可跑 `/rate-limit-options` 看升级 / 加包选项;OpenAI 兼容层会自动解析 `x-ratelimit-*` 响应头展示在 `/usage` 里 | +| **529 / `"type":"overloaded_error"`** | 上游服务过载 | 稍等几秒重试。如果开了 fast mode(`/fast`),系统会自动切回标准模型并进入冷却期,状态栏会写 "Fast mode overloaded and is temporarily unavailable · resets in N" | +| **模型不存在** | Provider 不认识你传的模型名 | 检查环境变量:OpenAI 看 `OPENAI_MODEL`,Gemini 看 `GEMINI_MODEL` 或 `GEMINI_DEFAULT_{HAIKU|SONNET|OPUS}_MODEL`,Grok 看 `XAI_API_KEY` / `GROK_*`。Gemini 缺配置时会**直接抛异常**,不会静默回退 | +| **`max_output_tokens` 扣留** | 单轮输出超过模型上限 | 系统会自动最多重试 3 次(源码常量 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3`,见 `src/query.ts:194`);如果三轮还没收敛,本轮会以 `apiError === 'max_output_tokens'` 的 assistant 消息结束 | + +`claude.ts` 把 `error.status === 529` 和消息体里包含 `"type":"overloaded_error"` 的情况都归到 `server_overload`(见 `src/services/api/errors.ts:1004-1011`),所以同一个上游过载事件,不管是用 HTTP 状态码表达还是用错误体表达,对用户而言是同一件事——稍等重试。 + +### 兼容层特有坑(OpenAI / Gemini / Grok) + +下面这些是兼容层才会遇到的,Anthropic 直连不会出现: + +- **我改了 API key 但没生效** —— 这是兼容层最高频的坑。`getOpenAIClient()`(`src/services/api/openai/client.ts:39`)和 Grok 客户端(`src/services/api/grok/client.ts`)都会把首次创建的客户端实例缓存到模块级变量(`cachedClient`,见 `openai/client.ts:15`)。中途改 `OPENAI_API_KEY` 环境变量不会让客户端重建。**解决办法**:重启 CLI;如果你在自己写脚本嵌入 Claude,必须显式调用 `clearOpenAIClientCache()`(`openai/client.ts:76`)清缓存。 +- **DeepSeek / 自托管模型报 400** —— DeepSeek 思维模式(`deepseek-reasoner`)会返回 `reasoning_content` 字段。把它原样回传给非思维模型变体会被服务端拒绝。系统在 `src/services/providerRegistry/providerCompatMatrix.ts` 里维护了一张兼容矩阵:`strip` 模式(Cerebras / Groq / strict-openai)总是剥掉 `reasoning_content`;`drop-on-non-thinking`(permissive)只在模型名匹配 `/reason|think/i` 时才保留;只有 DeepSeek 自己走 `always-preserve`。如果你用的是 DeepSeek 自托管端点且模型名不含 `reason` / `think` 字样,要么改模型名让正则命中,要么用 `permissive` 兼容规则。 +- **Bedrock Opus 4.7 报 400 `invalid beta flag`** —— 这是 `@anthropic-ai/bedrock-sdk` 0.26.4–0.28.1 的已知漏洞:SDK 把 `anthropic-beta` HTTP 头的值重植到请求体里成为 `anthropic_beta`,Bedrock 的 Opus 4.7 端点会拒绝任何带 `anthropic_beta` 体的请求。Claude Code 通过自定义 `BedrockClient` 类(`src/services/api/bedrockClient.ts`)在签名前剥离 `body.anthropic_beta` 解决。**普通用户不需要做什么**——这个补丁默认就生效。 +- **Gemini 报"requires GEMINI_MODEL"** —— Gemini 是唯一在模型映射全失败时**硬抛异常**的 Provider(`packages/@ant/model-provider/src/providers/gemini/modelMapping.ts:32`)。其它 Provider 找不到映射就原样返回模型名,Gemini 不行。看到这条报错就设一下 `GEMINI_MODEL` 或 `GEMINI_DEFAULT_SONNET_MODEL`(取决于你的家族)。 +- **限流信息看不到** —— OpenAI 兼容层的限流是从响应头 `x-ratelimit-remaining-requests` / `x-ratelimit-remaining-tokens` / `x-ratelimit-reset-*` 解析出来的(`src/services/providerUsage/adapters/openai.ts:62`)。如果你用的自托管端点不返回这些头,状态栏就拿不到限流信息——这不是 bug,是端点没实现。`/usage` 命令会展示已知 bucket。 + +### MCP 连不上的排查清单 + +MCP server 报"连接失败"时按下面顺序查: + +1. **stdio 类型**:命令路径对不对、参数对不对、本地能否手动跑起来。 +2. **SSE / HTTP 类型**:URL 能否 curl 通、是否需要 token、是否在 `claude mcp list` 里显示为已连接。 +3. **OAuth 失败**:跑 `/mcp-auth` 重新走授权流程。 +4. **MCP 配置文件 JSON 解析错误**:`claude doctor` 会显示 `MCP parsing warnings`,直接定位到具体文件和行号。 +5. **权限被拒**:检查 `/permissions` 里是否把工具 deny 掉了;deferred tool(不在 `CORE_TOOLS` 白名单里)需要通过 `SearchExtraTools` 按需加载。 + +### 长会话变卡怎么办 + +长会话内存膨胀有两类来源,处理方式不同: + +- **上下文太长** —— 跑 `/compact` 自动压缩;还不行就 `/force-snip` 强制剪裁历史;最彻底的是 `/clear` 重开。 +- **JSC 内存累积** —— 即使上下文压缩了,进程 RSS 也可能不下降。这是 JavaScriptCore 的已知特性(详见下文设计视角与设计第三章)。最快的解法是退出 CLI 重开。后台长跑场景(`/loop` / daemon)这个坑会更明显。 + +### 我想看看 Claude 到底在做什么 + +下面这几条命令按"侵入性"从低到高排: + +- `claude --dump-system-prompt` —— 把当前会话渲染出的完整 system prompt 打到 stdout(需要 build 时启用 `DUMP_SYSTEM_PROMPT` feature,见 `src/entrypoints/cli.tsx:90`)。排查"为什么 Claude 不按 CLAUDE.md 行事"时最有用。 +- `/debug-tool-call` —— 读取最近一次工具调用的请求 / 响应明细,源码在 `src/commands/debug-tool-call/index.ts`。 +- `BUN_INSPECT=9229 bun run dev:inspect` —— 把 Bun 调试器挂在 9229 端口,用 Chrome DevTools 连进去打断点。这是最重的手段,但对"卡死但没报错"类问题非常有效。 +- Langfuse 追踪 —— 如果你的部署启用了 Langfuse(详见 `docs/features/tools/langfuse-monitoring.md`),每次 API 调用都会被记录为一个 observation,包含模型名、Provider、token 用量、输入输出消息。 + +### 反馈与上报 bug + +- `/feedback` —— 弹出反馈表单,源码 `src/commands/feedback/feedback.tsx`。 +- `/perf-issue` —— 性能问题专用通道,源码 `src/commands/perf-issue/index.ts`。 +- `/bughunter` —— 实验性 bug 自动归因工具(隐藏命令)。 + +## 设计视角(写给开发者) + +设计大纲原本没有排错章——这是最大的缺口。补这一节是因为排错本身就是"被约束逼出来的工程化"的最好案例:每一个看似奇怪的兼容代码、每一条 TODO、每一个 probe 脚本,背后都对应着一个用户会碰到的具体错误。这一节按"这个错误的根因是 Y 设计决策"的思路展开。 + +### 为什么 Bedrock 补丁必须配 probe 脚本 + +打开 `src/services/api/bedrockClient.ts`,你会看到一个看起来有点啰嗦的类继承: + +```ts +export class BedrockClient extends AnthropicBedrock { + async buildRequest(options: BuildRequestArg): Promise { + const req = await super.buildRequest(options) + // ... 解析 inner.body,删掉 parsed.anthropic_beta,重写 content-length + return req + } +} +``` + +这个类的唯一作用是:**让 SDK 把请求构造完,然后在它签名之前把 `anthropic_beta` 从请求体里删掉**。注释(`bedrockClient.ts:1-25`)写得极其详尽——直接点名了 SDK 的具体文件和行号(`packages/bedrock-sdk/src/client.ts:193-198`)、相关 issue(`anthropics/claude-code#49238`,2026-04-16 提出)、漏洞版本范围(0.26.4 至少到 0.28.1)。 + +为什么不直接给上游提 PR?因为上游修了之后,这段兼容代码也必须能被安全删除。注释最后一段写明了删除流程: + +> When upstream ships a fix, verify the probe in scripts/probe-bedrock-beta-fix.ts shows "bug reproduced: false", then delete this class and change services/api/client.ts to instantiate AnthropicBedrock directly. + +`scripts/probe-bedrock-beta-fix.ts` 这个文件在源码注释里被点名引用,目的是"装个探针,等上游修了就跑一下,确认 false 就删类"。这是一种"针对性补丁 + 自动退役"的工程范式——和一般补丁的区别在于它**自带退役机制**:probe 脚本本身就是"这个补丁该不该继续存在"的判据。 + +> **诚实核对**:注释里点名的 `scripts/probe-bedrock-beta-fix.ts` 目前在仓库里**找不到**(仓库里现存的 probe 脚本是 `scripts/probe-local-wiring.ts` 和 `scripts/probe-subscription-endpoints.ts`)。这意味着这个"自动退役机制"目前只是注释里的口头约定,并没有真的自动化。这是反编译重建工作的一个典型痕迹:原版可能有这个脚本,重建时没还原。 + +### 为什么 DeepSeek 必须把 reasoning_content 分三种模式处理 + +DeepSeek 的思维模型(`deepseek-reasoner`)会在 assistant 消息里返回 `reasoning_content` 字段。但同样一个字段,对三个不同的接收端会触发完全不同的行为: + +- **DeepSeek 自己**:期望被原样回传(`always-preserve`)。 +- **Cerebras / Groq / 标准 OpenAI 协议端点**:拒绝任何非标准字段(`strip`)。 +- **permissive 端点(非 DeepSeek)**:思维模型变体可以保留,非思维变体会拒绝(`drop-on-non-thinking`,靠模型名正则 `/reason|think/i` 判断)。 + +这套规则定义在 `src/services/providerRegistry/providerCompatMatrix.ts:43-76` 的 `COMPAT_PROFILES` 表里,由 `applyCompatRule`(同文件 `:104`)实施。打开 `getDeepSeekReasoningMode`(`:86`)你能看到三种模式的判定:`thinking-only`(有 reasoning_content 无 tool_calls)、`thinking+tools`(两者都有)、`normal`(都没有)。 + +**根因**:DeepSeek 的 API 把"模型上一轮想了什么"塞回 `reasoning_content` 字段,期望客户端在下一次请求里回传。但标准 OpenAI 协议没有这个字段,严格端点(Cerebras / Qwen)会直接 400。所以兼容矩阵本质上是一张"哪些端点容忍哪些非标准字段"的合约表——这是"多 Provider 兼容"工程化的必然产物。 + +反事实推演:如果只写一种策略(比如永远 strip),DeepSeek 思维模式就彻底用不了;如果只写 always-preserve,严格端点全炸。三种模式是兼容性 / 功能性的最小必要切分。 + +### 为什么 isFirstPartyAnthropicBaseUrl 的 TODO 是个真陷阱 + +打开 `src/utils/model/providers.ts:43`: + +```ts +export function isFirstPartyAnthropicBaseUrl(): boolean { + const baseUrl = process.env.ANTHROPIC_BASE_URL + // TODO: 这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题 + if (!baseUrl) { + return true + } + // ... 检查 host 是否为 api.anthropic.com +} +``` + +这条 TODO 的含义是:**如果用户只配了 OpenAI 兼容层(`CLAUDE_CODE_USE_OPENAI=1` + `OPENAI_BASE_URL=...`),但没有配 `ANTHROPIC_BASE_URL`,那么这个函数返回 `true`**。也就是说系统会误以为"现在是 Anthropic 直连模式",从而触发一些只该在 firstParty 模式下才生效的行为。 + +这个函数在 `src/services/api/client.ts:367`(`buildFetch`)被用来决定是否注入 `x-client-request-id` 头。注释(`client.ts:365`)写得很谨慎:"Only send to the first-party API — Bedrock/Vertex/Foundry don't log it and unknown headers risk rejection by strict proxies (inc-4029 class)." + +**根因**:函数判定的输入只有 `ANTHROPIC_BASE_URL` 一个变量,但"用户在用哪家 Provider"实际上由 `getAPIProvider()`(同文件 `:15`)综合 `modelType` / `CLAUDE_CODE_USE_*` 环境变量决定。两个判定来源脱节就会导致 firstParty 行为泄漏到兼容层场景。 + +修复方向(TODO 没明说,但隐含)是把判定改成"先看 `getAPIProvider()` 是不是 `firstParty`,再看 base URL 是不是 anthropic 域"。但这是一个**有副作用的改动**——会改变 firstParty 路径下注入 header 的行为,需要回归测试,所以至今挂在 TODO 上。 + +### 为什么 OpenAI 客户端是模块级缓存,而 Anthropic 客户端不是 + +对比两个客户端工厂函数: + +| | Anthropic | OpenAI | Grok | +| --- | --- | --- | --- | +| 入口 | `getAnthropicClient`(`client.ts:84`) | `getOpenAIClient`(`openai/client.ts:39`) | `getGrokClient`(`grok/client.ts`) | +| 缓存 | 不缓存,每次按 model / region 参数化新建 | 模块级 `cachedClient` 单例 | 模块级单例 | +| 改 key 后果 | 下次调用立刻生效 | 必须重启或 `clearOpenAIClientCache()` | 必须重启 | + +为什么设计不一致?看 `client.ts:153-298` 就明白了:Anthropic 路径每次构造客户端时要做 AWS / GCP / Azure 凭证刷新、按模型选 region、注入几十个 header——这些都是**会话过程中可能变化的参数**,所以必须每次重新构造。OpenAI / Grok 路径简单得多:一个 key、一个 base URL,理论上整个会话都不变,所以缓存能省掉重复初始化的开销。 + +代价就是"改 key 不生效"这个高频用户困惑。`clearOpenAIClientCache`(`openai/client.ts:76`)是项目给用户留的逃生口——但这要求用户**知道这个函数存在**,对一般使用者完全不可见。这是"性能 vs 可调试性"的典型权衡。 + +### 为什么错误归类要绕一圈通过错误消息字符串匹配 + +打开 `src/services/api/errors.ts:1004-1011`,你会看到这种判定: + +```ts +if ( + error instanceof APIError && + (error.status === 529 || + error.message?.includes('"type":"overloaded_error"')) +) { + return 'server_overload' +} +``` + +为什么不光看 `status === 529`,还要扫消息文本?因为 Anthropic API 在某些路径下会用其它状态码(比如 503)配 `"type":"overloaded_error"` 错误体表达同一个"上游过载"事件。SDK 的 `APIError` 不一定把错误类型暴露成结构化字段,错误体只能从 `message` 里捞。 + +`withRetry.ts:612-616` 和 `:716-720` 用同样的字符串匹配判定 529 / overloaded。这种基于字符串的错误匹配**天然脆弱**——上游改一个字段名整个判定就失效。但目前没有更好的方案:上游 SDK 的错误类型抽象不够细,自己重写又会让兼容层耦合到具体 SDK 版本。这是"用 SDK 但 SDK 抽象不到位"的典型代价。 + +### 为什么 performanceShim 必须最先 import + +打开 `src/entrypoints/cli.tsx:5`: + +```ts +// Performance shim MUST be the first import — it replaces globalThis.performance +// with a JS-backed implementation before React/OTel capture the native reference. +import '../utils/performanceShim.js'; +``` + +注释里的"MUST be the first import"不是审美,而是**顺序依赖**。`src/utils/performanceShim.ts:1-17` 解释了原因:JSC 原生的 `performance` 对象把 marks / measures / resource timings 存进一个永不收缩的 C++ Vector。长会话(daemon、`/loop`)会累积几百 MB 的死容量。 + +shim 做的事是:保留 `performance.now()` 走原生(快、不占内存),但把 `mark` / `measure` / `getEntries` 重定向到 GC 可回收的 JS Map。**为什么必须最先 import**:因为 React reconciler 和 OTel / Langfuse 客户端会**捕获 `globalThis.performance` 的引用**。一旦它们拿到原生引用,shim 再装上也没用——它们调用的是自己缓存的原生对象。 + +`src/query.ts:367-380` 在每次 query 的 finally 块里调用 `gPerf.clearMarks()` / `clearMeasures()` / `clearResourceTimings()`,作为兜底——防止某些 sub-agent 路径直接 `import query` 而 shim 没装上的情况。这是一个"shim 没生效时的保险栓"。 + +**这条和排错的交集**:用户报告"长会话越用越卡,RSS 涨到 1GB"时,根因往往就是某个 import 路径绕过了 shim、或者某个第三方库缓存了原生 performance 引用。排查方向是去看最近一次新增的依赖有没有在顶层捕获 performance。 + +### 为什么 Langfuse 追踪必须从 getAPIProvider() 取 provider + +打开 `src/services/api/claude.ts:2997`: + +```ts +recordLLMObservation(options.langfuseTrace ?? null, { + model: resolvedModel, + provider: getAPIProvider(), + // ... +}) +``` + +`provider` 字段直接调 `getAPIProvider()`(`src/utils/model/providers.ts:15`)取值——不读缓存、不信变量、单一真相源。**为什么这么严格**:Langfuse 上游的报表按 Provider 分组聚合(openai / gemini / grok / firstParty / bedrock / vertex / foundry)。如果不同代码路径用了不同的 Provider 判定(比如有的读 `CLAUDE_CODE_USE_OPENAI`、有的读 `settings.modelType`),同一类请求会被分到不同桶,统计就废了。 + +`getAPIProvider()` 把判定逻辑收敛到一处:先看 `modelType`,再看 `CLAUDE_CODE_USE_*` 环境变量,最后默认 `firstParty`。**任何**想读"当前在用哪家 Provider"的代码——`/provider` 命令、Langfuse 观测、模型映射——都必须走这个函数。这是"单一真相源"原则的硬执行。 + +### 为什么 errors.ts 要写 1000+ 行 + +`src/services/api/errors.ts` 是一个超过 1000 行的文件,里面几乎全是错误归类逻辑(`return 'rate_limit'` / `return 'server_overload'` / `return 'prompt_too_long'` ...)。为什么错误归类要写这么多? + +因为每一个归类结果都对应**不同的用户提示 / 不同的重试策略 / 不同的 UI 反馈**: + +- `rate_limit` → 展示剩余配额、提示升级 +- `server_overload` → 静默重试 + cooldown +- `prompt_too_long` → 提示用户 `/compact` +- `pdf_too_large` → 提示用户拆分 PDF + +而归类的输入五花八门:HTTP 状态码、错误消息字符串、SDK 错误类型、自定义 off-switch 消息(见 `errors.ts:991-997`)。同一个"上游过载"语义可以用 `status === 529`、`status === 503 + overloaded_error`、甚至 emergency off-switch 消息表达。把所有这些判定集中到一个文件,是**避免错误处理碎片化**的工程实践——否则每个调用点都得自己写一遍字符串匹配,必然漂移。 + +## 两视角如何呼应 + +用户视角的痛点几乎都能在设计视角找到对应的设计决策: + +- **"我改了 API key 但没生效"**(产品视角)对应**"OpenAI/Grok 客户端为什么是模块级缓存"**(设计视角)——这是性能优化带来的副作用。设计视角给出逃生口 `clearOpenAIClientCache`,但这个逃生口对一般用户不可见,所以产品视角必须明说"重启 CLI"。 +- **"Bedrock Opus 4.7 报 400"**(产品视角)对应**"为什么 Bedrock 补丁必须配 probe 脚本"**(设计视角)——补丁默认就生效,用户什么都不用做;但 probe 脚本的缺失是反编译重建的诚实边界。 +- **"Gemini 报 requires GEMINI_MODEL"**(产品视角)对应**"Gemini 为什么在映射全失败时硬抛异常"**(设计视角)——这是 Gemini Provider 唯一不静默回退的设计选择,产品视角必须把"必须配置环境变量"讲清楚。 +- **"长会话越用越卡"**(产品视角)对应**"performanceShim 必须最先 import"**(设计视角)——用户看到的是 RSS 上涨,根因在 JSC C++ Vector 永不收缩。 +- **"529 / overloaded 怎么处理"**(产品视角)对应**"为什么错误归类要绕一圈通过字符串匹配"**(设计视角)——用户只需要知道"稍等重试",开发者必须理解字符串匹配的脆弱性。 +- **"Langfuse 里 Provider 分桶不对"**(产品视角)对应**"为什么 provider 字段必须从 getAPIProvider() 取"**(设计视角)——单一真相源是统计正确性的前提。 + +这种呼应关系是排错章必须双视角覆盖的核心原因:用户视角告诉你**遇到这个错误怎么办**,设计视角告诉你**为什么会有这个错误**。两个视角合在一起,才能让使用者和维护者用同一套词汇对话。 diff --git a/docs/outline-output/cross/02-performance-memory.md b/docs/outline-output/cross/02-performance-memory.md new file mode 100644 index 000000000..7b1e51e12 --- /dev/null +++ b/docs/outline-output/cross/02-performance-memory.md @@ -0,0 +1,207 @@ +# 性能与内存 + +> 同一个"长会话越用越卡"在使用者眼里是"我该怎么压上下文",在开发者眼里是"JavaScriptCore 的 C++ Vector 为什么永不收缩"。性能与内存是双视角主题里因果链最长的一个:用户能观察到的每一个 RSS 数字、每一次"重启就好",背后都对应着一条具体的运行时约束或一段反编译留下的工程妥协。 + +## 产品视角(写给使用者) + +这一节回答两个问题:**日常用着用着变卡了怎么办**,以及**怎么从一开始就把内存预算控制住**。读完之后你不需要去看源码,就能把九成长会话性能问题处理掉。 + +### 先分清两类"卡" + +长会话变慢几乎总是下面两类原因之一,处理方式完全不同: + +- **上下文太长** —— 每一轮对话都把历史消息塞进 prompt,模型推理时间和 token 账单随上下文线性增长。这种"卡"是**可逆的**:压一下上下文,立刻就快。 +- **进程内存累积** —— 即使上下文压缩了,进程的 RSS(常驻内存)也可能不下降。这种"卡"是**渐进的**:压缩上下文救不了,最快的解法是退出 CLI 重开。 + +判断方式:跑 `/compact` 之后看响应速度。如果明显变快,说明是上下文问题;如果还是慢、状态栏或 `ps aux | grep claude` 看到的 RSS 数字还在涨,就是内存累积问题。 + +### 上下文变长的三条解法,从轻到重 + +按下面顺序试,越往下越彻底: + +1. **`/compact`** —— 让 Claude 用一个小模型把历史对话总结成一段摘要,再用摘要替换原始消息。源码在 `src/commands/compact/compact.ts`。它会先尝试 session memory 压缩(保留结构化记忆),失败再走通用压缩模型。带自定义指令也行:`/compact 只保留与测试相关的部分`。 +2. **`/force-snip`** —— 直接在消息数组里插一条 `snip_boundary` 系统消息,把当前位置之前的历史标记为"已剪裁"。下一次 query 时 `snipCompactIfNeeded` 会把这些消息从模型视角下移除,但 REPL 里依然能看到完整滚动历史。源码在 `src/commands/force-snip.ts:18`。比 `/compact` 更暴力:不总结、直接砍。 +3. **`/clear`** —— 整个会话清空重开。源码在 `src/commands/clear/`。 + +日常推荐顺序是 `/compact` → `/force-snip` → `/clear`。`/force-snip` 适合"前面那段讨论已经跑偏了,我想从干净状态继续"的场景。 + +### 自动 compact 什么时候触发 + +系统会在上下文接近模型窗口上限时自动触发 compact,不需要你手动盯。如果你发现自动触发太频繁(每次刚聊几句就被压缩),说明你的 CLAUDE.md 或工具调用本身就在贡献大量上下文——可以跑 `/context` 或 `/ctx_viz` 看看上下文都被什么占满了。 + +### 长跑场景特别留意:daemon、/loop、容器 + +短会话几乎不会撞上内存累积问题,但下面这些长跑场景会: + +- **`/loop`** —— 每 N 分钟自动跑一次任务,进程常驻。 +- **daemon 模式** —— `claude daemon start` 启动的长驻 supervisor + worker。 +- **容器 / CI** —— `CLAUDE_CODE_REMOTE=true` 时,`cli.tsx:44-49` 会自动给子进程注入 `--max-old-space-size=8192`(前提是容器有 16GB)。这是项目对容器环境的硬编码假设:你的容器至少要有 8GB 余量给 Node.js 堆。 + +在长跑场景下,建议每隔几小时主动重启一次进程,或者把任务拆成多次独立会话而不是一条无限循环。 + +### 我想知道 Claude 现在吃了多少内存 + +- macOS / Linux:`ps aux | grep claude`,看 RSS 列(单位 KB)。 +- daemon / background session:`claude ps` 看进程列表,`claude logs` 看输出。 +- 性能问题专用反馈通道:`/perf-issue`(源码 `src/commands/perf-issue/`)。 + +### 为什么有时候重启 CLI 是唯一解 + +如果压缩了上下文、清了消息,进程 RSS 还是下不去,这是 JavaScriptCore(Bun 的 JS 引擎)的已知特性:某些内部缓冲区一旦分配就不再收缩。详细原因见下面的设计视角。**用户侧能做的就是退出重开**——这不是 bug,是运行时的硬约束。 + +## 设计视角(写给开发者) + +设计大纲里性能主题分布在第一、三、四章,是全书最深的几章。这一节把数据链串起来讲:从 17MB 单文件的灾难,到 `performanceShim` 的运行时补丁,到 6,889 个 `_debugStack` 的"看不见的内存",再到 `cli.tsx:48` 那条看似随意的 `--max-old-space-size` 注入。 + +### JSC 的贪婪解析:17MB 单文件为什么能让 RSS 涨到 1GB + +这是全书最戏剧性的设计动机。打开 `vite.config.ts:94-102`: + +```ts +output: { + format: 'es', + // 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. + entryFileNames: 'cli.js', + chunkFileNames: 'chunks/[name]-[hash].js', +}, +``` + +JavaScriptCore(Bun 用的 JS 引擎)和 V8(Node.js 用的)在解析策略上有根本差异:**JSC 全量解析 + 全量 JIT**,V8 懒解析。同样一份 17MB 的单文件 bundle,JSC 会把整份 bytecode 和 JIT 编译结果一次性吃进内存,RSS 直接冲到 ~1GB;V8 只在函数被调用时才解析,RSS 只要 ~220MB。 + +CLAUDE.md 里记录的实测数据更细:单文件 17MB 产物导致 RSS 暴涨至 ~1GB;切成 600+ chunks 后,Bun 按需加载,`--version` 的 RSS 从 966MB 骤降到 35MB,完整加载从 1GB+ 降到 ~500MB。 + +**为什么 Vite 必须代码分割而不是单文件**——这不是性能优化,是**生存需求**。Bun.build(`build.ts:23` 的 `splitting: true`)和 Vite(`vite.config.ts:94` 的 `chunkFileNames: 'chunks/[name]-[hash].js'`)两条构建管线都默认走代码分割,原因就是这条。 + +`scripts/post-build.ts` 还要在分割后做两件事:(1) 把 `import.meta.require` 替换成 Node.js 兼容的 `createRequire` 探测,让产物同时能在 bun 和 node 上跑;(2) patch 掉第三方依赖(`@anthropic-ai/sandbox-runtime`)里未受保护的 `var { ... } = globalThis.Bun` 解构——否则在 Node.js 启动会崩。这两步都是"代码分割 + 双运行时兼容"的下游工程代价。 + +### performanceShim:JSC 原生 Performance 的 C++ Vector 永不收缩 + +打开 `src/utils/performanceShim.ts:1-17`,文件头注释直接写明了根因: + +> In Bun, globalThis.performance is JSC's native Performance object. It stores marks, measures, and resource timings in a C++ Vector that never shrinks even after clearMarks(). Long-running sessions (daemon, /loop) accumulate hundreds of MB of dead capacity. + +JSC 的原生 `performance` 对象把 `mark()` / `measure()` / resource timings 存进一个 C++ Vector,这个 Vector **只增不减**——即使你调 `clearMarks()`,C++ 那头的容量也不会释放。React reconciler 和 OpenTelemetry / Langfuse 客户端都会反复调用 `mark` / `measure` 做时间打点,长会话里这些死容量能累积几百 MB。 + +shim 做的事(`performanceShim.ts:19-155`)很克制: + +- **`performance.now()` 继续走原生**(`performanceShim.ts:28-30`)—— 高频调用、不占内存,没必要劫持。 +- **`mark` / `measure` / `getEntries*` 重定向到 GC 可回收的 JS Map**(`performanceShim.ts:22-26` 的 `marks` / `measures`)—— Map 是普通 JS 对象,GC 能正常回收。 +- **不继承 Performance.prototype**(`performanceShim.ts:124-126`)—— 因为原生 getter(`timeOrigin` / `onresourcetimingbufferfull` / `toJSON`)会检查 `this` 是不是真正的 JSC Performance 实例,继承就抛错。 +- **提供 `markResourceTiming` 空函数**(`performanceShim.ts:140`)—— Node.js v22 的 undici 内部每次 fetch 后都会调这个方法,不存在就 TypeError。 + +**为什么必须最先 import**——这是整段代码里最脆弱的顺序依赖。打开 `src/entrypoints/cli.tsx:1-5`: + +```ts +#!/usr/bin/env bun +// 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'; +``` + +原因(`performanceShim.ts:14-16`):React reconciler 和 OTel / Langfuse 在 import 时会**捕获 `globalThis.performance` 的引用**。一旦它们拿到原生引用,shim 再装上也没用——它们调用的是自己缓存的原生对象。所以 shim 必须在 React / OTel 加载**之前**就把 `globalThis.performance` 换掉。`installPerformanceShim()`(`performanceShim.ts:162-166`)用 `globalThis.__performanceShimInstalled` 守护幂等性,并且文件末尾(`:169`)自动调用一次,保证"import 即安装"。 + +### query.ts:367 的兜底:防 sub-agent 绕过 shim + +`src/query.ts:367-380` 在每次 query 的收尾位置写了这段: + +```ts +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 + } +} +``` + +注释(`query.ts:367-370`)解释了为什么需要兜底:"OTel 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." + +**为什么有了 shim 还要兜底**:某些 sub-agent 路径会**直接 `import query`**,而不经过 `cli.tsx` 的入口。如果那个进程的 shim 没装上(比如测试环境、嵌入式调用),原生的 `performance` 还在,每次 query 累积的 marks 就会泄漏。这段兜底调的是 `globalThis.performance`(已经被 shim 替换过的话就是 shim 的 `clearMarks`,没有的话就是原生的),作为"shim 没生效时的保险栓"。 + +注意这个兜底是**尽力而为**:原生 `clearMarks()` 在 JSC 上即使能调,C++ Vector 也不收缩(见上面 shim 注释)。所以兜底主要救的是 shim 已装但 Map 需要清空的场景,以及"sub-agent 没装 shim 但又想尽力"的场景。 + +### 6,889 个 _debugStack Error 对象:开发模式下看不见的 12MB + +打开 `build.ts:26-31`: + +```ts +define: { + ...getMacroDefines(), + // React production mode — eliminates _debugStack Error objects + // (6,889 objects × ~1.7KB = 12MB in dev builds) and removes + // prop-type / key warnings not useful in a production CLI tool. + 'process.env.NODE_ENV': JSON.stringify('production'), +}, +``` + +React 在开发模式下(`process.env.NODE_ENV !== 'production'`)会为每次组件渲染构造一个 `Error` 对象,用于捕获调用栈、生成 `_debugStack` 字段。这在浏览器开发工具里有用,但在 CLI 工具里就是纯内存浪费:6,889 个 `Error` 对象,每个约 1.7KB,合计约 12MB。 + +`vite.config.ts:124` 的对应位置注释("6,889 objects × ~1.7KB = 12MB in dev builds")和 `build.ts` 的注释互相印证。这就是为什么 build 强制 `NODE_ENV='production'`——不是审美,是实打实的 12MB。 + +### cli.tsx:44 的 CLAUDE_CODE_REMOTE 内存注入 + +打开 `src/entrypoints/cli.tsx:42-49`: + +```ts +// Set max heap size for child processes in CCR environments (containers have 16GB) +if (process.env.CLAUDE_CODE_REMOTE === 'true') { + const existing = process.env.NODE_OPTIONS || ''; + process.env.NODE_OPTIONS = existing + ? `${existing} --max-old-space-size=8192` + : '--max-old-space-size=8192'; +} +``` + +注释写得很直白:"containers have 16GB"。这是项目对容器环境(Claude Code Remote / CCR)的**硬编码假设**:容器至少有 16GB 内存,所以子进程堆上限可以放心设到 8GB。 + +**为什么硬编码 8GB 而不是按容器实际内存动态算**:因为 `NODE_OPTIONS` 必须在子进程启动前设置,而那时还没有可靠的"当前容器内存上限"查询方式(cgroup 接口在不同运行时下行为不一)。8GB 是一个保守的"16GB 容器的一半给堆"的工程经验值。 + +**为什么这段代码在 cli.tsx 顶层而不是 init.ts**:和 `CLAUDE_CODE_ABLATION_BASELINE`(`cli.tsx:56`)是同一个原因——子进程一启动就要读 `NODE_OPTIONS`,`init()` 跑得太晚。这是入口文件的"副作用顶层化"模式。 + +### distRoot.ts:vendor 二进制路径解析 + +打开 `src/utils/distRoot.ts:15-27`: + +```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 +})() +``` + +代码分割之后,chunk 文件散落在 `dist/` 或 `dist/chunks/` 下,但 vendor 二进制(ripgrep、audio-capture)在 `dist/vendor/`。chunk 文件需要能在运行时定位到 vendor 目录。`distRoot` 用 `lastIndexOf('dist')` 或 `lastIndexOf('src')`(dev 模式)反向定位根目录。 + +**为什么不用 `import.meta.url` 的相对路径推算**:因为 chunk 文件名带 hash(`chunks/[name]-[hash].js`),嵌套层级不固定;`ripgrep.ts` / `computerUse/setup.ts` / `claudeInChrome/setup.ts` / `updateCCB.ts` 都依赖这个共享函数。CLAUDE.md 的"尾声"章节提到一个相关坑:`vendor/ripgrep/arm64-darwin` 二进制如果缺失,Grep 工具会 spawn 该路径并 ENOENT——`distRoot` 的 vendor 复制逻辑(`build.ts:91-93`)就是为了保证构建产物里 vendor 二进制存在。 + +### 性能预算与 token 预算的耦合 + +内存预算之外还有 token 预算:`TOKEN_BUDGET` feature 与 `/cost` / `/usage` 联动。token 预算直接影响单轮 API 调用的延迟和费用,但它和内存预算是**正交**的——压缩上下文(省 token)不一定释放内存(JSC Vector 不收缩),释放内存(重启进程)也不一定省 token(上下文还在持久化存储里)。 + +用户看到"卡"时,往往分不清是哪一类预算耗尽。这正是性能主题必须双视角覆盖的原因:产品视角教用户**按症状分流**(上下文卡 vs 内存卡),设计视角解释**为什么分流之后内存卡还是救不回来**。 + +## 两视角如何呼应 + +用户视角的痛点几乎都能在设计视角找到对应的运行时约束: + +- **"长会话越用越卡,重启就好"**(产品视角)对应 **"JSC 的 C++ Vector 永不收缩 + performanceShim 必须最先 import"**(设计视角)——用户看到的是 RSS 上涨,根因在 JSC 原生 Performance 对象的内存模型。设计视角的 shim 把大部分 `mark` / `measure` 重定向到 GC 可回收的 JS Map,但兜底代码(`query.ts:367`)承认 shim 可能被 sub-agent 绕过,所以用户侧的"重启就好"是最诚实的解法。 +- **"`/compact` 之后还是慢"**(产品视角)对应 **"token 预算与内存预算正交"**(设计视角)——`/compact` 压的是模型视角的上下文(省 token、省推理时间),但 REPL 里的消息对象、JSC Vector 里的 marks 都还在内存里。这是为什么产品视角必须教用户区分"上下文卡"和"内存卡"。 +- **"容器里跑 Claude 会不会 OOM"**(产品视角)对应 **"cli.tsx:44 的 CLAUDE_CODE_REMOTE 内存注入硬编码 8GB"**(设计视角)——产品视角告诉用户"容器至少给 16GB",设计视角解释为什么是 8GB 而不是动态算。 +- **"启动 `--version` 为什么也要几百 MB"**(隐含的工程好奇)对应 **"17MB 单文件让 RSS 涨到 1GB,必须代码分割"**(设计视角)——`--version` RSS 从 966MB 降到 35MB 是代码分割的直接收益,用户感知到的是"CLI 启动飞快",背后是 JSC 全量解析 vs V8 懒解析的根本差异。 + +这种呼应关系是性能章必须双视角覆盖的核心原因:产品视角告诉用户**遇到卡顿怎么办**,设计视角告诉用户**为什么有些卡顿只能重启**。两个视角合在一起,才能让使用者在"压缩、剪裁、清空、重启"之间做出正确选择,也让维护者在改性能相关代码时知道哪些约束是硬的、不能碰。 diff --git a/docs/outline-output/cross/03-security.md b/docs/outline-output/cross/03-security.md new file mode 100644 index 000000000..3c6633124 --- /dev/null +++ b/docs/outline-output/cross/03-security.md @@ -0,0 +1,221 @@ +# 安全 + +> 同一份 `sk-ant-...` 在使用者眼里是"我的密钥去了哪里、谁能看到",在开发者眼里是"为什么用 0o600 写文件、为什么 ChatGPT 订阅要复用 `~/.codex/auth.json`、为什么 `bypassPermissions` 必须先检测是不是 root 或 sandbox"。安全天生是双视角主题——用户担心泄漏,开发者负责把每一处存储、刷新、传输、共享都设计成"即使被泄漏也尽量不致命"。 + +## 产品视角(写给使用者) + +这一节回答三个问题:**我的密钥和令牌存在哪里**、**它们什么时候会被刷新或销毁**、**我把对话分享出去时哪些东西会跟着泄漏**。读完之后,你应该能判断"我能不能把这台机器借给同事"、"我能不能把这份 transcript 发到群里"。 + +### 凭证存储位置清单 + +Claude Code 把不同来源的凭证分散存在几个地方,不要把它们当成一个文件。下面这张表覆盖最常见的几类: + +| 凭证类型 | 存储位置 | 谁能读到 | 备注 | +| --- | --- | --- | --- | +| Anthropic OAuth 令牌 / 自定义 API key | `~/.claude/` 下的 secure storage(macOS Keychain / Windows Credential Manager / Linux libsecret) | 只有当前用户的操作系统账户 | `/logout` 会清掉它(见 `src/commands/logout/logout.tsx:24` 调 `removeApiKey()`) | +| ChatGPT 订阅凭证(`OPENAI_AUTH_MODE=chatgpt`) | `~/.claude/openai-chatgpt-auth.json` | 任何能读这个文件的进程 | 文件用 `mode: 0o600` 写入(见 `src/services/api/openai/chatgptAuth.ts:162`),但仍然是明文 JSON | +| Codex CLI 共享凭证 | `~/.codex/auth.json`(即 `CODEX_HOME/auth.json`) | 任何能读这个文件的进程 | Claude Code **只读不写**这个文件(`chatgptAuth.ts:342`);如果 `~/.claude/openai-chatgpt-auth.json` 不存在,会回退去读它 | +| Provider 环境变量(`OPENAI_API_KEY` 等) | 写进 `settings.json` 的 `env` 字段或 shell rc 文件 | 任何能读 settings 的进程 | `/provider` 命令切换 Provider 不清这些 key(见下文) | +| 团队共享设置 | `<项目>/.claude/settings.json` | 仓库的所有 collaborator | **不要**把 key 写进团队 settings.json,写到 `settings.local.json` 或环境变量里 | +| 个人覆盖设置 | `<项目>/.claude/settings.local.json` | 当前用户 | 默认被 git ignore,适合放本地 API key 之类 | + +一个高频误用:把 `OPENAI_API_KEY` 提交到了项目根目录的 `.claude/settings.json`,结果 push 到团队仓库所有人都看到了。**正确做法**是放到 `.claude/settings.local.json`(git ignored)或者用 `apiKeyHelper`(`src/utils/settings/types.ts:255`,指向一个能输出 key 的本地脚本)。 + +### 权限模式:让 Claude 在沙箱里干活 + +权限模式控制 Claude 在执行工具调用之前是否需要按一次回车。用 `/permissions` 命令(`src/commands/permissions/permissions.tsx`)或 `settings.json` 的 `permissions.defaultMode` 字段切换: + +- `default` —— 文件写入、shell 命令等危险操作按规则匹配后**问你**(最常见)。 +- `acceptEdits` —— 文件编辑直接放行,shell 仍然问。 +- `plan` —— 只读分析,不允许任何写操作。 +- `auto` —— 自动分类器判定(需要 `TRANSCRIPT_CLASSIFIER` feature)。 +- `bypassPermissions` —— 全部放行,**不要在普通环境用**。 + +`bypassPermissions` 是这条链上最危险的模式,所以代码里有专门的"环境硬性检测"(`src/setup.ts:391-435`):在你以 root/sudo 身份启动它、或者环境既不是 Docker 也不是 Bubblewrap 也不是 `IS_SANDBOX=1`、还连着外网的情况下,CLI 会**直接退出**并报错 `--dangerously-skip-permissions cannot be used in Docker/sandbox containers with no internet access`。换句话说,bypass 只允许在"无网 + 沙箱容器"的组合里用。这是有意把滥用路径堵死。 + +权限规则本身写在 `settings.json` 的 `permissions.allow` / `deny` / `ask` 里(schema 在 `src/utils/settings/types.ts:42-55`),用 `/permissions` 命令可视化编辑。规则按"工具名 + glob 路径"匹配,比如 `Bash(npm install:*)` 表示允许所有 `npm install ...` 命令;`Read(~/.ssh/**)` 表示禁止读 ssh 目录。**deny 永远赢过 allow**,这是优先级铁律(详见 `src/utils/permissions/permissions.ts`)。 + +### OAuth 令牌什么时候刷新、什么时候过期 + +两种 OAuth 路径,各自有自己的刷新窗口: + +- **ChatGPT 订阅路径** —— `chatgptAuth.ts:9` 定义了 `REFRESH_SKEW_MS = 5 * 60 * 1000`,意思是"令牌距离过期不到 5 分钟时就主动刷新"。每次调用 `getValidChatGPTAuth()`(`chatgptAuth.ts:339`)都会先 `getTokenExpiryMs` 检查,到点就 `refreshTokens` + `saveStoredAuth`。**用户侧含义**:只要你的网络能通到 `auth.openai.com`,令牌永远不会过期;如果断网超过令牌寿命(通常 1 小时),下一次调用会失败,需要重新 `/login`。 +- **Bridge 模式的会话 JWT** —— `src/bridge/jwtUtils.ts:52` 同样定义了 `TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000`,加上 `FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000` 和 `MAX_REFRESH_FAILURES = 3`。`createTokenRefreshScheduler` 会"在令牌过期前 5 分钟排一个 setTimeout",失败 3 次后放弃。**用户侧含义**:Bridge 长会话(自托管 RCS、远程控制)理论上一周不掉线,但如果你看到 `bridge_token_refresh_no_oauth` 这种 diagnostic log,说明刷新链断了。 + +**`/logout` 会做什么**:不止删 key。它会 `flushTelemetry()` 先把还没上报的埋点冲掉(防止组织数据泄漏,见 `logout.tsx:21` 的注释),然后 `removeApiKey()` + `removeChatGPTAuth()` + 清掉 secure storage + 清一堆缓存(betas、toolSchema、Grove、policyLimits),最后 `gracefulShutdownSync(0, 'logout')` 让进程退出。所以 `/logout` 是"重置到初次安装状态"的快捷方式。 + +### `/share` 与 `/export` 的隐私边界 + +这两个命令都把会话内容写到外部,但隐私处理完全不同: + +- **`/export`**(`src/commands/export/export.tsx`)—— 把会话渲染成纯文本**写到本地文件**。**没有任何脱敏**——你说了什么、Claude 回了什么、API key 是不是出现在消息里,全部原样写出去。这个命令的隐私边界就是"你自己机器上的文件系统",把它交给同事之前请自己检查一遍。 +- **`/share`**(`src/commands/share/index.ts`)—— 把会话日志**上传到 GitHub Gist**(或 `0x0.st` 兜底)。默认 `--private`(私有 Gist),但 GitHub 的 private Gist 对**任何知道 URL 的人**都可读,所以本质上还是"URL 即权限"。`--mask-secrets` 旗标会触发 `maskSecrets()`(`share/index.ts:98`),用一组正则把 `sk-ant-*` / `sk-*` / `Bearer xxx` / `AKIA*`(AWS)/ `ghp_*` / `xoxb-*`(Slack)等常见 token 替换成 `[REDACTED_*]`(模式表在 `share/index.ts:53-92`)。 + +**关键提醒**:`/share --mask-secrets` **不是银弹**。源码里那条 NOTE 写得很明确(`share/index.ts:89-91`): + +> We intentionally do NOT redact generic ≥32-char hex strings because they match legitimate git commit SHAs and base64 content, producing garbled share output. + +也就是说,如果你的 token 长得像 32 位以上的 hex(比如某些自建服务的 token),它**不会被脱敏**。私有信息(内部文档片段、同事姓名、内部 URL)也完全不在脱敏范围里。**最稳的做法**:分享前用 `/export` 导到本地,自己过一遍再决定怎么发。 + +### 跨工具凭证共享:和 Codex CLI 复用 auth + +如果你机器上同时装了 Codex CLI(OpenAI 官方 CLI),你会发现 ChatGPT 订阅登录会在两边都生效。这是因为 `getValidChatGPTAuth()`(`chatgptAuth.ts:339-346`)在 `~/.claude/openai-chatgpt-auth.json` 不存在时会**回退去读 `~/.codex/auth.json`**(`codexAuthFilePath()`,`chatgptAuth.ts:52`)。注释里写得很坦诚(`:344`):`Using ChatGPT auth from Codex auth.json`。 + +**隐私含义**: + +- 你在 Codex CLI 登录 ChatGPT,Claude Code 也能直接用,不需要再登一次。 +- 反过来不成立:Claude Code 的 `saveStoredAuth` 只写 `~/.claude/openai-chatgpt-auth.json`,不写 `~/.codex/auth.json`。 +- 如果你想完全隔离两个工具的凭证,设 `CODEX_HOME` 环境变量把 Codex 的目录指到别处(`chatgptAuth.ts:54`)。 + +### `/provider unset` 只清 Provider 不清 key + +一个高频困惑:跑了 `/provider unset`,以为已经把 OpenAI 凭证清干净了。看 `src/commands/provider.ts:49-62`,它做的事是:清 `modelType` 设置 + 删 `CLAUDE_CODE_USE_*` 环境变量。**它不动**: + +- `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 这些 key 环境变量(仍在 shell 或 settings.json 里)。 +- `~/.claude/openai-chatgpt-auth.json`(仍在磁盘上)。 +- OpenAI/Grok 客户端的模块级缓存(见设计视角)。 + +要彻底清,必须跑 `/logout`(清凭证文件 + secure storage)+ 手动从 settings.json 删 key 环境变量 + 重启 CLI(清缓存)。 + +## 设计视角(写给开发者) + +设计大纲原本没有"安全"章节,相关决策散落在 Provider、Bridge、权限系统各处。这一节把它们串起来,按"为什么这么存、为什么这么检、为什么这么共享"展开。每个决策背后都有一个具体的威胁模型或约束。 + +### 为什么 ChatGPT 凭证用明文 JSON + 0o600,而不是 secure storage + +打开 `src/services/api/openai/chatgptAuth.ts:148-164`: + +```ts +async function saveStoredAuth(tokens: ChatGPTAuthTokens): Promise { + const path = authFilePath() + await mkdir(getClaudeConfigHomeDirLocal(), { recursive: true }) + const body: StoredAuthFile = { auth_mode: 'chatgpt', tokens: { ... }, last_refresh: ... } + await writeFile(path, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 }) + await chmod(path, 0o600).catch(() => undefined) +} +``` + +明文 JSON,文件权限 `0o600`(只有文件 owner 能读写)。**为什么不像 Anthropic OAuth 那样走 secure storage**?因为这套凭证要和 **Codex CLI 互操作**——Codex CLI 的存储格式就是 `~/.codex/auth.json` 明文 JSON(见 OpenAI 官方设计)。如果 Claude Code 把凭证塞进 macOS Keychain,Codex CLI 读不到,跨工具共享就做不到。 + +`chmod 0o600` 是这个权衡下的最大补偿:文件本身明文(互操作需求),但 OS 层面把读权限收紧到当前用户。注意 `chmod` 那行有 `.catch(() => undefined)`——某些文件系统(比如 FAT32 挂载点)不支持 chmod,这种情况会静默失败但文件还是会被写出来。这是一个**优先可用性而非绝对安全**的设计选择。 + +**根因**:跨工具互操作和强凭证存储在本地文件系统层面是冲突的。OpenAI 选择了明文 JSON,Claude Code 跟随这个选择才能复用凭证。 + +### 为什么 `bypassPermissions` 必须先检测 root 和 sandbox + +`src/setup.ts:391-435` 是一段看起来啰嗦的检测代码,但它精确对应一个威胁模型:"用户图省事用 `sudo claude --dangerously-skip-permissions` 启动"。在这种情况下,Claude 拿到的是 root 权限,所有文件(包括 `/etc/passwd`、其它用户的 home)都可读写可执行——bypass 模式就变成了"任意代码执行 root"。 + +检测逻辑按"威胁递进"排: + +1. **第一道(`:397-408`)**:`process.getuid() === 0` 且不是 sandbox(`IS_SANDBOX !== '1'` 且 `CLAUDE_CODE_BUBBLEWRAP` 未设)——直接 `process.exit(1)`。这是"绝对禁止"层。注释里特意提到"TPU devspaces 要求 root",所以留了 `IS_SANDBOX=1` 的逃生口。 +2. **第二道(`:410-434`,仅 `USER_TYPE === 'ant'`)**:进一步要求"必须是 Docker / Bubblewrap / IS_SANDBOX 容器"**且** "无外网"。`hasInternet` 这一条特别严:即使你套了 Docker,只要还能 ping 通外网,bypass 就被拒。 + +**为什么对 `USER_TYPE === 'ant'` 特别严格**:Anthropic 内部用户的默认部署环境更复杂,代码里特意为内部用户加了"容器 + 无网"的双重要求(`:411` 那行 `process.env.USER_TYPE === 'ant'` 判断)。外部用户的判断只走第一道。 + +**根因**:bypassPermissions 模式下整个权限管线被跳过,所以必须在它生效**之前**做环境断言。一旦放进去,再想限制就晚了——Claude 已经能跑任意 shell 命令了。这是一个"防御必须在威胁生效前完成"的典型例子。 + +### 为什么 ACP 权限走"本地管线 + 远端委托"两段式 + +`src/services/acp/permissions.ts:32-173` 的 `createAcpCanUseTool` 是 ACP 模式下所有工具调用的权限闸门。它不直接把每个调用都甩给远端客户端,而是分两段: + +1. **本地管线(`:79-106`)**:先跑 `hasPermissionsToUseTool`,让 deny / allow / bypassPermissions / acceptEdits 这些本地规则自己消化。如果本地已经能决定 allow 或 deny,**直接返回,不打扰远端**。 +2. **远端委托(`:108-172`)**:本地规则判定为 `ask` 时,才通过 `conn.requestPermission()` 把 `allow_always` / `allow_once` / `reject_once` 三个选项发给 ACP 客户端(VS Code、Cursor 等)。 + +**为什么这么设计**:ACP 客户端可能是 IDE、Web UI、自研工具,它们不一定都有良好的权限 UI,而且每次 round-trip 都有延迟。如果连"用户已经 deny 的工具"都要去远端问一遍,体验会很糟。本地管线是"快速短路",远端委托只在"真的需要人决策"时才触发。 + +注意 `forceDecision !== undefined` 那一段(`:71-73`):coordinator / swarm worker 场景会预绑定一个决策,跳过本地管线直接返回。这是"信任父进程已经做了决策"的快捷路径,避免子 worker 重复打断用户。 + +### 为什么 `HasAppStateContext` 主动 throw 防嵌套 + +打开 `src/state/AppState.tsx:57-64`: + +```ts +const HasAppStateContext = React.createContext(false); + +export function AppStateProvider({ children, ... }: Props): React.ReactNode { + const hasAppStateContext = useContext(HasAppStateContext); + if (hasAppStateContext) { + throw new Error('AppStateProvider can not be nested within another AppStateProvider'); + } + // ... +} +``` + +第一眼看起来像"开发者警告",但它其实有**安全含义**。AppState 是整个应用的单一 store,包含 messages、tools、permissions、MCP 连接等敏感字段。如果允许嵌套,外层 Provider 的 children 里某个子组件 mount 了一个内层 Provider,内层的 store 就和外层**脱钩**——内层的 useAppState 拿到的是内层 store,permission 决策、消息历史、凭证状态全部错乱。 + +具体的安全风险场景:一个恶意 MCP 工具或者插件组件如果不小心(或故意)渲染了一个 AppStateProvider,就有可能让一部分 UI 用着"被隔离的、权限被偷偷放宽"的 store。React Context 本身没有"防重复嵌套"机制,所以项目用 `HasAppStateContext` 这个布尔 context 主动 throw——**第一次 mount 时它从 false 变 true,第二次 mount 时读到 true 就抛错**。 + +**根因**:单一 store 是"权限决策单一真相源"的前提。一旦允许多 store 嵌套,权限规则、bypass 状态、secure storage 引用都可能错配。这是"防御性编程"在 React Context 层的落地。 + +### 为什么 Bridge 的 JWT 不验签 + +`src/bridge/jwtUtils.ts:21-32` 的 `decodeJwtPayload` 函数注释里写得很坦诚: + +```ts +/** + * Decode a JWT's payload segment without verifying the signature. + * Strips the `sk-ant-si-` session-ingress prefix if present. + */ +``` + +只解码 payload,不验签。**为什么**?因为 Bridge 模式(自托管 RCS、远程控制)用的是"会话级 JWT",签发和验证都在**同一进程**里完成(Anthropic 服务端签发,Bridge 进程消费)。签名校验在 TLS 层已经做了——Bridge 客户端到服务端的 WebSocket 是 `wss://`,传输层防了 MITM。在这个信任模型下,再做一次 JWT 验签只是徒增 CPU 开销。 + +但这套设计的**前提**是"Bridge 进程本身没被入侵"。如果攻击者拿到了 Bridge 进程的内存,他们可以直接调 `getAccessToken()`(`jwtUtils.ts:168`)拿到 OAuth 令牌,根本不用伪造 JWT。所以威胁模型是"防网络层攻击,不防进程被入侵"。 + +`createTokenRefreshScheduler`(`:72-256`)那 200 行的"失败重试 + generation counter + 30 分钟兜底 + 3 次失败放弃"逻辑,本质上是在防"刷新链断裂后会话静默掉线"——这是**可用性**防御,不是机密性防御。 + +### 为什么 share 的脱敏用正则而不是结构化扫描 + +`src/commands/share/index.ts:53-92` 的 `SECRET_PATTERNS` 表是一组正则,按"前缀 + 长度"匹配各类 token。**为什么不用 AST 解析 JSON、扫所有字符串字段**? + +因为 transcript 的内容**不是结构化的**——它是用户和 Claude 的自由对话,token 可能出现在 markdown 代码块里、可能出现在错误消息里、可能被 Claude 引用又转述了一遍。结构化扫描要么扫不到(被文本包裹),要么扫到太多(合法的长字符串被误判)。 + +正则方案的优势是**精准按已知前缀匹配**:`sk-ant-` 是 Anthropic key 的固定前缀,`ghp_` 是 GitHub PAT 的固定前缀,`AKIA` 是 AWS key 的固定前缀。这些前缀是上游服务设计的"防误识别"机制,复用它们比自创规则更可靠。 + +但代价就是 `share/index.ts:89-91` 那条 NOTE 承认的局限:**没有固定前缀的 token(hex、base64)无法脱敏**,因为它们和合法的 git SHA、文件 hash 无法区分。这是"宁可漏过,不可误杀"的设计选择——误杀会把 transcript 弄成 `[REDACTED]` 满屏飞,比漏掉少数 token 还糟。 + +**根因**:在自由文本上做凭证脱敏是一个"召回率 vs 精确率"的权衡。share 选择了高精确率(固定前缀匹配),牺牲召回率(无前缀 token 漏过)。如果需要更强的脱敏,应该在源头(写入 transcript 之前)做,而不是在导出时亡羊补牢。 + +### 为什么 `/logout` 必须先 flushTelemetry + +`src/commands/logout/logout.tsx:19-22` 的顺序看起来很奇怪: + +```ts +export async function performLogout({ clearOnboarding = false }): Promise { + // Flush telemetry BEFORE clearing credentials to prevent org data leakage + const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js'); + await flushTelemetry(); + await removeApiKey(); + // ... +} +``` + +注释里的"prevent org data leakage"是关键。OpenTelemetry 的 instrumentation 在用户登录状态下会带上"当前组织 ID、用户 ID"等元数据,这些数据要发到 Anthropic 的 telemetry 后端。如果你先 `removeApiKey()` 再 flush,flush 出去的 telemetry 是"未登录状态"的,但这些事件实际上发生在"登录状态"下——属性不匹配。 + +更严重的场景:用户从 Org A 切到 Org B。如果先 clear 再 flush,A 状态下的事件可能被错误归因到 B 组织,泄漏 A 的活动给 B 管理员。先 flush 保证 A 状态下的事件还带着 A 的身份信息发出去,再 clear 切换身份。 + +**根因**:telemetry 的"身份绑定"必须和"事件发生时机"一致。`/logout` 不是单纯的"删 key",而是一次"身份切换的状态机迁移",必须按正确顺序:flush(保留旧身份) → clear(切换到匿名) → reset caches(清旧身份相关的缓存) → shutdown(进程退出)。 + +### 为什么 OpenAI 客户端是模块级缓存(设计取舍回顾) + +这个点在 cross/01-troubleshooting.md 已经详细讲过,这里只补充**安全含义**。`getOpenAIClient`(`src/services/api/openai/client.ts:39`)把首次创建的客户端缓存到模块级 `cachedClient`,整个会话不重建。 + +**安全副作用**:会话中改 `OPENAI_API_KEY` 环境变量,**新 key 不会生效**,旧 key 仍在用。这听起来是 bug,但在另一个角度是**安全特性**:如果某个恶意脚本在会话中途改了 `OPENAI_API_KEY` 想劫持流量,它做不到——客户端已经被缓存,绑定的是原始 key。 + +代价是"用户合法换 key"也得重启 CLI,这是性能优化(避免每次调用都重建 axios 实例)和安全性(绑定首次凭证)的共同产物。`clearOpenAIClientCache()`(`openai/client.ts:76`)是逃生口,但只在 SDK 嵌入场景(用户自己写脚本)才可见——普通 CLI 用户根本不知道这个函数存在,只能通过重启来清缓存。 + +对比 `getAnthropicClient`(`client.ts:84`):每次按 model/region 参数化新建,因为 AWS / GCP / Azure 凭证刷新、region 选择、header 注入都是**会话过程中可能变化的参数**。Anthropic 路径必须每次重新构造,所以它的"换 key 立即生效"行为是被动得到的,不是有意设计的。 + +## 两视角如何呼应 + +用户视角的每一个安全焦虑,几乎都能在设计视角找到对应的设计决策: + +- **"我的密钥存在哪里"**(产品视角)对应 **"ChatGPT 凭证为什么用明文 JSON + 0o600"**(设计视角)——明文是为了和 Codex CLI 互操作,0o600 是这个权衡下的补偿。用户看到的是"明文 JSON",开发者看到的是"互操作和强存储的冲突"。 +- **"bypassPermissions 为什么被拒了"**(产品视角)对应 **"为什么 bypass 必须先检测 root 和 sandbox"**(设计视角)——用户看到的是"启动失败报错",开发者看到的是"防御必须在威胁生效前完成"。 +- **"令牌什么时候过期"**(产品视角)对应 **"为什么 OAuth 用 5 分钟刷新窗口"**(设计视角)——用户看到的是"自动续期",开发者看到的是"刷新链断裂后的 3 次重试 + 30 分钟兜底"。 +- **"`/share --mask-secrets` 会不会泄漏"**(产品视角)对应 **"为什么脱敏用正则而不是结构化扫描"**(设计视角)——用户看到的是"已脱敏"标签,开发者看到的是"召回率 vs 精确率权衡 + 无前缀 token 漏过的诚实交代"。 +- **"`/logout` 真的清干净了吗"**(产品视角)对应 **"为什么必须先 flushTelemetry 再清凭证"**(设计视角)——用户看到的是"重置到初次安装",开发者看到的是"telemetry 身份绑定的状态机迁移"。 +- **"我把项目 settings.json push 到团队仓库会怎样"**(产品视角)对应 **"settings.json vs settings.local.json 的分层"**(设计视角)——用户看到的是"哪些文件会被共享",开发者看到的是"团队设置和个人覆盖的优先级"。 +- **"Codex CLI 登录的 ChatGPT 凭证 Claude 能用吗"**(产品视角)对应 **"为什么 chatgptAuth 回退读 `~/.codex/auth.json`"**(设计视角)——用户看到的是"两边都生效",开发者看到的是"跨工具凭证互操作的有意设计"。 + +这种呼应关系是安全章必须双视角覆盖的核心原因:用户视角告诉你**怎么用才安全**,设计视角告诉你**这个安全机制覆盖了什么、没覆盖什么**。两个视角合在一起,才能让使用者正确评估"我能把这台机器借给同事吗"、"我能把这份 transcript 发到群里吗"这类问题——不会盲目信任某个"已脱敏"标签,也不会因为某个明文 JSON 就以为整套凭证管理都不安全。 diff --git a/docs/outline-output/cross/04-upgrade-versioning.md b/docs/outline-output/cross/04-upgrade-versioning.md new file mode 100644 index 000000000..929025b30 --- /dev/null +++ b/docs/outline-output/cross/04-upgrade-versioning.md @@ -0,0 +1,187 @@ +# 升级与版本管理 + +> 同一个 `2.7.0` 在使用者眼里是"该不该 `claude update`、`claude doctor` 里那个 latest 是不是真的比我新",在开发者眼里是"为什么 `MACRO.VERSION` 必须从 `package.json` 反推、为什么 `--version` 走零模块加载 fast-path、为什么 Bedrock 那段针对性补丁必须留一段写着 probe 文件路径的注释"。升级和版本管理天生是双视角主题——用户想知道"怎么升、升完会不会坏",开发者想知道"版本号从哪里来、补丁什么时候才能拆"。 + +## 产品视角(写给使用者) + +这一节回答三个问题:**我怎么知道该不该升级**、**怎么升**、**升完之后老的行为会不会变**。读完之后,你应该能判断"我现在跑的版本是不是最新的"、"这次升级会不会把我正在用的 Provider 弄坏"。 + +### 我怎么知道该不该升级 + +两条路,任选其一: + +- **跑 `claude doctor`**。这是最稳的诊断入口,对应 `src/commands/doctor/doctor.tsx`(命令本身在 `src/commands/doctor/index.ts` 注册)。它会渲染一个 `Doctor` 屏幕(`src/screens/Doctor.tsx`),里面分三段对你最有用的信息: + - **Diagnostics** 段:`Currently running: ()`、安装路径、被哪个二进制调用、ripgrep 是否可用(`Doctor.tsx:218-232`)。如果你装了多个版本(npm-global + native + package-manager 混着装),这里会显式 warn `Multiple installations found` 并把每个安装的 type 和 path 列出来(`Doctor.tsx:244-254`)。多安装是升级后行为飘移最常见的根因——你 `claude update` 升的是某一个,shell 里 `claude` 还指向另一个。 + - **Updates** 段:`Auto-updates` 的开关、`Update permissions: Yes/No (requires sudo)`、`Auto-update channel`(`latest` 或 `stable`),以及从远端拉下来的 `Stable version` / `Latest version`(`Doctor.tsx:279-289`,远端版本走 `getGcsDistTags` 或 `getNpmDistTags`,见 `Doctor.tsx:91-98`)。 + - **Version Locks** 段(仅当 PID-based locking 启用时):列出当前被锁住的版本和持有它的 PID(`Doctor.tsx:311-328`)。如果你看到某个 lock 标了 `(stale)`,说明上次升级被中断了,残留了一个进程没清掉的锁。 +- **直接跑 `claude --version`**(或 `claude -v` / `claude -V`)。这是最快的路径,只打印一行 ` (Claude Code)` 就退出(`src/entrypoints/cli.tsx:80-84`)。**注意**:它只告诉你"当前跑的是几",不会告诉你"远端最新是几"——要对比必须用 `claude doctor`。 + +`claude doctor` 还会顺带帮你把一堆"升级之后可能出问题"的信号检查一遍:env 变量是否超上限(`BASH_MAX_OUTPUT_LENGTH` / `TASK_MAX_OUTPUT_LENGTH` / `CLAUDE_CODE_MAX_OUTPUT_TOKENS`,见 `Doctor.tsx:103-128`)、settings 有没有 schema 错误、agent 文件有没有解析失败、MCP server 有没有 parsing warning、keybindings 有没有冲突。升级前先跑一次 `claude doctor`、升级后再跑一次对比,是排错最高效的姿势。 + +### 怎么升 + +跑 `claude update`(注册在 `src/main.tsx:5346-5353`,实现是 `src/cli/updateCCB.ts` 的 `updateCCB()`)。它会做这几件事: + +1. 读当前版本:先尝试从 `distRoot` 上层的 `package.json` 读 `version`,读不到就退回 `MACRO.VERSION`(`updateCCB.ts:18-29`)。这一步保证"全局装的 ccb"和"开发模式下跑的 cli.tsx"看到的是同一个版本号。 +2. 探测包管理器:先看当前进程是不是从 bun 起的(`process.execPath` 含 `bun`,或者 `~/.bun/install/global/node_modules/claude-code-best` 存在),是就用 bun;否则用 npm(`updateCCB.ts:56-77`)。 +3. 从 npm registry 拉 latest 版本号:`npm view claude-code-best@latest version --prefer-online`(`updateCCB.ts:79-90`),10 秒超时。 +4. 比较:如果 `current >= latest`,直接打印 `ccb is up to date ()` 退出;否则继续(`updateCCB.ts:113-122`)。 +5. 实际装:`bun install -g claude-code-best@latest` 或 `npm install -g claude-code-best@latest`,120 秒超时(`updateCCB.ts:131-152`)。 + +升级完成之后**必须重启 `claude`**。原因有两条: + +- `claude update` 只动磁盘上的文件,不动当前正在运行的进程内存。你的 REPL 还跑着旧代码。 +- 多个兼容层的客户端(OpenAI / Grok)走的是模块级缓存(见 cross/03-security.md 的"为什么 OpenAI 客户端是模块级缓存"),重启之外没有任何方式让它们重新读 key 和 endpoint。 + +如果 `claude update` 失败,错误信息会直接建议你手动跑对应的 `bun install -g claude-code-best@latest` 或 `npm install -g claude-code-best@latest`(`updateCCB.ts:155-173`)。这两个命令本质上和 `claude update` 跑的是同一条 shell,区别只是 `claude update` 多了一层"探测包管理器 + 比较版本"的逻辑——失败时跳过这层逻辑直接装 latest 是最快的恢复方式。 + +### 升级之后老的行为会不会变 + +会,但只有两种情况值得你担心: + +- **版本号最小限制**。`assertMinVersion()`(`src/utils/autoUpdater.ts:79-111`)会在启动时从远端 Statsig config `tengu_version_config` 读 `minVersion`,如果你跑的版本低于这个值,CLI 会**直接退出**并打印 `It looks like your version of Claude Code () needs to update`。这是服务端 kill switch——某些重大变更(API schema 不兼容、安全修复)上线时,官方会把这个值推高,强制所有人升级。**用户侧含义**:如果你某天打开 `claude` 发现它拒绝启动并提示要 update,先 `claude update` 再说。 +- **最大版本回退**。`getMaxVersion()`(`autoUpdater.ts:125-141`)从同一个远端 config 读 `external` / `ant` 字段,作为"当前允许的最高版本"。这是 incident 时的紧急刹车——如果新版本被发现有严重 bug,官方会把 max 版本设到上一个稳定版,auto-updater 就不会把用户升到坏版本。**用户侧含义**:你手动 `claude update` 后看到的版本可能比 npm registry 上的 `latest` 旧,这是有意的回退,不是你装错了。 + +注意 `assertMinVersion` 的注释(`autoUpdater.ts:46-60`)专门讲了一处容易混淆的设计:版本号格式 `X.X.X+SHA`(continuous deployment 用的带 build metadata 的 semver)里,**比较版本大小**(`assertMinVersion`)会忽略 `+SHA`,**检测是否有更新**(`claude update`)会用精确字符串比较不忽略。所以你可能看到 `claude --version` 显示 `2.7.0+abc123`、npm 上 latest 也是 `2.7.0`,但 `claude update` 还是会重新装一遍——因为它在比 SHA,发现你本地的 SHA 不是最新的。这不是 bug,是为了让 continuous deployment 的每次 commit 都能推到用户。 + +### 升级前自检清单 + +- `claude doctor` 看一下 `Auto-update channel`、`Update permissions`、有没有 `Multiple installations found` 警告。多安装的情况下先想清楚 shell 里 `which claude` 指向哪一个。 +- 如果你在用 OpenAI / Gemini / Grok 兼容层,记录一下当前 `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 的值(升级本身不动 key,但万一升级过程中断了重装,可能要重设)。 +- 如果你在 Bridge / Daemon / 后台 session 模式下长跑,升级前先 `claude daemon stop` / `claude kill` 把它们停掉——升级会替换二进制,但不会通知正在跑的进程。 + +## 设计视角(写给开发者) + +设计大纲原本只在第二章入口链里点了一句"版本号单一来源 `package.json`"。这一节把版本号怎么流到运行时、针对性补丁什么时候该拆、双构建管线的版本一致性这三件事讲透。每个决策背后都有一个具体的约束(漂移、SDK 漏洞、bun/node 双运行时)。 + +### 为什么版本号必须从 `package.json` 反推,而不是 hardcoded + +打开 `scripts/defines.ts:7-24`: + +```ts +const pkgPath = resolve(__dirname, '..', 'package.json') +const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + +export function getMacroDefines(): Record { + return { + 'MACRO.VERSION': JSON.stringify(pkg.version), + 'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()), + // ... + } +} +``` + +注释里写得很直白:`VERSION is read from package.json to avoid version drift`。版本号如果既写在 `package.json`、又写在 `defines.ts`、又出现在某处字符串字面量,发版时一定有人忘了同步其中一个,用户看到的 `claude --version` 就会和 npm 上的版本对不上。 + +但"单一来源"的实现路径很有意思——它必须穿过三层 MACRO 注入才能到达运行时: + +1. **dev 模式**:`scripts/dev.ts:18-29` 把 `getMacroDefines()` 的返回值用 `-d` flag 一条条传给 `bun run`。注释(`dev.ts:5-9`)专门解释了为什么不用 `bunfig.toml` 的 `[define]`——因为它不会传播到 dynamically imported modules。 +2. **build 模式**:`build.ts` 把同样的 defines 喂给 `Bun.build({ define })`,由 Bun 编译器在 transpile 阶段做字面量替换。 +3. **运行时兜底**:如果有人直接跑 `bun src/entrypoints/cli.tsx`(既不走 `bun run dev` 也不走 dist/),`cli.tsx:9-21` 会检测 `globalThis.MACRO === undefined` 并填一个 fallback,`VERSION` 从 `process.env.CLAUDE_CODE_VERSION || '2.1.888'` 取。这个 `'2.1.888'` 是写死的 fallback——它只在"完全脱离工具链直接跑源码"时才出现,正常使用路径上永远不会看到这个版本号。 + +**为什么 `--version` fast-path 必须零模块加载**:`cli.tsx:79-84` 的逻辑只有一行 `console.log(\`${MACRO.VERSION} (Claude Code)\`)`。这之所以能做到"零模块加载",恰恰是因为 `MACRO.VERSION` 在 transpile 阶段就已经被替换成了字面量字符串——运行时不需要 import 任何东西就能拿到版本号。如果版本号是从某个模块的 `getVersion()` 函数读出来的,`--version` 就必须 import 那个模块,fast-path 就破了。**版本号的单一来源约束反过来塑造了 fast-path 的实现方式**——这是约束驱动设计的一个干净例子。 + +### `claude update` 为什么自己重新发明了版本比较,而不是用现成的 semver 库 + +看 `src/cli/updateCCB.ts:124-134`: + +```ts +function gte(a: string, b: string): boolean { + const parseVer = (v: string) => v.replace(/^\D/, '').split('.').map(Number) + const pa = parseVer(a) + const pb = parseVer(b) + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true + if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false + } + return true +} +``` + +一个手写的、只有 8 行的 `gte`。**为什么不复用 `src/utils/semver.ts`**?因为 `updateCCB.ts` 是一个**必须能独立运行的子命令**——它从 `getCurrentVersion()` 开始就要能在"用户刚装好 ccb、还没装依赖"的极简环境下工作。它 import 的全是 `node:child_process` / `node:fs` / `node:os` 这种 zero-dependency 标准库,加上项目内部的 `distRoot` / `execFileNoThrowWithCwd` / `gracefulShutdown` / `process` / `debug` / `chalk`。`semver.ts` 依赖的图更大,引入它会让 updateCCB 的启动时间变长、潜在故障面变大。 + +代价是这个 `gte` **不处理 build metadata**:`2.7.0+abc` 和 `2.7.0+def` 在这个比较里是相等的。`updateCCB.ts:120` 那条 `latestVersion === currentVersion || gte(currentVersion, latestVersion)` 的 `||` 短路就是补偿——先用精确字符串比较(能区分 SHA),相等了再退到手写 semver 比较(防 latest 比当前旧这种边界情况)。这个组合策略和 `autoUpdater.ts:46-60` 那段注释承认的"两套比较逻辑并存"是同一个权衡的延伸。 + +### Bedrock 补丁为什么必须留一段写着 probe 文件路径的注释 + +这是整个项目里最有"工程纪律"感的一段代码。打开 `src/services/api/bedrockClient.ts:1-35`: + +```ts +/** + * Extends AnthropicBedrock to work around an upstream bug where the SDK + * re-plants the `anthropic-beta` HTTP header value into the request body + * as `anthropic_beta`. Bedrock's Opus 4.7 endpoint rejects any request with + * `anthropic_beta` in the body with a 400 "invalid beta flag" error. + * + * Source of the bug (SDK 0.26.4, still present through 0.28.1): + * node_modules/@anthropic-ai/bedrock-sdk/client.js lines 122-127 + * + * When upstream ships a fix, verify the probe in scripts/probe-bedrock-beta-fix.ts + * shows "bug reproduced: false", then delete this class and change + * services/api/client.ts to instantiate `AnthropicBedrock` directly. + */ +``` + +这段注释干了两件不寻常的事: + +1. **精确锁定漏洞的范围**:SDK 版本(0.26.4-0.28.1)、出问题的源码行号(`client.js` 122-127)、错误现象(body 里多了 `anthropic_beta` 字段、Opus 4.7 返回 400)、上游 issue 编号(`anthropics/claude-code#49238`)。所有信息都精确到能在 5 秒内验证。 +2. **指明补丁的拆除条件**:当上游修复后,跑某个 probe 脚本确认 bug 不再复现,就可以**删掉整个 `BedrockClient` 类**,把 `services/api/client.ts` 改回直接 `new AnthropicBedrock(...)`。 + +**值得注意的事实**:注释里提到的 `scripts/probe-bedrock-beta-fix.ts` **目前并不存在于仓库里**(`find scripts -name '*probe*'` 只能找到 `probe-local-wiring.ts` 和 `probe-subscription-endpoints.ts`)。这不是文档错——这是注释作者留下的**意图标记**:补丁本身写了,但配套的"自动检测修复后能否拆除"的 probe 脚本还没补。读者看到这段注释时,应该理解成"这个补丁是临时的,未来某天上游修了就要拆,但目前没人持续监控上游 SDK 的变化"。 + +这正是 probe 模式的**价值与代价**: + +- **价值**:每个针对性补丁都明确标注"我为什么存在、什么时候可以消失"。两年后某个新人接手代码,看到 `BedrockClient` 不会一脸懵——他能从注释里立刻判断"这个补丁还要不要留"。 +- **代价**:probe 脚本必须有人维护。注释里写的那个文件不存在,意味着拆除条件目前**没有自动验证**——上游 SDK 升级到修复版之后,没有人会被自动通知"现在可以删 BedrockClient 了"。补丁会一直留着,直到某次 code review 有人手动翻到这段注释、手动验证、手动拆。 + +**根因**:针对性补丁是技术债的一种特殊形态——它承认"我在等上游修"。probe 模式是把这种"等"变得**可追踪**:每段补丁都自带拆除说明书。但说明书本身不会自动执行,所以 probe 模式的实际效果取决于团队是否真的定期跑 probe。这个项目目前的状态是"说明书有了,自动化还没跟上"。 + +### 为什么 MACRO 必须用编译期字面量替换,而不是运行时函数 + +版本号和构建时间这种常量,理论上完全可以写成一个普通的 `export const VERSION = pkg.version`。为什么非要走 MACRO 编译期替换? + +答案藏在 `--version` 的 fast-path 设计里。如果 VERSION 是普通 export,`cli.tsx:80-84` 那段代码就必须 `import { VERSION } from '...constants...'`,这次 import 会触发常量模块所在依赖图的解析——`constants/` 里如果还有别的导出、还有别的副作用,fast-path 就不再是"零模块加载"。 + +MACRO 替换绕开了这个问题:`MACRO.VERSION` 在 transpile 阶段被替换成字符串字面量 `'2.7.0'`,运行时 `cli.tsx` 里那行就是 `console.log(\`2.7.0 (Claude Code)\`)`——没有任何 import、没有任何模块解析、没有任何副作用。`--version` 的 RSS 因此能从"加载整个 CLI"降到几十 MB(见 cross/02-performance-memory.md)。 + +这个选择还顺手解决了**dev 和 build 的版本号一致性**:`dev.ts` 和 `build.ts` 都从同一个 `getMacroDefines()` 读 defines(`defines.ts:14`),所以 dev 模式跑出来的 `--version` 和 build 出来的 dist 跑出来的 `--version` 一定是同一个值。如果走 `export const VERSION`,dev 模式读源码 `package.json`、build 模式读 build 时打包进去的 `package.json`,两边就有漂移风险。 + +**根因**:MACRO 不是"为了语义清晰而引入的抽象",而是"为了让 fast-path 真的快、为了让 dev/build 版本一致而被迫引入的编译期机制"。它是性能和一致性约束的共同产物。 + +### 双构建管线(Bun.build vs Vite)的版本号一致性 + +项目有两套构建管线(详见设计大纲第一章):`build.ts` 跑 `Bun.build()`、`vite.config.ts` 跑 Vite。两者都从 `scripts/defines.ts` 读 MACRO defines: + +- **Bun.build 路径**:`build.ts` 直接调 `getMacroDefines()` 喂给 `Bun.build({ define })`。 +- **Vite 路径**:`scripts/vite-plugin-feature-flags.ts` 在 transform 阶段做字面量替换。 + +两条路径用的是同一个 defines 函数,所以产物的版本号一致。这看起来是显然的,但它是**有意设计**——如果两条路径各自硬编码版本号、或各自从不同地方读,就会有"Vite 构建的 `--version` 和 Bun 构建的 `--version` 不一致"这种诡异 bug。`defines.ts` 既是单一来源,也是两条管线的契约。 + +构建后还有一道独立的 post-process(`build.ts:43-46`):把 `import.meta.require` 替换成 `typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url)`。这道 patch 让产物**同时兼容 bun 和 node**——同一份 dist 文件,bun 跑用 `import.meta.require`(Bun 原生支持),node 跑用 `createRequire`(Node 标准 API)。这是双入口 `cli-bun.js` / `cli-node.js` 能共用同一份 chunk 的前提。 + +### 升级流程为什么不走"热替换" + +`claude update` 装完新版本后,**当前进程不会被替换**。REPL 还跑着旧代码,直到用户手动退出重开。为什么不像浏览器那样做热替换? + +打开 `cli/updateCCB.ts:131-152` 看实际逻辑:它跑的是 `execSync('bun install -g ...@latest')` 或 `execSync('npm install -g ...@latest')`。这是**子进程同步执行**,完成后新文件就位,但**父进程(当前 REPL)的 require 缓存、模块级 const、模块级 client 缓存全部不动**。 + +热替换需要解决三个难题: + +1. **模块级缓存的失效**。`getOpenAIClient` / `getGrokClient`(见 cross/03-security.md)把客户端实例缓存到模块级变量,热替换要遍历所有这些模块、清掉缓存。 +2. **模块级 const 的重捕获**。`cli.tsx:56-69` 那段 ablation 逻辑,`BashTool` / `AgentTool` / `PowerShellTool` 在 import 时就把环境变量捕获进模块级 `const`。热替换要重新 import 这些模块,让 const 重新捕获——但这意味着工具实例全部重建,正在跑的 agent / 后台 task 全部丢失。 +3. **React 状态树的保留**。REPL 是 Ink 渲染的 React 树,messages / tools / MCP 连接全是 state。热替换要保证 state 不丢——但新版代码的 state shape 可能变了(schema migration)。 + +三个难题都没好解。所以项目选择了一个朴素但鲁棒的方案:**升级只动磁盘,重启靠用户**。代价是多了一次手动重启,收益是绝对不会出现"半新半旧"的不一致状态。这个权衡和 `/logout` 必须先 flushTelemetry 再清凭证(见 cross/03-security.md)是同一种风格——**宁可让用户多做一步,也不接受状态不一致**。 + +## 两视角如何呼应 + +用户视角的每一个升级困惑,几乎都能在设计视角找到对应的设计决策: + +- **"我怎么知道该不该升"**(产品视角)对应 **"`--version` 为什么是零模块加载 fast-path"**(设计视角)——用户看到的是"一行命令秒出",开发者看到的是"MACRO 编译期替换让版本号成为字面量、绕开 import 触发的模块解析"。 +- **"`claude update` 装的是哪个版本"**(产品视角)对应 **"为什么版本号必须从 `package.json` 反推"**(设计视角)——用户看到的是"升级提示很准",开发者看到的是"`scripts/defines.ts` 的单一来源约束 + dev/build 双管线共用同一个 defines 函数"。 +- **"为什么 `claude update` 之后还要手动重启"**(产品视角)对应 **"为什么升级不走热替换"**(设计视角)——用户看到的是"多一步操作",开发者看到的是"模块级缓存 + 模块级 const + React state 三重难题的工程权衡"。 +- **"为什么我的版本号带 `+SHA` 后缀,npm 上的 latest 看起来一样却还是要重装"**(产品视角)对应 **"`assertMinVersion` 的两套比较逻辑"**(设计视角)——用户看到的是"莫名其妙的重复升级",开发者看到的是"continuous deployment 的 SHA 比较与 semver 比较并存的诚实设计"。 +- **"Bedrock 报 400 invalid beta flag 怎么办"**(产品视角,详见 cross/01-troubleshooting.md)对应 **"BedrockClient 为什么必须留 probe 注释"**(设计视角)——用户看到的是"升级 SDK 之后某个错误消失了或出现了",开发者看到的是"针对性补丁的拆除条件被写成注释、probe 脚本作为意图标记但当前仓库里还没建"。 +- **"升级之后 key 还在不在"**(产品视角)对应 **"升级为什么只动磁盘不动进程"**(设计视角)——用户看到的是"key 不变、设置不变",开发者看到的是"`updateCCB.ts` 只跑 npm/bun install、完全不碰 ~/.claude/ 下的凭证文件"。 + +这种呼应关系是升级与版本管理章必须双视角覆盖的核心原因:用户视角告诉你**怎么升才安全**,设计视角告诉你**这个升级机制覆盖了什么、没覆盖什么**。两个视角合在一起,才能让使用者正确评估"我现在该不该升、升完之后哪些东西会变、哪些不会变"——不会盲目相信"升级就是好的",也不会因为某次升级出过 bug 就永远不敢再升。 diff --git a/docs/outline-output/cross/05-tool-integration.md b/docs/outline-output/cross/05-tool-integration.md new file mode 100644 index 000000000..877bb7a8d --- /dev/null +++ b/docs/outline-output/cross/05-tool-integration.md @@ -0,0 +1,170 @@ +# 与其他工具集成 + +> 同一个"接入外部工具"的动作,在使用者眼里是"我能在 VS Code / Zed / Cursor / GitHub Actions / Codex CLI 里用 Claude 吗、要装什么、凭证怎么走",在开发者眼里是"为什么 IDE 走 MCP 的 `sse-ide` / `ws-ide` 子类型、为什么 ACP agent 用 stdio NDJSON、为什么 ChatGPT 订阅凭证要 fallback 读 `~/.codex/auth.json`、为什么 `install-github-app` 是 React 多步表单而不是一行 shell"。集成天然是双视角主题——用户想知道"能不能接、怎么接",开发者想知道"边界在哪、契约长什么样、为什么这样切"。 + +## 产品视角(写给使用者) + +这一节回答一个最高频的问题:**我能在 X 里用 Claude 吗?** 答案按"接入形态"分成五类,每类给一个清单式的"做什么 → 怎么做"。 + +### 第一类:把 Claude 接进 IDE(VS Code / Cursor / Windsurf / JetBrains / Zed) + +你能在主流 IDE 里得到一个能看见当前工作区、能开 diff、能跑工具的 Claude。两条路径,按 IDE 选: + +- **VS Code 家族(VS Code / Cursor / Windsurf)+ JetBrains 家族**:装官方扩展或插件,然后在 `claude` REPL 里跑 `/ide`(命令在 `src/commands/ide/index.ts` 注册,实现在 `src/commands/ide/ide.tsx`)。它会扫描当前在跑的 IDE、列出带扩展的实例、让你选一个连过去。`/ide open`(`ide.tsx:277-329`)还会把当前 worktree 或 cwd 在选中的 IDE 里打开。注意 VS Code 系列有一条限制:同一时刻只能有一个 Claude 实例连过去(`ide.tsx:127-131` 的告警)。 +- **Zed / Cursor 等 ACP 客户端**:ACP(Agent Client Protocol)是 stdio NDJSON 协议。Claude 自身就是一个 ACP agent,跑 `claude --acp`(`src/entrypoints/cli.tsx:123-124` 的 fast-path,受 `feature('ACP')` 门控)就会进入 stdio 模式,由 IDE 直接 spawn。Zed 侧的配置方式见 `docs/features/agents/acp.md`:在 Zed 的 `settings.json` 里加 `agent_servers`,`command` 指向 `claude`,`args` 写 `["--acp"]`。 + +**这两条路径的区别**:`/ide` 是 Claude 主动连过去(Claude 作为 MCP client 反向连 IDE 的 MCP server),适合在终端 REPL 里把 IDE 当作"上下文源";`--acp` 是 IDE 把 Claude 当 agent 调起来(Claude 作为 ACP server),适合 IDE 内置的 Agent Panel。两种方向都支持,挑你顺手的。 + +**自动连接**:`/ide` 第一次手动选完之后,会在 `IdeAutoConnectDialog`(`src/components/IdeAutoConnectDialog.js`)里问你要不要"以后自动连"。开了之后下次启动 REPL 会自动连上同一台 IDE,不用每次 `/ide`。要关掉就再跑 `/ide` 选 `None`,会弹 `IdeDisableAutoConnectDialog`。 + +### 第二类:把 Claude 暴露成可以被远程调用的服务(ACP / Bridge / RCS) + +"我有一台跑 Claude 的机器、想让另一台机器(或浏览器、或团队同事)调用它"——三类方案: + +- **ACP agent 远程化**:`claude --acp` 默认是本地 stdio。要让 WebSocket 客户端也能调,跑 `acp-link`(`packages/acp-link/`,README 在 `packages/acp-link/README.md`)。它把 WebSocket 连接桥接到 ACP agent 的 stdin/stdout。默认端口 9315,默认会自动生成一个 token;要固定 token 用 `ACP_AUTH_TOKEN` 环境变量,要禁用认证(不推荐)用 `--no-auth`。详细 CLI 选项见 README。 +- **Bridge / Remote Control 快速路径**:`claude remote-control` / `claude rc` / `claude remote` / `claude sync` / `claude bridge`(`cli.tsx:178-188`,五个别名都进同一条 fast-path,受 `feature('BRIDGE_MODE')` 门控)。这条路径把当前进程接到一个 Remote Control 后端,让你的 REPL 能被远端控制。 +- **自托管 RCS(Remote Control Server)**:如果你要给一个团队或长期跑的后端,用 `packages/remote-control-server/`(Docker 部署 + Web UI 控制面板,启动用 `bun run rcs`)。它的 README(`packages/remote-control-server/README.md`)列了五项能力:会话管理、实时消息流(WebSocket / SSE 双向)、权限审批(在 Web UI 里点同意/拒绝)、多环境管理(注册多台运行环境、心跳和断线重连)、API Key + JWT 双层认证。acp-link 也能注册到 RCS:设 `ACP_RCS_URL` / `ACP_RCS_TOKEN` / `ACP_RCS_GROUP`(或 `--group ` flag),就能在 RCS Web UI 里看到这个 ACP agent。 + +**这三类的取舍**:acp-link 适合"我有一台机器、想让外部 WebSocket 调一下";`claude remote-control` 适合"我正在 REPL 里干活、临时让远端接入";自托管 RCS 适合"团队级长期跑"。同一个底(query loop + 工具系统)三种接入形态,见设计视角的"集成边界"一节。 + +### 第三类:把 Claude 嵌进 GitHub 工作流(issue / PR review / 自动修复) + +两条入口: + +- **手动一键装**:`claude install-github-app`(实现在 `src/commands/install-github-app/install-github-app.tsx`,命令注册在 `src/commands/install-github-app/index.ts`)。它是一个多步 React 表单(不是 shell 命令),会带你走完:检测 `gh` 是否装了、选 repo、检测现有 workflow、装 GitHub App、写 API key 到 GitHub Secret、装 workflow 文件。装完之后,在你的 GitHub repo 里 `@claude` 提一句,就会触发 `claude-code-action` 跑一轮。具体能触发什么事件、workflow 模板长什么样,看 `src/constants/github-app.ts`——`WORKFLOW_CONTENT` 是写进你 repo 的 workflow 文件内容,`GITHUB_ACTION_SETUP_DOCS_URL` 指向 `anthropics/claude-code-action` 仓库的 setup 文档。 +- **直接 commit + push + 开 PR**:`/commit-push-pr`(`src/commands/commit-push-pr.ts`)。这不是 GitHub App,是你本地 `claude` 直接用 `gh` CLI 帮你开 PR。它内部有一个 `ALLOWED_TOOLS` 白名单(`commit-push-pr.ts:11-23`),只允许 `Bash(git ...)` / `Bash(gh pr ...)` / `SearchExtraTools` 和两个 Slack 工具。如果你的 CLAUDE.md 提到要往 Slack 发 PR 链接,它还会用 `SearchExtraTools` 找 Slack 工具问你要不要发(`commit-push-pr.ts` 的 `slackStep`)。 +- **PR 自动修复**:`/autofix-pr`(`src/commands/autofix-pr/`,入口 `launchAutofixPr.ts`)。这是给 CI 上跑的——PR 触发后 Claude 看一遍、发现明显问题就自动提交一个修复 commit。 + +### 第四类:和 Codex CLI 共享 ChatGPT 订阅凭证 + +如果你同时在用 Codex CLI 和 Claude,并且想用 ChatGPT 订阅当后端(`OPENAI_AUTH_MODE=chatgpt`),你**不需要在两边各登录一次**。Claude 会先读自己的 `~/.claude/openai-chatgpt-auth.json`;如果不存在,会 fallback 读 Codex CLI 的 `~/.codex/auth.json`(`src/services/api/openai/chatgptAuth.ts:339-344`)。所以你在 Codex CLI 里登录过、Claude 这边就能直接复用。 + +反过来不成立:Codex CLI 不会读 Claude 的凭证文件。如果你只想在 Claude 里用,就只在 Claude 这边 `/login` 走 ChatGPT 设备码流程;如果你想在两边都用,去 Codex CLI 登录一次更省事。 + +凭证刷新有 5 分钟的偏差窗口(`REFRESH_SKEW_MS = 5 * 60 * 1000`,`chatgptAuth.ts:7`)——令牌过期前 5 分钟内任意一次请求都会触发刷新,避免边界 race。详见 cross/03-security.md 的凭证章节。 + +### 第五类:跨工具凭证共享(其他 Provider) + +**只有 ChatGPT 订阅路径**会跨工具读 Codex 的凭证文件。其他 Provider(Anthropic / 普通 OpenAI API key / Gemini / Grok / Bedrock / Vertex / Foundry)的 key 都存在 Claude 自己的 `~/.claude/` 下或 `settings.json` 里,不与任何外部工具共享。 + +如果你同时在别的工具(比如 Aider、Continue)里用 Anthropic API,那些工具各自读自己的配置——你需要在每个工具里都配一遍 `ANTHROPIC_API_KEY` 或对应的环境变量。这不是 bug,是有意的隔离:一个工具的凭证泄露不应该顺带把另一个工具的也带出去。 + +## 设计视角(写给开发者) + +设计大纲原本完全没有"跨工具集成视角"。这一节补上"集成边界"——每一类集成背后都有一组明确的契约和决策:协议形态、凭证流向、feature 门控、命令路径。读完之后你应该能回答:"如果我要加一个新的 IDE 集成、或一个新的 CI 平台,边界在哪、哪些约束是必须遵守的"。 + +### 为什么 IDE 集成走 MCP 的 `sse-ide` / `ws-ide` 子类型,而不是普通 MCP + +打开 `src/commands/ide/ide.tsx:463-472`,看连接 IDE 时写入 `dynamicMcpConfig` 的逻辑: + +```ts +const url = selectedIDE.url +newConfig.ide = { + type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide', + url: url, + ideName: selectedIDE.name, + authToken: selectedIDE.authToken, + ideRunningInWindows: selectedIDE.ideRunningInWindows, + scope: 'dynamic' as const, +} as ScopedMcpServerConfig +``` + +IDE 在 MCP config 里是一个特殊的 `ide` key,type 是 `sse-ide` 或 `ws-ide`——不是普通的 `sse` / `websocket`。这两个子类型在 `src/services/mcp/` 里有专门的处理路径。**为什么不给 IDE 用普通 MCP?** 因为 IDE 提供的不只是工具(`mcp__ide__*` 工具前缀,见 `ide.tsx:455-456` 的 `filter` 清理逻辑),还有 diff 显示、当前选中文件、diagnostics 推送这些"非工具形态"的能力。给 IDE 单独留一个 type,让 MCP client 知道"这个连接除了普通工具调用,还有 IDE 专有的副作用通道"。 + +**另一个有意思的设计**:`dynamicMcpConfig` 的 scope 是 `'dynamic'`。这意味着 IDE 配置不写进 `settings.json`,而是活在 React state 里——下次启动 REPL 不会自动恢复。自动恢复靠 `IdeAutoConnectDialog` 单独存的标志位("以后自动连"),连接动作本身每次都要重新走一遍。这个设计的代价是:用户换一台机器、或者把 settings 同步到另一台,IDE 自动连不会跨机器带过去。收益是:IDE 的端口和 token 是会话期会变的(IDE 重启端口就变),写进持久化 settings 反而会读到过期值。 + +**disconnect 的细节**(`ide.tsx:446-460`):断开连接时除了清 config,还主动 `ideClient.client.onclose = () => {}` 把 onclose 置空。**为什么?** MCP client 有自动重连机制,正常关闭会触发重连。置空 onclose 是"我说了要断、别再自己连回来"的信号——这是 RPC 类连接很容易踩的坑,`/ide` 选 None 的时候必须做这一步,否则用户会看到"我明明断了它又自己连上"。 + +### 为什么 ACP agent 是 stdio NDJSON,而 acp-link 要做 WebSocket → stdio 桥接 + +ACP 的协议形态选择写在 `docs/features/agents/acp.md`:stdin/stdout 的 NDJSON 流。**为什么是 stdio?** 因为 stdio 是 IDE 调子进程最简单的形态——IDE spawn `claude --acp`,往 stdin 写 NDJSON,从 stdout 读 NDJSON。不需要开端口、不需要握手、不需要网络配置。代价是"只能本地调用"——IDE 和 agent 必须在同一台机器上同一个进程树里。 + +acp-link(`packages/acp-link/`)就是为突破这个限制存在的。看 README 的 "How It Works":它监听 WebSocket、收到 `connect` 消息就 spawn 配置好的 ACP agent 子进程、把 WebSocket 帧和 agent 的 stdin/stdout 双向桥接。**为什么不直接给 ACP agent 加一个 WebSocket 模式?** 因为 stdio 和 WebSocket 是两种完全不同的 I/O 模型——stdio 是阻塞 read、WebSocket 是事件回调。把它们塞进同一个 agent 进程会让 agent 的代码复杂度爆炸。acp-link 作为独立进程承担"协议翻译",agent 自己保持纯 stdio,**单一职责**。 + +**这个设计的代价**:多了一层进程。acp-link 进程崩了,agent 和 WebSocket 客户端都会失联。RCS 的多环境管理(README 提到"心跳和断线重连")部分就是为了缓解这个——acp-link 进程挂了 RCS 能检测到、能重启。`packages/acp-link/src/manager/`(README 的 "Manager UI" 段)进一步提供了"一台机器跑多个 acp-link 子进程、统一管理"的形态,这是为团队场景设计的。 + +**凭证透传**:ACP agent 启动时会读 `settings.json` 里的环境变量(见 `docs/features/agents/acp.md` 第 58 行,`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` 等)。Zed 这种 IDE 还能在 `agent_servers` 配置里显式传 `env`。**为什么不让 ACP 协议自己带凭证?** 因为 ACP 是协议、凭证是部署期决策——协议只规定"怎么对话",凭证由调用方(IDE 的 `agent_servers.env` / RCS 的环境变量 / acp-link 启动时的环境)决定。这种分离让同一个 ACP agent 能在不同 IDE、不同部署形态下复用,不需要改 agent 代码。 + +### 为什么 ChatGPT 订阅凭证要 fallback 读 `~/.codex/auth.json` + +打开 `src/services/api/openai/chatgptAuth.ts:42-57`: + +```ts +function authFilePath(): string { + return join(getClaudeConfigHomeDirLocal(), AUTH_FILE) +} + +function codexAuthFilePath(): string { + return join( + process.env.CODEX_HOME ?? join(process.env.HOME ?? '', '.codex'), + 'auth.json', + ) +} +``` + +两个路径函数。`getValidChatGPTAuth`(`chatgptAuth.ts:339-344`)的读取顺序是:**先读 Claude 自己的 `~/.claude/openai-chatgpt-auth.json`,读不到再 fallback 读 Codex CLI 的 `~/.codex/auth.json`**,并打一条 debug 日志 `[OpenAI] Using ChatGPT auth from Codex auth.json`。 + +**为什么这么设计?** ChatGPT 订阅的 OAuth 设备码流程是 OpenAI 自己发的(`ISSUER = 'https://auth.openai.com'`,`CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'`,`chatgptAuth.ts:5-6`)。Codex CLI 用的是同一个 issuer 同一个 client_id(验证:`verificationUrl` 用 `${ISSUER}/codex/device`,`chatgptAuth.ts:217`)。两边走的是同一套令牌体系,令牌可以互换——所以让 Claude 复用 Codex 的凭证是合法的、不是"借用"。 + +**为什么不强制让用户在 Claude 这边也登录一次?** 因为 ChatGPT 订阅用户已经为这个 token 付过费、已经走完设备码握手了。让他在每个工具里都重做一次设备码登录(打开浏览器、输 userCode、等待授权)是明显的体验灾难。fallback 读 Codex 凭证把"一次登录、多个工具复用"变成可能。 + +**反向不成立**:Codex CLI 不会读 Claude 的凭证文件。这是有意的非对称——Claude 这边承认"我是后来的、我读你的",但 Codex CLI 作为 OpenAI 自家工具不知道 Claude 的存在。这种非对称在跨工具凭证共享里很常见:后入场的一方做兼容,先入场的一方保持简单。 + +**风险**:这个 fallback 假设 Codex CLI 的凭证文件格式稳定。如果某天 Codex CLI 改了 `auth.json` 的 schema(加字段、改字段名、嵌套层级变化),Claude 这边的 `readStoredAuth`(`chatgptAuth.ts:123`)就要跟着改。这是跨工具集成的固有脆弱性——**两边的格式没有契约约束,只靠"碰巧一致"维持**。如果 Codex CLI 那边改了,Claude 这边不会自动收到通知,要靠用户报"我用 ChatGPT 模式登录不了了"才会被发现。 + +### `install-github-app` 为什么是 React 多步表单,而不是一行 shell + +打开 `src/commands/install-github-app/install-github-app.tsx`,它 import 了 11 个 Step 组件:`ApiKeyStep` / `CheckExistingSecretStep` / `CheckGitHubStep` / `ChooseRepoStep` / `CreatingStep` / `ErrorStep` / `ExistingWorkflowStep` / `InstallAppStep` / `OAuthFlowStep` / `SuccessStep` / `WarningsStep`。一个简单的"装 GitHub App"为什么要拆这么多步? + +因为"装一个 GitHub App"在生产环境里至少有 11 个分支: + +- `gh` 装了吗?没装怎么办?(`CheckGitHubStep`) +- 用户想装到当前 repo 还是别的 repo?当前 repo 探测到了吗?(`ChooseRepoStep`) +- API key 用现有的还是新建?用 OAuth 还是 API key?(`ApiKeyStep`,`selectedApiKeyOption: 'new' | 'existing' | 'oauth'`,见 `install-github-app.tsx:36`) +- repo 里已经有同名 secret 了吗?要覆盖还是保留?(`CheckExistingSecretStep`) +- repo 里已经有 workflow 文件了吗?要装哪几个?(`ExistingWorkflowStep`,默认 `['claude', 'claude-review']`,见 `install-github-app.tsx:35`) +- 创建过程中出错了?错误长什么样、能不能重试?(`ErrorStep`、`CreatingStep`) +- 装完了有哪些警告?比如权限不够、repo 是 fork、org policy 限制?(`WarningsStep`) + +每一个分支都需要用户决策、都要展示状态。**用一行 shell 解决不了**——shell 是"我已知所有参数、一次性执行",而 GitHub App 安装是"边探测边问边装"。React 多步表单是这种"探测-决策-执行-反馈"循环的自然形态。 + +**契约**:`install-github-app` 写进用户 repo 的 workflow 文件内容是写死在 `src/constants/github-app.ts` 的 `WORKFLOW_CONTENT` 常量里——这是一个 GitHub Actions YAML 字符串,定义了 `issue_comment` / `pull_request_review_comment` / `issues` / `pull_request_review` 四类事件的触发条件(都是 `@claude` mention),跑在 `ubuntu-latest` 上,permissions 是 `contents: read` / `pull-requests: read` / `issues: read` / `id-token: write`。PR 标题也是常量 `PR_TITLE = 'Add Claude Code GitHub Workflow'`。**这些常量就是 Claude ↔ GitHub 的契约**——改 `WORKFLOW_CONTENT` 等于改所有未来用户装上去的 workflow 模板,要非常小心向后兼容。 + +### `/commit-push-pr` 的 `ALLOWED_TOOLS` 白名单为什么这么窄 + +看 `src/commands/commit-push-pr.ts:11-23`:只允许 `Bash(git ...)` 几条、`Bash(gh pr ...)` 几条、`SearchExtraTools`、两个 Slack 工具。**为什么不给它 `FileEdit` / `FileWrite`?** 因为 `/commit-push-pr` 的语义是"把已经做好的改动提交、推送、开 PR"——它不应该再修改代码。如果允许 `FileEdit`,这个命令就会从"提交工具"退化成"提交 + 顺手再改点代码"的工具,权限边界就乱了。 + +**为什么 Slack 工具要单列?** 看 `commit-push-pr.ts` 后面的 `slackStep` 逻辑:如果你的 CLAUDE.md 里写了"开完 PR 往 Slack 发链接",这个命令会尝试用 `SearchExtraTools` 找 Slack 工具,问你要不要发。这是一条**条件性集成**——不是所有用户都装了 Slack MCP,所以不能硬编码 `mcp__slack__send_message`,要走延迟工具搜索。如果没找到,silent skip,不打扰用户。 + +**`getPromptContent` 里的 `process.env.USER_TYPE === 'ant' && isUndercover()`**(`commit-push-pr.ts:33-40` 附近)是一个有意思的边界:Anthropic 内部用户跑这个命令时,会切到 "undercover" 模式——不暴露自己在用 Claude。这是 dogfooding 的产物:内部用户用自家工具时,对外(比如在公开的开源 repo 里开 PR)不能露出"这是 AI 写的"的痕迹。这个分支只在 `USER_TYPE === 'ant'` 时生效,普通用户看不到。 + +### 三种长驻模式(ACP / Bridge / Daemon)共享底层 query loop 但各有独立 entry + +这是设计大纲第十二章的核心论点在集成视角下的具体化。三者的关系: + +- **ACP**(`src/services/acp/`):`cli.tsx:123-124` 的 `--acp` fast-path,受 `feature('ACP')` 门控。进入 `src/services/acp/entry.ts`,spawn 一个 `AcpAgent`(`agent.ts`)。agent 把 ACP 客户端的请求桥接到内部的 query loop(`src/services/acp/bridge.ts`),权限决策走 `createAcpCanUseTool`(`src/services/acp/permissions.ts`)。 +- **Bridge**(`src/bridge/`):`cli.tsx:178-188` 的 `remote-control` / `rc` / `remote` / `sync` / `bridge` 五个别名 fast-path,受 `feature('BRIDGE_MODE')` 门控。进入 `src/bridge/bridgeMain.ts`,JWT 认证(`jwtUtils.ts`)、消息传输(`bridgeMessaging.ts`)、权限回调(`bridgePermissionCallbacks.ts`)。 +- **Daemon**(`src/daemon/`):`cli.tsx` 的 `daemon` 子命令,受 `feature('DAEMON')` 门控。`src/daemon/main.ts` 是 entry,`workerRegistry.ts` 管 worker,`--daemon-worker=` 派生精简 worker。 + +**共享的部分**:三者都最终调用 `src/query.ts` 的 `query()` async generator(见设计大纲第五章)。工具系统、Provider 路由、流式响应——这些都是共用的。**各自增加的编排层**:ACP 加了"会话管理 + 权限桥接 + prompt 排队",Bridge 加了"JWT 认证 + 远端消息传输 + 权限远程审批",Daemon 加了"worker 注册表 + 心跳 + 精简 worker 派生"。 + +**为什么三个要分开**:因为它们的**调用方不同**。ACP 的调用方是 IDE(同机 stdio),Bridge 的调用方是 RCS 后端(远端 JWT),Daemon 的调用方是 CI 或 supervisor(进程级 spawn)。三种调用方对认证、传输、生命周期的要求完全不同——IDE 不需要认证(已经在用户机器上)、RCS 必须认证(暴露在网络上)、Daemon 必须支持后台 + 心跳(长跑)。把这些塞进同一个 entry 会让代码变成"if (acp) {...} else if (bridge) {...} else if (daemon) {...}"的分支地狱。分开三个 entry、各自 feature-gated,是**用 entry 数量换 entry 简单度**的权衡。 + +**BYOC runner 是三条线的交汇点**:`claude environment-runner` / `claude self-hosted-runner`(见设计大纲第十二章)是这三条线和 CI(产品大纲第十一章)的交汇——它能让外部 CI 系统以 Bring-Your-Own-Compute 的方式调用 Claude,背后可能用 ACP(同机)、Bridge(远端)、或 Daemon(长跑)任意一种。这是"集成边界"最抽象的一层:用户不直接选 ACP/Bridge/Daemon,他选的是 environment-runner,由 runner 决定底下用哪种长驻模式。 + +### VS Code 桥接(`vscode-ide-bridge/`)的现状 + +CLAUDE.md 提到 `vscode-ide-bridge/` 是"VS Code 桥接"辅助目录。**但这个目录在当前仓库里实际不存在**(`ls` 返回空)。VS Code 集成实际走的是 `/ide` 命令 + VS Code 扩展(扩展是独立分发的,不在本仓库里),不是通过这个目录里的代码。`vscode-ide-bridge/` 在仓库的某个历史版本里存在过、后来被移除或合并到 `src/commands/ide/`——`CLAUDE.md` 的描述滞后了。**这是反编译重建工作的典型痕迹**:文档描述的是"原本应该有什么",代码里实际是"重建后剩下了什么"。 + +## 两视角如何呼应 + +用户视角的每一个"我能接什么"的清单,几乎都能在设计视角找到对应的契约和决策: + +- **"我能在 VS Code / Zed / Cursor 里用 Claude 吗"**(产品视角)对应 **"为什么 IDE 走 MCP 的 `sse-ide` / `ws-ide` 子类型、为什么 ACP agent 用 stdio NDJSON"**(设计视角)——用户看到的是"装个扩展、`/ide` 一连就行",开发者看到的是"`dynamicMcpConfig` 的 `ide` key 用了专门的 type、ACP 协议形态选择 stdio 是为了 IDE spawn 子进程最简单"。 +- **"我能不能让远端调用我机器上的 Claude"**(产品视角)对应 **"acp-link 为什么是 WebSocket → stdio 桥接、自托管 RCS 为什么是 Docker + Web UI"**(设计视角)——用户看到的是"`claude remote-control` 一跑、Web UI 一开就能用",开发者看到的是"三种长驻模式(ACP / Bridge / Daemon)共享 query loop 但各有独立 entry、用 entry 数量换 entry 简单度"。 +- **"我在 Codex CLI 登录过、Claude 这边能复用吗"**(产品视角)对应 **"为什么 ChatGPT 订阅凭证要 fallback 读 `~/.codex/auth.json`"**(设计视角)——用户看到的是"不用再登录一次",开发者看到的是"两边用同一 issuer 同一 client_id、令牌可互换、但 schema 没有契约约束只靠碰巧一致"。 +- **"我能在 GitHub Actions 里用 Claude 吗"**(产品视角)对应 **"`install-github-app` 为什么是 React 多步表单、`/commit-push-pr` 的 `ALLOWED_TOOLS` 白名单为什么这么窄"**(设计视角)——用户看到的是"`claude install-github-app` 一键装、`@claude` 一 at 就触发",开发者看到的是"11 个 Step 组件对应 11 个分支、`WORKFLOW_CONTENT` 常量是 Claude ↔ GitHub 的契约、白名单用'允许什么'定义命令的语义边界"。 +- **"我的 key 会不会被别的工具读到"**(产品视角)对应 **"跨工具凭证共享为什么只有 ChatGPT 订阅路径、为什么反向不成立"**(设计视角)——用户看到的是"除了 ChatGPT 订阅路径、其他 key 都不共享",开发者看到的是"后入场的一方做兼容、先入场的一方保持简单的非对称设计"。 +- **"`vscode-ide-bridge/` 是什么"**(产品视角用户翻 CLAUDE.md 看到的)对应 **"反编译重建工作的典型痕迹——文档描述原本应该有什么、代码里实际剩下了什么"**(设计视角)——用户看到的是"文档里提到了一个目录",开发者看到的是"那个目录在当前仓库里实际不存在、VS Code 集成走的是 `/ide` + 独立扩展"。 + +这种呼应关系是"与其他工具集成"必须双视角覆盖的核心原因:用户视角告诉你**怎么接**,设计视角告诉你**接的边界在哪、契约长什么样、哪些描述滞后于代码**。两个视角合在一起,才能让使用者正确判断"我现在的接法是不是最优、要不要换一种",也让开发者在加新集成时知道"哪些约束(凭证隔离、协议形态、feature 门控、entry 分离)是必须遵守的"——而不是把每个集成都重新发明一遍。 diff --git a/docs/outline-output/cross/06-observability.md b/docs/outline-output/cross/06-observability.md new file mode 100644 index 000000000..f09972273 --- /dev/null +++ b/docs/outline-output/cross/06-observability.md @@ -0,0 +1,157 @@ +# 可观测性 + +> 同一个"我想知道 Claude 在做什么"的诉求,在使用者眼里是"它现在到底卡在哪一步、这次回答烧了多少 token、能不能把这次对话导出来给同事看",在开发者眼里是"为什么 Langfuse 追踪必须从 `getAPIProvider()` 取单一真相源、为什么 `performanceShim` 必须抢在 React/OTel 之前装上、为什么 `--dump-system-prompt` 要被 feature flag 锁死"。可观测性天然是双视角主题——用户想知道"我能不能看见、怎么看",开发者想知道"探针插在哪、插这个位置要付出什么代价、会不会反过来把会话拖垮"。 + +## 产品视角(写给使用者) + +这一节回答一个高频但被低估的问题:**Claude 在帮我跑任务的时候,我自己怎么知道它正在干什么、干得对不对、花了多少?** 答案按"你想看什么"分四类工具,从轻到重排列。 + +### 第一类:我想看它现在在做什么(实时观测) + +你在 REPL 里发完一条消息,最直接的观测就是屏幕本身——流式回复、工具调用、权限弹窗、token 状态栏,这些都是"被动观测":你不主动做什么,它们自己会显示。但当会话变长、工具链变深(比如一个 Agent 派了三个子代理、每个子代理又跑了若干次 Bash + FileEdit),光靠屏幕就不够了。这时候有两条主动路径: + +- **`/debug-tool-call [N]`**:列出本会话最后 N 次工具调用(默认 5)的输入与输出。源码在 `src/commands/debug-tool-call/index.ts`,它不依赖任何远程服务,直接读会话日志(JSONL transcript,路径由 `getTranscriptPath()` 在 `index.ts:33` 决定,位于 `~/.claude/projects//.jsonl`)。用法场景很具体——"刚才那次 FileEdit 把哪一行改错了"、"Agent 派的子代理到底跑了什么命令",不用翻整个 transcript 文件。注意它只显示 tool_use + tool_result 配对,纯文本回复不在这张表里。 +- **状态栏的 token 数字**:每次 API 调用结束,REPL 状态栏会刷新 input/output/cache token。想看历史累积、单次费用估算,用 `/cost`(本次会话总费用)、`/usage`(按模型拆分的用量)、`/stats`(更细的统计)。这三个命令读的都是同一份 usage 累加器,区别只是聚合粒度。 + +### 第二类:我想把每次 API 调用、每个工具调用都记下来(Langfuse 追踪) + +如果你在做长任务、调试 prompt、或者想把 Claude 的行为变成可回放的训练数据,屏幕不够用——你需要结构化的请求链路。这就是 Langfuse 集成的用途。打开 `docs/features/tools/langfuse-monitoring.md`,它是一个开源 LLM 可观测性平台,CCB 通过 OpenTelemetry 桥接进去。**核心只需要三个环境变量**: + +| 环境变量 | 说明 | +|---------|------| +| `LANGFUSE_PUBLIC_KEY` | Langfuse 公钥(必填) | +| `LANGFUSE_SECRET_KEY` | Langfuse 密钥(必填) | +| `LANGFUSE_BASE_URL` | 服务地址,默认 `https://cloud.langfuse.com`;自部署时改成你的地址 | + +推荐写进 `.claude/settings.json` 的 `env` 字段,每次启动自动生效。**没配这三个变量时所有追踪函数都是 no-op、零开销**——不用担心开了它拖慢响应。配齐之后,每次 API 请求、每次工具调用都会被打成 span 发到 Langfuse,你在面板里能看到: + +- **LLM 调用**:模型名、Provider、输入/输出消息、token 用量(含 cache_creation / cache_read)、首 token 耗时(TTFT)、总耗时 +- **工具执行**:工具名、输入、输出、耗时、错误 +- **多 Agent 链路**:主 Agent 和子 Agent 各有独立 trace,能在面板里看到父子关系 +- **自动脱敏**:API key、文件内容片段、shell 输出里的敏感字段会被遮蔽(实现见 `src/services/langfuse/sanitize.ts`) + +其他可选参数(`LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_FLUSH_AT` / `LANGFUSE_FLUSH_INTERVAL` / `LANGFUSE_EXPORT_MODE` / `LANGFUSE_TIMEOUT`)见 `docs/features/tools/langfuse-monitoring.md:49-57` 的表格,按需调。 + +### 第三类:我想知道系统提示长什么样(`--dump-system-prompt`) + +一个常见疑问:"Claude 每次开头那长长一串系统提示到底是什么?CLAUDE.md 真的被读进去了吗?" `claude --dump-system-prompt` 会渲染并打印当前模型对应的系统提示,然后直接退出——不进入 REPL、不发任何 API 请求。可选 `--model ` 指定模型。用法: + +```bash +claude --dump-system-prompt +claude --dump-system-prompt --model claude-sonnet-4-5 +``` + +**注意**:这条 fast-path 受 `feature('DUMP_SYSTEM_PROMPT')` 门控(`src/entrypoints/cli.tsx:93`),主要用于 prompt sensitivity eval 在特定 commit 上提取系统提示。**外部构建产物里这条路径会被编译期剔除**,dev 模式默认开启。如果你跑 `claude --dump-system-prompt` 没有任何输出,多半是当前构建禁用了这个 feature。 + +### 第四类:我想用调试器接进去(`BUN_INSPECT` + `dev:inspect`) + +当 Claude 行为异常、你想看运行时变量值或断点单步,用 Bun 内置的 V8 inspector。两条路径: + +- **开发模式**:`bun run dev:inspect`(实际跑 `scripts/dev-debug.ts`)。它读 `BUN_INSPECT` 环境变量作为端口,默认会 await inspector 连上再继续执行,适合断在启动早期。 +- **指定端口**:`BUN_INSPECT=9229 bun run dev:inspect`。然后用 Chrome `chrome://inspect` 或 VS Code 的 Bun 调试器连 `ws://localhost:9229`。 + +注意这是开发自检工具,不是给最终用户的——它要求你能在仓库里 `bun install` 后跑 dev 模式。普通使用者想看"它在做什么",用前两类的命令就够了。 + +### 一句话总结这四类 + +| 我想看 | 用什么 | 代价 | +|--------|--------|------| +| 当前会话的工具调用 | `/debug-tool-call` | 零(读本地 transcript) | +| 历次 API 调用 + token 用量 | `/cost` `/usage` `/stats` | 零(读本地累加器) | +| 完整请求链路(可回放) | Langfuse(`LANGFUSE_*` 环境变量) | 配齐才启用,未配零开销 | +| 系统提示长什么样 | `claude --dump-system-prompt` | feature-gated,外部构建可能被剔除 | +| 运行时变量 / 断点 | `BUN_INSPECT=9229 bun run dev:inspect` | 需要开发环境 | + +## 设计视角(写给开发者) + +设计大纲原本几乎没有"观测的注入点"这一节——只有第七章锚点提到 `claude.ts:2999`。这一节补上:探针插在哪、为什么插在那里、插这个位置要付出什么代价。读完之后你应该能回答:"如果我要加一个新的观测维度(比如工具执行的 p99 latency),应该挂在哪一行、为什么不能挂在那行之前"。 + +### 为什么 Langfuse 追踪的 `provider` 字段必须从 `getAPIProvider()` 取单一真相源 + +打开 `src/services/api/claude.ts:2997-2999`: + +```ts +// Record LLM observation in Langfuse (no-op if not configured) +recordLLMObservation(options.langfuseTrace ?? null, { + model: resolvedModel, + provider: getAPIProvider(), +``` + +`provider` 字段的值直接来自 `getAPIProvider()`——整个项目里唯一一个"当前用哪个 Provider"的真相源。`getAPIProvider()`(`src/utils/model/providers.ts:15`)按 `modelType` 参数 > `CLAUDE_CODE_USE_*` 环境变量 > firstParty 默认 这条优先级链返回字符串。 + +**为什么不另起一个变量、不读 `process.env.CLAUDE_CODE_USE_OPENAI` 这种直接环境变量?** 因为 Provider 选择有运行时动态性。`/provider openai` 命令会清掉所有 `CLAUDE_CODE_USE_*` 然后写新的配置(`src/commands/provider.ts:39`),这一步走 `applyConfigEnvironmentVariables` 把配置反推回 `process.env`。如果在 Langfuse 这边直接读 `process.env.CLAUDE_CODE_USE_OPENAI`,就有两个风险:一是和 `/provider` 命令的写入时机产生 race,二是兼容层(OpenAI / Gemini / Grok)各自有不同的 env var 名,硬编码会漏。 + +**`getAPIProvider()` 作为单一真相源的设计红利**:`/provider` 命令、模型映射(`resolveOpenAIModel` / `resolveGeminiModel` / `resolveGrokModel`)、Langfuse 追踪——三个看似不相关的子系统都从同一个函数取值。只要 `getAPIProvider()` 正确,这三个地方的 Provider 字段必然一致。这是"单一真相源"原则的教科书例子:观测数据天然就应该和决策数据同源,否则面板上看到的 Provider 和实际跑的不一致,追踪就失去了意义。 + +**代价**:`getAPIProvider()` 不是纯函数,它每次调用都要走一遍优先级链解析。在 `claude.ts:2997` 这个位置(每次 API 响应结束后调用一次)是可接受的——一次 turn 调一次,不在热路径里。但如果你想把 provider 字段加到更高频的观测点(比如每个流式 chunk),就不能再调 `getAPIProvider()` 了,得缓存结果。 + +### 为什么 `recordLLMObservation` 是 fire-and-forget,不是 await + +看 `claude.ts:2997` 的调用——它没有 `await`。`recordLLMObservation` 在 `src/services/langfuse/tracing.ts:85` 是 async function,但调用方不等它。 + +**为什么?** 观测不该阻塞主路径。Langfuse 走 OTel exporter,批量异步发到远端(`LANGFUSE_FLUSH_AT=20` 默认 20 条 span 攒一批)。如果 `await recordLLMObservation(...)`,每次 API 响应都要等网络 round-trip,用户看到的 TTFT 会暴涨。fire-and-forget 让观测在后台跑,主路径零延迟。 + +**代价**:观测失败用户感知不到。`tracing.ts:178` 里有一行 `logForDebugging('[langfuse] recordLLMObservation failed: ...')`——失败只打 debug 日志,不抛、不告警。这是有意的:观测是辅助、不是必需。如果 Langfuse 挂了,Claude 本身必须照常工作。`isLangfuseEnabled()`(`src/services/langfuse/client.ts:13`)只检查 `LANGFUSE_PUBLIC_KEY` 和 `LANGFUSE_SECRET_KEY` 是否存在——未配置时整条链路是 no-op,连 fire-and-forget 的开销都没有。 + +### 为什么 `performanceShim` 必须最先 import,OTel 才能正常工作又不会撑爆内存 + +打开 `src/utils/performanceShim.ts:1-17` 的文件头注释——这是整个项目最强烈的"必须最先 import"约束(在 `src/entrypoints/cli.tsx` 的第一行 import)。背景:Bun 的 `globalThis.performance` 是 JSC 原生 Performance 对象,它的 marks / measures / resource timings 存在一个**永不收缩的 C++ Vector**。长会话(daemon / `/loop`)持续累积,能撑出几百 MB 死容量。 + +**这跟可观测性有什么关系?** 因为 Langfuse 走 OTel,OTel 的 performance exporter(`otperformance`)会大量调用 `performance.mark()` 和 `performance.measure()` 来打 span 计时。**如果没有 shim**,每个 OTel span 都会在 C++ Vector 里留一条永不释放的 entry——观测越勤,内存爆得越快。这是"观测反向拖垮被观测对象"的经典反例。 + +`performanceShim` 的解决方案(`performanceShim.ts:127-155`):保留 `performance.now()` 走原生(快、零内存成本——OTel 用它打时间戳),劫持 `mark` / `measure` / `getEntries` / `clearMarks` 走 JS Map(GC 能回收)。**必须在 React reconciler 和 OTel import 之前装上**,否则它们会捕获原生 Performance 的引用,shim 装了也劫持不到。 + +**这条约束的代价**:`performanceShim` 永远是 `cli.tsx` 的第一行。如果你写了一个新模块、它在 import 阶段就碰 performance(比如模块顶层 `performance.mark('foo')`),你必须保证它 import 在 shim 之后。这就是为什么 `cli.tsx` 的 import 顺序不能随便调。 + +### 为什么 query.ts 的 finally 块要兜底 clearMarks + +打开 `src/query.ts:367-379`: + +```ts +// 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 { ... } +``` + +这是 performanceShim 的第二道防线。**为什么有了 shim 还要在这里兜底?** 因为 sub-agent 会直接 `import query from 'src/query.ts'`,不走 `cli.tsx` 的入口。如果某个 sub-agent 启动路径上 shim 没装上(比如测试环境、或某种奇怪的 import 顺序),原生的 C++ Vector 就会开始累积。`query()` 是所有 turn 的共同出口,在它的 finally 块兜底一次 `clearMarks`,是"shim 万一没装上"的最后保险。 + +**注释里有意思的一句话**:"even after spans are flushed and nullified"——OTel 自己 flush span 之后会把自己持有的引用置空,但**原生 Performance 的 Vector 不会被 OTel 清**。OTel 和 Performance 是两个独立的累积源,OTel 的清理不覆盖 Performance。这是 JSC 实现的细节,也是 shim 必须劫持 mark/measure 而不是依赖 OTel 自己清理的根因。 + +### 为什么 `--dump-system-prompt` 必须 feature-gated + +看 `cli.tsx:90-104` 的 fast-path:`feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt'`。注释说得很清楚:"Used by prompt sensitivity evals to extract the system prompt at a specific commit. Ant-only: eliminated from external builds via feature flag." + +**为什么这么谨慎?** 系统提示是产品的核心 IP——它定义了 Claude 的行为、约束、工具使用风格。`--dump-system-prompt` 把它原样 stdout 出来,等于把 IP 暴露给任何能跑这个命令的人。feature flag 让这条路径在内部 eval 场景(CI 跑 prompt 回归)可用、在外部构建产物里编译期剔除——DCE 直接把整段 if 删掉,连字符串"`--dump-system-prompt`"都不出现在外部产物里。 + +**这条路径本身的设计也很克制**:它不发任何 API 请求,只渲染系统提示然后 exit(`cli.tsx:102-103`)。`getSystemPrompt([], model)` 传空 messages 数组——因为系统提示不依赖对话内容,只依赖模型(不同模型的 prompt 略有差异)。如果你想 debug "我的 CLAUDE.md 到底有没有被读进去",`--dump-system-prompt` 是最直接的工具,但前提是你跑的构建启用了这个 feature。 + +### 为什么 `/debug-tool-call` 不走远程服务、只读本地 transcript + +打开 `src/commands/debug-tool-call/index.ts`——整个命令没有任何网络调用。`getTranscriptPath()`(`index.ts:33-43`)返回本会话的 JSONL 路径,`parseToolCallsFromLog()`(`index.ts:85-119`)逐行 parse JSON、按 `tool_use_id` 配对 use 和 result。 + +**为什么不走 Langfuse?** 两个原因: + +1. **零依赖原则**:`/debug-tool-call` 是诊断工具,诊断工具不能依赖被诊断的东西。如果 Langfuse 挂了、网络断了、配置错了,用户跑 `/debug-tool-call` 还得能看到工具调用——这是排错最后一道防线,必须本地可用。 +2. **新鲜度**:transcript 是本会话刚写下去的,Langfuse 是批量异步发的(`LANGFUSE_FLUSH_AT=20`),有延迟。"`/debug-tool-call` 显示的就是刚才那一次"和"显示的是 20 个 span 之前那一次",对排错体验差别巨大。 + +**代价**:transcript 文件格式是会话私有的 JSONL schema,没有跨工具兼容承诺。如果未来 transcript 格式改了,`parseToolCallsFromLog` 的字段访问(`block.type === 'tool_use'` / `block.tool_use_id` 等)要同步改。这是"零依赖"换"零网络"的固有成本。 + +## 两视角如何呼应 + +用户视角的每一个"我想看什么",在设计视角都能找到对应的注入点决策: + +- **"我想看这次 API 调用烧了多少 token、用的哪个 Provider"**(产品视角的 `/cost` `/usage` + Langfuse 面板)对应 **"`provider` 字段为什么必须从 `getAPIProvider()` 取、`recordLLMObservation` 为什么是 fire-and-forget"**(设计视角)——用户看到的是面板里一行清晰的 `provider: openai`,开发者看到的是"单一真相源 + 异步不阻塞主路径"的双重决策,否则要么面板字段和实际跑的不一致,要么 TTFT 被观测拖慢。 +- **"我想看 Claude 的完整请求链路,可回放"**(产品视角的 Langfuse)对应 **"performanceShim 为什么必须最先 import、query.ts 的 finally 块为什么兜底 clearMarks"**(设计视角)——用户看到的是"开了 Langfuse 长跑也不卡",开发者看到的是"OTel 越勤、JSC 原生 Performance 的 C++ Vector 撑得越快,shim + finally 双保险把累积源掐死在 GC 能回收的 JS 内存里"。如果这个决策做错了,观测本身会把会话拖崩——这是可观测性章节必须双视角覆盖的最强理由。 +- **"我想知道系统提示到底长什么样"**(产品视角的 `--dump-system-prompt`)对应 **"为什么这条 fast-path 必须 feature-gated、为什么外部构建编译期剔除"**(设计视角)——用户看到的是"`claude --dump-system-prompt` 一跑就有",开发者看到的是"系统提示是核心 IP、DCE 在编译期把整段 if 删掉、外部产物连这个字符串都不出现"。 +- **"我想看刚才那次工具调用的输入输出"**(产品视角的 `/debug-tool-call`)对应 **"为什么它只读本地 transcript、不走 Langfuse"**(设计视角)——用户看到的是"零延迟、零配置就能用",开发者看到的是"诊断工具不能依赖被诊断的东西 + 新鲜度优先于跨工具兼容性"的双重原则。 +- **"我想断点单步看运行时变量"**(产品视角的 `BUN_INSPECT=9229 bun run dev:inspect`)对应 **"`bun run dev:inspect` 走 `scripts/dev-debug.ts`、读 `BUN_INSPECT` 环境变量决定端口"**(设计视角)——用户看到的是"端口一连、断点就生效",开发者看到的是"开发自检工具要求仓库可 `bun install`、普通使用者用前几类命令就够了"。 + +这种呼应关系是"可观测性"必须双视角覆盖的核心原因:用户视角告诉你**怎么看**,设计视角告诉你**探针插在哪里、这个位置会不会反过来把会话拖垮、哪些观测路径受 feature 门控**。两个视角合在一起,才能让使用者正确选择观测工具的层级(被动看屏幕 → `/debug-tool-call` → Langfuse → `--dump-system-prompt` → `dev:inspect`,按介入深度递增),也让开发者在加新观测维度时知道"挂在 `getAPIProvider()` 同源、走 fire-and-forget、注意 performanceShim 已经装好"——而不是把每个探针都重新设计一遍、甚至不小心把观测路径变成新的内存泄漏源。 diff --git a/docs/outline-output/cross/07-credentials-auth.md b/docs/outline-output/cross/07-credentials-auth.md new file mode 100644 index 000000000..dd8e4a150 --- /dev/null +++ b/docs/outline-output/cross/07-credentials-auth.md @@ -0,0 +1,260 @@ +# 凭证与认证生命周期 + +> 同一份"我的令牌存在哪、什么时候过期、改了 key 为什么没生效"的困惑,在使用者眼里是"我刚才输的那串 sk-... 到底被写到了哪个文件、能不能给同事看、明天还会不会自动登录",在开发者眼里是"为什么 `getOpenAIClient` 要做模块级缓存、为什么 ChatGPT 订阅路径要去读 Codex CLI 的 `~/.codex/auth.json`、为什么 OAuth 刷新要留 5 分钟偏差窗口、为什么 `/provider unset` 只清 Provider 不清 key"。凭证生命周期天然是双视角主题——用户想知道"我的密钥去了哪里、安不安全",开发者想知道"为什么 token 这么存、这个缓存策略逼出了哪些权衡、跨工具复用凭证是怎么落到代码里的"。 + +## 产品视角(写给使用者) + +这一节回答一个几乎每个新用户都会撞上的问题:**我的密钥和登录令牌,到底去了哪里?什么时候会过期?我改了 key 为什么有时候不生效?** 我们按"凭证存哪 → 怎么登录 → 怎么刷新 → 怎么排错"四段走,每段都给你能直接照做的步骤。 + +### 第一件事:搞清楚你的凭证存在哪个文件 + +Claude Code 的凭证不是一个统一的地方,而是**按 Provider 分散在好几个文件**。下面这张清单是你需要知道的全部位置(默认 `CLAUDE_CONFIG_DIR` 没被改写时,它等于 `~/.claude`): + +| 凭证类型 | 存储位置 | 谁会写它 | 谁会读它 | +|---------|---------|---------|---------| +| Anthropic OAuth 令牌(claude.ai 订阅) | `~/.claude/.credentials.json` | `/login` OAuth 流程、自动刷新 | `getAnthropicClient` 每次 API 调用前 | +| 自定义 Anthropic API Key(workspace key) | `~/.claude.json`(userSettings 的 `workspaceApiKey` 字段) | `/login` 里按 W 输入 | `getAuthStatus` / `getAnthropicApiKey` | +| `ANTHROPIC_API_KEY` 环境变量 | 你的 shell 配置(`.zshrc` / `.bashrc` / CI secrets) | 你自己 | 优先级低于 settings 里的 `workspaceApiKey` | +| ChatGPT 订阅令牌(用 ChatGPT 订阅当后端) | `~/.claude/openai-chatgpt-auth.json` | `/login` 选 "ChatGPT account" 后写 | `getValidChatGPTAuth` 每次 OpenAI 请求前 | +| Codex CLI 共享令牌(跨工具复用) | `~/.codex/auth.json` | OpenAI 官方 Codex CLI | Claude Code 找不到自己的 chatgpt 凭证时会回退读它 | +| OpenAI / Gemini / Grok 兼容层 API Key | `~/.claude/settings.json` 的 `env` 字段(`OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 或 `XAI_API_KEY`) | `/login` 表单填写 | 各 Provider 的 client 实例化时读 `process.env` | +| Bridge 模式的会话 JWT | 运行时签发,`sk-ant-si-` 前缀 | Remote Control 服务端 | Bridge 每次请求带在 Authorization 头 | +| 个人覆盖配置(`settings.local.json`) | `~/.claude/settings.local.json` | 你手动编辑 | 不进 git,覆盖 `settings.json` | + +**怎么自查**:跑 `/login` 命令,第一屏的 `AuthPlaneSummary`(`src/commands/login/AuthPlaneSummary.tsx`)会把当前生效的凭证来源摘要给你看——是 env var 还是 settings、有没有 workspace key、是不是 claude.ai 订阅。**这个摘要永远不会回显密钥原文**(`getAuthStatus` 的注释明确写了 "ANTHROPIC_API_KEY / workspaceApiKey values are NEVER returned raw; only their presence and source"),所以你截图给同事看是安全的。 + +### 第二件事:用 `/login` 还是手动改配置?四种登录方式怎么选 + +Claude Code 支持四种登录路径,选择哪一种取决于你有什么: + +1. **claude.ai 订阅账号(Anthropic OAuth)**:在 `/login` 的 ConsoleOAuthFlow 里走 OAuth 设备码流程——它会给你一个 URL 和一个 code,浏览器打开、授权、回来。成功后令牌写进 `~/.claude/.credentials.json`。这是推荐路径,因为它走 Anthropic 官方 OAuth,token 自动刷新、不需要你管过期。 + +2. **Anthropic API Key(直连 API)**:两种方式。一是 `export ANTHROPIC_API_KEY=sk-ant-...` 写进 shell;二是在 `/login` 里按 W,输入 key,它会存到 `~/.claude.json` 的 `workspaceApiKey`("workspace" 是因为按工作目录可覆盖)。**settings 里的 key 优先级高于 env var**——如果你两个都设了,settings 赢。 + +3. **ChatGPT 订阅当后端(复用 OpenAI 订阅)**:`OPENAI_AUTH_MODE=chatgpt` 打开后,`/login` 会走 OpenAI 的设备码流程(`https://auth.openai.com/codex/device`),成功后令牌写进 `~/.claude/openai-chatgpt-auth.json`。**这条路径最大的彩蛋是跨工具共享**:如果你之前装过 OpenAI 官方的 Codex CLI,它的令牌存在 `~/.codex/auth.json`,Claude Code 在自己的文件找不到时会自动回退读 Codex 的(`getValidChatGPTAuth` 的第二段,`src/services/api/openai/chatgptAuth.ts:339-346`)。换句话说:**你在 Codex CLI 登录过,Claude Code 直接就能用,不用重复登录**。 + +4. **OpenAI 兼容 / Gemini / Grok / 中国 LLM**:全部走 `/login` 的表单填写流程。选 Provider、填 Base URL(OpenAI 兼容层必填)、填 Key、选模型。提交后写入 `~/.claude/settings.json` 的 `env` 字段,同时把 `modelType` 改成对应的 Provider。**中国 LLM 是这条路径的一个精巧分支**:在 ConsoleOAuthFlow 里选 "China LLM Provider"(`src/components/ConsoleOAuthFlow.tsx:1294` 的 `china_provider_select` 表单),会给你一个预设列表,目前包含 DeepSeek、智谱 GLM、通义千问、小米 MiMo 四家(`src/utils/chinaLlmProviders.ts:44` 的 `CHINA_LLM_PROVIDERS`),每家还分"按量计费 API"和"包月 Coding Plan"两档 base URL。选完之后它自动填好 base URL、你只需要填 key,不用记地址。 + +**一个重要差别**:前三种(claude.ai 订阅 / API Key / ChatGPT 订阅)属于"认证",后一种(OpenAI 兼容层 / Gemini / Grok)属于"换 Provider"。`/login` 命令同时处理两件事,但 `/provider` 只处理后者——见下文排错段。 + +### 第三件事:令牌什么时候过期、怎么自动刷新 + +如果你用 claude.ai 订阅或 ChatGPT 订阅,**你不需要手动刷新令牌**。Claude Code 在每次 API 调用前会检查令牌是否快过期,快过期就自动刷新。 + +**关键的时间窗口是 5 分钟偏差**。无论是 Anthropic OAuth 还是 ChatGPT OAuth,代码都用同一个常量: + +- Anthropic OAuth:`isOAuthTokenExpired`(`src/services/oauth/client.ts:344`)用 `bufferTime = 5 * 60 * 1000`(5 分钟)。当前时间 + 5 分钟 ≥ 过期时间就认为"快过期",触发刷新。 +- ChatGPT OAuth:`REFRESH_SKEW_MS = 5 * 60 * 1000`(`src/services/api/openai/chatgptAuth.ts:9`),同样的 5 分钟窗口。 + +**为什么是 5 分钟不是 1 分钟?** 这是容错设计:API 请求的端到端延迟(包括网络、排队、模型推理)可能就有几秒到几十秒。如果你卡在"过期前 10 秒才刷新",刷新完成时令牌可能已经过期了,请求被拒。5 分钟窗口给整个请求链路留出足够余量——刷新完拿到新令牌,再用它发请求,时间上稳稳的。 + +**多进程场景**:如果你同时开了几个 Claude Code 终端,它们都会发现令牌过期、都想去刷新。`checkAndRefreshOAuthTokenIfNeededImpl`(`src/utils/auth.ts:1443`)用了 `lockfile.lock(claudeDir)` 文件锁——谁先抢到锁谁刷新,其他进程等锁、拿到锁后再检查一次令牌是否已被刷新("double-checked locking"),是的话直接用新令牌、不重复刷新。**还有一个跨进程失效机制**(`invalidateOAuthCacheIfDiskChanged`,`auth.ts:1316`):进程 A 的 `/login` 写了新令牌到 `.credentials.json`,进程 B 通过 mtime 检测到文件变了,清掉自己的内存缓存、重读——避免"B 用 A 早就 revoke 掉的旧令牌反复 401"的死循环。 + +### 第四件事:我改了 API key 但没生效?三个最常见的"为什么" + +这是排错章节里最高频的三个困惑,全部跟凭证生命周期有关。 + +**困惑 A:我在 `/login` 输了新 key,为什么下一个请求还在用旧的?** + +如果你切的是 claude.ai 订阅或 Anthropic API Key(`workspaceApiKey`),`/login` 的 `onDone` 回调(`src/commands/login/login.tsx:33-65`)会做一连串副作用:`stripSignatureBlocks`(清掉绑旧 key 的签名块)、`resetCostState`(重置费用统计)、`authVersion++`(强制 hook 重新拉取 auth 相关数据)。这些做完之后下一次请求就是新 key。 + +但如果你切的是 **OpenAI 兼容层 / Grok**,就要小心了:`getOpenAIClient`(`src/services/api/openai/client.ts:39`)和 `getGrokClient`(`src/services/api/grok/client.ts:15`)都是**模块级缓存客户端实例**——首次调用读 `process.env.OPENAI_API_KEY` 创建 OpenAI SDK 实例,之后整个会话直接返回这个缓存的实例。你在会话中途改了 `process.env.OPENAI_API_KEY`,缓存里的 client 还握着旧 key。 + +**解决办法**:要么重启 Claude Code(最简单),要么代码层面调一次 `clearOpenAIClientCache()`(`client.ts:76`)或 `clearGrokClientCache()`(`grok/client.ts:42`)。**注意**:`/login` 表单改 key 的流程会同步更新 `process.env`(`ConsoleOAuthFlow.tsx:1464-1470` 的 `process.env[k] = v` 循环),但**不会自动 clear client cache**——这是已知的"改 key 必须重启"陷阱,尤其影响 dev 模式下的迭代调试。 + +**困惑 B:我跑了 `/provider unset`,为什么 key 还在?** + +`/provider unset`(`src/commands/provider.ts:49-62`)只清 Provider 选择本身——它 `delete` 的是 `CLAUDE_CODE_USE_BEDROCK` / `CLAUDE_CODE_USE_VERTEX` / `CLAUDE_CODE_USE_FOUNDRY` / `CLAUDE_CODE_USE_OPENAI` / `CLAUDE_CODE_USE_GEMINI` / `CLAUDE_CODE_USE_GROK` 这一组 Provider 触发变量,并把 `settings.json` 的 `modelType` 清空。**它不会清 `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 这些 key 本身**。 + +这是有意为之——`unset` 的语义是"回到默认 Provider(firstParty)",不是"清空所有认证"。如果你想彻底清掉某个 Provider 的 key,要手动编辑 `~/.claude/settings.json` 的 `env` 字段,或者 `/logout`(见下文)。 + +**例外**:如果你切到的是 bedrock / vertex / foundry 这三个云 Provider(`provider.ts:147-161` 的 else 分支),代码会顺手 `delete process.env.OPENAI_API_KEY` 和 `delete process.env.OPENAI_BASE_URL`——因为这些云 Provider 不应该带着 OpenAI 的 key 跑。但 gemini 和 grok 的 key 不会被清。 + +**困惑 C:我设了 `OPENAI_BASE_URL` 指向自己的端点,为什么有些行为还像在调官方 API?** + +这是 `isFirstPartyAnthropicBaseUrl()` 的 TODO 陷阱(`src/utils/model/providers.ts:43-58`)。代码注释直白地写着:"这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题"。 + +具体症状:`buildFetch`(`src/services/api/client.ts:366-367`)会在 `getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()` 都为真时,给每个请求注入一个 `x-client-request-id` header(用于服务端日志关联)。但 `isFirstPartyAnthropicBaseUrl()` 只看 `ANTHROPIC_BASE_URL`,不看 `OPENAI_BASE_URL`。如果你只设了 `OPENAI_BASE_URL` 指向自托管端点、没设 `ANTHROPIC_BASE_URL`,`isFirstPartyAnthropicBaseUrl()` 会因为 `ANTHROPIC_BASE_URL` 不存在而返回 `true`,然后这个注入逻辑就被错误地激活了。**目前没有完美绕过**,只能同时设 `ANTHROPIC_BASE_URL` 显式指向你的端点(哪怕你不调 Anthropic 协议)来让判断走 host 比较分支。 + +### 第五件事:`/logout` 到底清掉了什么 + +`/logout`(`src/commands/logout/logout.tsx`)是"全部清空"按钮。`performLogout` 会做这一串: + +1. `flushTelemetry`(**先** flush 再清凭证,避免清了之后还拿着旧 org 的 telemetry 数据往外发) +2. `removeApiKey`(清 Anthropic API Key) +3. `removeChatGPTAuth`(删 `~/.claude/openai-chatgpt-auth.json`) +4. `clearChatGPTSettingsAuthMode`(清 `OPENAI_AUTH_MODE` env 和 settings) +5. `secureStorage.delete()`(清安全存储——macOS keychain 或 fallback) +6. `clearAuthRelatedCaches`(清 OAuth token 缓存、betas 缓存、tool schema 缓存、user cache、Grove 配置缓存、远程管理 settings 缓存、policy limits 缓存) +7. `saveGlobalConfig` 改 `oauthAccount: undefined`(清账号关联) +8. **2 秒后 `gracefulShutdownSync(0, 'logout')`**——logout 之后进程会退出 + +**所以 `/logout` 之后你必须重新 `/login`**。它不像 `/provider unset` 那样保留 key、只切 Provider。 + +### 给同事分享对话前要注意什么 + +`/share` 和 `/export` 的产物**默认不包含凭证原文**,但有几个隐私边界要注意: + +- `/share`(`src/commands/share/index.ts`)会把错误信息里的 home 目录路径替换成 `~`、把长 stack trace 截断到 200 字符(`sanitizeErrorMessage`,`share/index.ts:31-39`)。这是为了避免在分享链接里泄漏你的本地路径结构。但它**不会**扫描对话内容里的 key——如果你在对话里粘贴过密钥("帮我调试一下,我的 key 是 sk-..."),那段文本会被原样分享出去。分享前自己搜一下 `sk-` 之类的敏感前缀。 +- `/export` 导出的是 transcript 的子集(消息、工具调用、结果),同样**不主动扫密钥**。导出的 JSON 里不会有 `~/.claude/.credentials.json` 的内容,但会有你在对话里手动输入过的任何东西。 + +**最稳的做法**:分享前 `/clear` 开一个干净会话复现问题,避免把历史对话里可能含的敏感信息带出去。 + +## 设计视角(写给开发者) + +这一节回答一组环环相扣的设计问题:**为什么 Claude Code 的凭证存储是分散的而不是统一的?为什么 `getOpenAIClient` 做模块级缓存、`getAnthropicClient` 不做?为什么 ChatGPT 订阅路径要去读 Codex CLI 的凭证文件?为什么 OAuth 刷新的偏差窗口两边都是 5 分钟?为什么 `/provider unset` 的清理边界画在"Provider 触发变量"而不是"全部凭证"?** 每个决策都不是随手做的——它们各自回应一个具体的约束或权衡。 + +### 为什么凭证存储是按 Provider 分散的,而不是统一一个文件 + +打开凭证文件清单你会发现:Anthropic OAuth 在 `~/.claude/.credentials.json`、ChatGPT OAuth 在 `~/.claude/openai-chatgpt-auth.json`、Codex CLI 共享在 `~/.codex/auth.json`、各兼容层 key 在 `~/.claude/settings.json` 的 `env`、workspace key 在 `~/.claude.json`。**为什么不收敛到一个 `~/.claude/credentials.json`?** + +三个理由,重要性递减: + +1. **凭证生命周期不一样**。Anthropic OAuth 令牌会自动刷新、文件会被多进程并发写(`auth.ts:1443` 的 lockfile 锁),它需要独立的文件做 mtime 检测(`invalidateOAuthCacheIfDiskChanged`,`auth.ts:1316`)。ChatGPT OAuth 也会刷新但走完全不同的 OAuth 端点(`auth.openai.com` vs Anthropic 的 OAuth 服务器),它有自己的刷新逻辑(`refreshTokens`,`chatgptAuth.ts:289`)。如果塞同一个文件,两种刷新逻辑要协调文件锁、mtime、原子写——复杂度爆炸。**按 Provider 分文件,让每个 Provider 自己管自己的生命周期**,是最干净的切分。 + +2. **跨工具复用要求路径兼容**。ChatGPT 订阅路径回退读 `~/.codex/auth.json`(`chatgptAuth.ts:339-346`)是为了**复用 Codex CLI 已登录的凭证**——用户在 Codex 登过,Claude Code 就能用,不用重复登录。这个设计的前提是"不修改 Codex 的文件"——Claude Code 只读它,写还是写自己的 `~/.claude/openai-chatgpt-auth.json`。如果两个工具共用一个文件,谁刷新令牌、谁负责写、文件锁怎么共享都会变成跨工具协调问题。**只读对方、写自己**是最低耦合的复用方式。 + +3. **环境变量与 settings 的分层**。OpenAI / Gemini / Grok 的 key 是通过 `process.env` 读的(`getOpenAIClient` 的 `process.env.OPENAI_API_KEY`,`client.ts:46`),但 `/login` 把它们写到 `settings.json` 的 `env` 字段是为了**持久化 + 跨会话生效**。`applyConfigEnvironmentVariables`(在 `/provider` 命令末尾调用,`provider.ts:145`)负责把 settings.json 的 `env` 字段反推回 `process.env`,这样 client 实例化时就能读到。**为什么不直接写 shell rc 文件?** 因为 Claude Code 不应该改你的 shell 环境——那会把它的配置泄漏到所有终端会话。settings.json 的 `env` 字段是"只在 Claude Code 进程内生效的 env var",作用域正确。 + +**这条分散设计的代价**:用户(和文档)需要记住五个不同的文件位置。这是清晰的复杂度——集中式存储看似简洁,但要把五种不同的刷新策略、并发安全、跨工具兼容塞进一个文件,复杂度只会更高、更难调试。 + +### 为什么 `getOpenAIClient` 做模块级缓存,`getAnthropicClient` 不做 + +打开两个 client 工厂对比: + +- `getOpenAIClient`(`src/services/api/openai/client.ts:39`):`let cachedClient: OpenAI | null = null`,首次调用创建实例后赋给 `cachedClient`,之后直接 return。需要清空时调 `clearOpenAIClientCache()`(`client.ts:76`)把 `cachedClient = null`。 +- `getGrokClient`(`src/services/api/grok/client.ts:15`):完全相同的模式,`cachedClient` + `clearGrokClientCache()`。 +- `getAnthropicClient`(`src/services/api/client.ts:84`):**没有模块级缓存**。每次调用都走完整的 client 构造流程——读 env、检查 OAuth、动态 import Bedrock/Foundry/Vertex SDK、构造 `new Anthropic(...)` 或 `new BedrockClient(...)` 等。 + +**为什么这种不对称?** 因为两个家族的 client 构造代价完全不同。 + +OpenAI / Grok 的 client 构造很便宜——读三个 env var、`new OpenAI({ apiKey, baseURL, ... })` 就完了。但每次 API 请求都重新构造一个 OpenAI SDK 实例会有隐性开销:SDK 内部会建立 HTTP agent、连接池、重试策略。**缓存这个实例让连接池能复用**,是合理的性能优化。 + +Anthropic 路径的 client 构造代价高且动态:它要根据 `CLAUDE_CODE_USE_BEDROCK` / `CLAUDE_CODE_USE_VERTEX` / `CLAUDE_CODE_USE_FOUNDRY` 动态 import 不同的 SDK(`client.ts:153-298`),还要 `await checkAndRefreshOAuthTokenIfNeeded()`、`await refreshAndGetAwsCredentials()`、`await refreshGcpCredentialsIfNeeded()`——**这些都是异步、有副作用的**。每次调用都走一遍这套流程,相当于每次 API 请求都触发一次凭证刷新检查。**关键在于 Anthropic 路径的 client 实例按参数化构造**——`getAnthropicClient({ apiKey, model, ... })` 接收 model/region 参数,不同 model(比如 Haiku vs Sonnet)可能要走不同的 AWS region(`ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION`,`client.ts:157-160`)。模块级单例缓存根本对不上这种参数化需求。 + +**这条不对称的代价**就是产品视角提到的"困惑 A"——会话中途改 OpenAI/Grok key,缓存里的 client 握着旧 key。`clearOpenAIClientCache` 是逃生口,但 `/login` 表单流程没调它。这是"性能优化 vs 配置变更"的固有张力:缓存越激进,改配置越要手动清缓存。 + +**为什么 `clearOpenAIClientCache` 还存在?** 因为它服务于 dev/调试场景——开发者在 REPL 里 `process.env.OPENAI_API_KEY = '...'` 手动改环境变量做实验,调一次 clear 就能强制重建 client。生产用户的等价操作是重启进程。 + +### 为什么 OAuth 刷新偏差窗口两边都是 5 分钟 + +打开两处刷新判断的代码: + +```ts +// Anthropic OAuth —— src/services/oauth/client.ts:344 +export function isOAuthTokenExpired(expiresAt: number | null): boolean { + if (expiresAt === null) return false; + const bufferTime = 5 * 60 * 1000; // 5 分钟 + const now = Date.now(); + const expiresWithBuffer = now + bufferTime; + return expiresWithBuffer >= expiresAt; +} + +// ChatGPT OAuth —— src/services/api/openai/chatgptAuth.ts:9 +const REFRESH_SKEW_MS = 5 * 60 * 1000; // 同样 5 分钟 +// ... +if (expiresAt !== null && expiresAt <= Date.now() + REFRESH_SKEW_MS) { + tokens = await refreshTokens(tokens); + await saveStoredAuth(tokens); +} +``` + +**两边都是 5 分钟,不是巧合**。这个数字回应一个共同的约束:**API 请求的端到端延迟不可忽略**。 + +考虑这条时间线:`getValidChatGPTAuth` 判断"快过期"→ 触发 `refreshTokens`(一次 OAuth 端点的网络 round-trip,可能 200ms-2s)→ 拿到新 access_token → 用它发 API 请求(排队 + 模型推理,几秒到几十秒)。如果偏差窗口留得太短(比如 10 秒),就会出现:判断"还没过期"→ 用旧 token 发请求 → 请求到达服务端时 token 已经过期 → 401。5 分钟窗口给整个请求链路(刷新 + 排队 + 推理)留出了充足余量。 + +**为什么不更长,比如 30 分钟?** 因为偏差窗口越长,刷新越频繁,OAuth 服务端承受的 refresh 请求越多。对 Anthropic 这种用户量级,每个用户每 25 分钟刷一次 vs 每 55 分钟刷一次,服务端负载差一倍。5 分钟是"请求链路延迟的上界估计 + 余量"的工程取舍——它不会卡到过期边界,也不会刷新得太勤。 + +**ChatGPT 路径的额外复杂度**:`getValidChatGPTAuth`(`chatgptAuth.ts:339-361`)还有一条**读 Codex 文件的回退逻辑**。先读 `~/.claude/openai-chatgpt-auth.json`,读不到再读 `~/.codex/auth.json`。**为什么这么做?** 因为 OpenAI 官方 Codex CLI 用的是同一个 OAuth client_id(`CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'`,`chatgptAuth.ts:7`)——也就是说 Codex CLI 和 Claude Code 在 OpenAI 那边注册的是**同一个应用**。用户在 Codex 登录拿到的令牌,Claude Code 拿来直接能用,因为对 OpenAI 服务端来说是同一个 client。这是一个相当大胆的跨工具复用决策——它把"Codex 装了 → Claude Code 免登录"做成了零配置体验,代价是两个工具的 OAuth client_id 必须永远保持一致。 + +### 为什么 `/provider unset` 的清理边界画在 Provider 触发变量,而不清 key + +打开 `src/commands/provider.ts:49-62` 的 `unset` 分支: + +```ts +if (arg === 'unset') { + updateSettingsForSource('userSettings', { modelType: undefined }); + // Also clear all provider-specific env vars to prevent conflicts + 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; + return { + type: 'text', + value: 'API provider cleared (will use environment variables).', + }; +} +``` + +它清的是 `modelType` 和六个 `CLAUDE_CODE_USE_*`——**全部是"Provider 选择"层**。它不清 `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` / `OPENAI_BASE_URL` / 任何 settings.json `env` 字段里实际存的 key。 + +**为什么这么画边界?** 因为"切换 Provider"和"清空凭证"是两个独立的用户意图。`/provider unset` 的返回文案说得很清楚:"API provider cleared (will use environment variables)"——它的语义是"回到 firstParty 默认,接下来按 env var 决定行为",**不是"把我所有的 key 都删了"**。如果 unset 顺手清了 key,用户切个 Provider 试一下、再切回来,key 就没了——这是不可接受的数据丢失。 + +**真正"清凭证"的命令是 `/logout`**(见产品视角)——它做完整的清空 + 进程退出。`unset` 和 `logout` 的分工是:`unset` 改 Provider 选择(可逆,不动凭证),`logout` 清认证身份(不可逆,进程退出)。 + +**有意思的对比**:`/provider` 切换到 bedrock / vertex / foundry(云 Provider)时(`provider.ts:147-161`),代码会顺手 `delete process.env.OPENAI_API_KEY` 和 `delete process.env.OPENAI_BASE_URL`。**为什么这三个 Provider 特殊?** 因为云 Provider 走的是 Anthropic 协议(Bedrock / Vertex / Foundry 都是 Anthropic 模型在云厂商的托管),不应该带着 OpenAI 协议的 key 跑——带了反而可能让 SDK 误判走错路径。Gemini / Grok 的 key 不被清,是因为它们和 firstParty 之间不存在协议混淆风险(Provider 选择本身就是排他的)。 + +### 为什么 `/login` 的 `onDone` 要做那么多副作用 + +打开 `src/commands/login/login.tsx:33-65`——`onDone` 回调在登录成功后会做这一串: + +```ts +context.onChangeAPIKey(); +context.setMessages(stripSignatureBlocks); // 清掉绑旧 key 的签名块 +resetCostState(); // 重置费用统计 +void refreshRemoteManagedSettings(); // 拉新的远程管理 settings +void refreshPolicyLimits(); // 拉新的 policy limits +resetUserCache(); // 清 user 数据缓存 +refreshGrowthBookAfterAuthChange(); // 刷 GrowthBook feature flags +clearTrustedDeviceToken(); // 清旧的 trusted device token +void enrollTrustedDevice(); // 重新注册 trusted device +resetAutoModeGateCheck(); // 重置 auto mode 检查 +context.setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 })); +``` + +**为什么这么多副作用?** 因为登录本质上是"切换身份"——身份变了,所有跟身份绑定的状态都得跟着刷新,否则就会出现"用 A 身份登录、UI 上显示的还是 B 身份的数据"的撕裂。 + +逐条看: + +- `stripSignatureBlocks`:thinking blocks 和 connector_text 这些字段在 API 响应里是带签名的(绑 API key)。新 key 不能验证旧 key 的签名,所以必须清掉,否则下一次请求会被服务端拒。 +- `resetCostState`:费用统计是按账号累计的,换账号必须清零。 +- `refreshRemoteManagedSettings` / `refreshPolicyLimits`:远程管理的 settings 和 policy limits 是按 org/account 下发的,换账号要重新拉。 +- `resetUserCache` + `refreshGrowthBookAfterAuthChange`:**顺序很重要**——必须先清 user cache 再刷 GrowthBook,否则 GrowthBook 会拿到旧账号的 user 数据去判 feature flag。注释(`login.tsx:46-48`)专门写了这一点。 +- `clearTrustedDeviceToken` + `enrollTrustedDevice`:**也必须先清再注册**(`login.tsx:51-54` 注释)——否则异步的 `enrollTrustedDevice` 还在飞行中时,bridge 调用可能拿着旧账号的 trusted device token 发出去。 +- `authVersion++`:这是一个"脏检查"版本号。`useAppState` 的 hook 订阅这个字段,它变了就触发重新拉取 auth 相关数据(比如 MCP server 列表是按账号不同的)。 + +**这条设计的核心原则**:登录不是"换一个字符串",而是"切换一整套绑身份的状态"。`onDone` 这串副作用是在明确枚举所有跟身份绑定的子系统,确保它们同步更新。**代价**是这条回调很长、修改时要小心——加一个新的"绑身份"子系统,必须在这里加对应的刷新调用,否则就会出现状态撕裂。这是"集中式身份切换"的维护成本。 + +### 为什么凭证文件要 `chmod 0o600`,settings.json 不要 + +打开 `saveStoredAuth`(`chatgptAuth.ts:148-165`)——写 `openai-chatgpt-auth.json` 时显式 `mode: 0o600`,然后 `chmod(path, 0o600)` 兜底(`chatgptAuth.ts:164`)。**为什么这么严格?** + +因为这个文件包含 `access_token` / `refresh_token` / `id_token`——任何能读这个文件的人都能冒用你的 ChatGPT 订阅。0600(owner 读写,其他人无权限)是文件系统层面的最低保护。兜底的 `chmod` 是为了应付 umask 没生效或跨平台差异——某些系统 `writeFile({ mode: 0o600 })` 会被 umask 削成 0644,显式 `chmod` 把权限补回去。 + +**对比**:`settings.json` 里的 `OPENAI_API_KEY` 没有这种保护——它就是普通 JSON 文件,按你的 umask 走。**为什么差别对待?** 因为 API key 是可以撤销的(去服务商面板 revoke),泄露后的止损路径清晰。OAuth refresh_token 撤销要复杂得多(要走 OAuth revocation endpoint、还可能影响其他用同一 OAuth 应用的工具)。**敏感度越高,文件权限越严**——这是一个朴素但被严格执行的原则。 + +### 为什么 Anthropic 的 workspace key 走 macOS keychain,OpenAI 兼容层的 key 走明文 settings + +打开 `src/utils/secureStorage/`——有 `macOsKeychainStorage.ts` / `plainTextStorage.ts` / `fallbackStorage.ts`。`workspaceApiKey`(Anthropic 的自定义 API Key)在 macOS 上会优先走 keychain(`src/utils/auth.ts` 的 `getApiKeyFromApiKeyHelper` 流程)。但 OpenAI / Gemini / Grok 的 key 直接写在 settings.json 的 `env` 字段、明文存储。 + +**为什么不对称?** 两个原因: + +1. **历史路径依赖**。Anthropic 的 API Key 存储从早期就走 keychain(因为 Anthropic 是默认 Provider,它的 key 是核心凭证)。OpenAI 兼容层是后加的(反编译重建时恢复的),它复用了 `settings.json` 的 `env` 字段——这个字段本来就是"明文环境变量配置",加 key 进去是最低改造成本。 +2. **跨平台**。macOS keychain 是平台特性,Linux / Windows 没有等价物(`fallbackStorage.ts` 是降级方案)。OpenAI 兼容层要在所有平台一致工作,最简单就是不用 keychain。Anthropic 路径在非 macOS 平台也会降级到 fallback 存储。 + +**这条不对称的安全含义**:你的 `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROK_API_KEY` 是**明文存在 `~/.claude/settings.json` 里的**。任何能读这个文件的进程(包括你运行的任何脚本、任何被攻破的进程)都能拿到这些 key。**实践建议**:如果你在共享机器上用,把 key 放 shell env var(`export OPENAI_API_KEY=...`)而不是 `/login` 表单——shell 配置文件至少权限是 0600 默认、不进 git。 + +## 两视角如何呼应 + +用户视角的每一个"凭证相关的痛点",在设计视角都能找到对应的边界决策: + +- **"我的密钥去了哪里"**(产品视角的凭证文件清单)对应 **"为什么凭证存储按 Provider 分散、为什么不收敛到一个文件"**(设计视角)——用户要记五个文件位置,是因为三种凭证生命周期(OAuth 自动刷新 / API Key 手动管理 / 兼容层 env 配置)的并发安全和跨工具复用要求不同的存储策略,强行合并只会让复杂度从"五个文件"变成"一个文件里的五种锁"。 +- **"我改了 key 为什么没生效"**(产品视角困惑 A)对应 **"`getOpenAIClient` 为什么做模块级缓存、`getAnthropicClient` 为什么不做"**(设计视角)——用户遇到的是"改了 key 还在用旧的",开发者看到的是"连接池复用的性能优化 vs 配置变更的缓存失效"的固有张力。`clearOpenAIClientCache` 是逃生口,但 `/login` 表单没调它——这是已知的设计缺口,不是 bug。 +- **"令牌什么时候过期、怎么自动刷新"**(产品视角第三段)对应 **"为什么两边偏差窗口都是 5 分钟、为什么有跨进程 lockfile"**(设计视角)——用户看到的是"不用手动刷新,自动续期",开发者看到的是"API 请求端到端延迟的工程余量 + 多进程并发刷新的 double-checked locking + 跨进程 mtime 失效"的三重设计。 +- **"`/provider unset` 为什么 key 还在"**(产品视角困惑 B)对应 **"为什么 unset 的清理边界画在 Provider 触发变量、不清 key 本身"**(设计视角)——用户期望 unset 是"全部清空",开发者把它定位成"可逆的 Provider 切换",把"不可逆的凭证清空"留给 `/logout`。两个命令的分工是明确且有意的。 +- **"用 Codex CLI 登过,Claude Code 为什么不用再登"**(产品视角第三种登录路径)对应 **"ChatGPT 路径为什么读 `~/.codex/auth.json`、为什么两个工具共用一个 OAuth client_id"**(设计视角)——用户看到的是"零配置跨工具体验",开发者看到的是"两个工具注册为同一个 OAuth 应用、只读对方凭证、写自己凭证"的最低耦合复用,代价是 client_id 永远不能改。 +- **"分享对话前要注意什么"**(产品视角末段)对应 **"`sanitizeErrorMessage` 为什么只清路径不清 key、为什么 `/share` 和 `/export` 不主动扫密钥"**(设计视角)——用户被告知"分享前自己搜一下 `sk-`",开发者看到的是"自动扫密钥的误报风险(误伤合法的 sk- 前缀 demo key)和实现成本(要支持几十种 Provider 的 key 格式识别),所以只做路径清理这种零误报的操作,把 key 识别留给用户"。 + +这种呼应关系是"凭证与认证生命周期"必须双视角覆盖的核心原因:用户视角告诉你**密钥去哪了、怎么管理、出了问题怎么自救**,设计视角告诉你**为什么 token 这么存、这个缓存策略逼出了什么权衡、跨工具复用是怎么落到代码里的**。两个视角合在一起,才能让使用者正确选择登录方式(订阅 OAuth / API Key / 兼容层表单 / 跨工具复用)并知道每种方式的凭证文件位置和过期行为,也让开发者在改 Provider 系统时知道"为什么不能把所有 key 塞一个文件、为什么 client 缓存策略要按 Provider 家族区分、为什么 OAuth 偏差窗口改了会出问题"——而不是把每个决策都重新走一遍、甚至不小心破坏跨工具凭证复用或多进程刷新安全。 diff --git a/docs/outline-output/design/00-prologue.md b/docs/outline-output/design/00-prologue.md new file mode 100644 index 000000000..b114c4e82 --- /dev/null +++ b/docs/outline-output/design/00-prologue.md @@ -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`——当反编译丢掉了结构信息时,退化为字典类型是唯一安全的选项。 + +如果你在代码里看到某个函数接收 `Record` 参数,或者在某个地方有 `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) diff --git a/docs/outline-output/design/01-code-splitting.md b/docs/outline-output/design/01-code-splitting.md new file mode 100644 index 000000000..e2dab6f1e --- /dev/null +++ b/docs/outline-output/design/01-code-splitting.md @@ -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) diff --git a/docs/outline-output/design/02-fast-path.md b/docs/outline-output/design/02-fast-path.md new file mode 100644 index 000000000..5821990f6 --- /dev/null +++ b/docs/outline-output/design/02-fast-path.md @@ -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) diff --git a/docs/outline-output/design/03-performance-shim.md b/docs/outline-output/design/03-performance-shim.md new file mode 100644 index 000000000..2a45894be --- /dev/null +++ b/docs/outline-output/design/03-performance-shim.md @@ -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() +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).__performanceShimInstalled) return + ;(globalThis as Record).__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) diff --git a/docs/outline-output/design/04-query-loop.md b/docs/outline-output/design/04-query-loop.md new file mode 100644 index 000000000..6897fb245 --- /dev/null +++ b/docs/outline-output/design/04-query-loop.md @@ -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`。每次 `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() + private loadedNestedMemoryPaths = new Set() +``` + +每个字段都有明确的跨 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 +``` + +它在内部调用 `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 子系统如何被触发,见产品大纲第三章"上下文管理与自动压缩" diff --git a/docs/outline-output/design/05-feature-flags.md b/docs/outline-output/design/05-feature-flags.md new file mode 100644 index 000000000..c72daf6ba --- /dev/null +++ b/docs/outline-output/design/05-feature-flags.md @@ -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_=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) diff --git a/docs/outline-output/design/06-tools-deferred.md b/docs/outline-output/design/06-tools-deferred.md new file mode 100644 index 000000000..64a9ae380 --- /dev/null +++ b/docs/outline-output/design/06-tools-deferred.md @@ -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`,注释写得很清楚: + +```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() +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() +``` + +这个 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, +): 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 { + 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 { + // 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) diff --git a/docs/outline-output/design/07-provider-dispatch.md b/docs/outline-output/design/07-provider-dispatch.md new file mode 100644 index 000000000..46913cb2a --- /dev/null +++ b/docs/outline-output/design/07-provider-dispatch.md @@ -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 = 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`。但在函数体内部,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) diff --git a/docs/outline-output/design/08-stream-adapters.md b/docs/outline-output/design/08-stream-adapters.md new file mode 100644 index 000000000..d9b67fe2c --- /dev/null +++ b/docs/outline-output/design/08-stream-adapters.md @@ -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, + model: string, +): AsyncGenerator { +``` + +这不是什么中间件框架,也不是事件发射器。一个纯粹的 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> = {} +// ... +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` 作为类型,因为它在类型层面就是"结构未知的 JSON"。把它留在 `src/services/api` 层更合理。 + +## 延伸阅读 + +- 想看 Usage 字段映射与模型映射的优先级链,见 [第九章](./09-usage-model-mapping.md) +- 想看 Provider 调度的完整流程(消息归一化、工具过滤、三路径分发),见 [第七章](./07-provider-dispatch.md) +- 想看模块级 client cache 的陷阱和 clearOpenAIClientCache(),见 [第九章](./09-usage-model-mapping.md) diff --git a/docs/outline-output/design/09-usage-mapping.md b/docs/outline-output/design/09-usage-mapping.md new file mode 100644 index 000000000..e6d0f20b1 --- /dev/null +++ b/docs/outline-output/design/09-usage-mapping.md @@ -0,0 +1,338 @@ +# 第九章:Usage 字段映射与模型映射的优先级链 + +> 四级优先级链、ANSI 清理、模块级缓存陷阱——兼容层里那些不能省的"丑"代码 + +## 模型映射不是查表那么简单 + +三个兼容层(OpenAI、Gemini、Grok)各自有一个 `resolveModel` 函数,都遵循同一套四级优先级链。但"遵循"的方式有微妙分歧,正是这些分歧暴露了每个 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 | 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 + } + } 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, + 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 { + 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 + try { + parsed = JSON.parse(inner.body) as Record + } 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 + 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 路由优先级链"一节 diff --git a/docs/outline-output/design/10-ink-framework.md b/docs/outline-output/design/10-ink-framework.md new file mode 100644 index 000000000..a3e435140 --- /dev/null +++ b/docs/outline-output/design/10-ink-framework.md @@ -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) diff --git a/docs/outline-output/design/11-state-management.md b/docs/outline-output/design/11-state-management.md new file mode 100644 index 000000000..463c0ccad --- /dev/null +++ b/docs/outline-output/design/11-state-management.md @@ -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 组件树 | `` 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 +``` + +注释直白地说:放在这里是为了 `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 = (args: { newState: T; oldState: T }) => void + +export type Store = { + getState: () => T + setState: (updater: (prev: T) => T) => void + subscribe: (listener: Listener) => () => void +} + +export function createStore( + initialState: T, + onChange?: OnChange, +): Store { + let state = initialState + const listeners = new Set() + + 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`:为什么订阅者用 Set 而不是 Array + +`listeners = new Set()` 是另一个值得注意的选择。`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(initialState ?? getDefaultAppState(), onChangeAppState)); +``` + +这段代码做了三件值得拆的事。 + +### `useState(() => createStore(...))`:lazy initialization + +注意 store 不是在模块顶层创建的,而是放在 `useState` 的 lazy initializer 里。这保证了: + +1. **每个 `` 实例有独立的 store**:如果同一个 React 树里 mount 了两个 provider(虽然在嵌套禁令下不可能,但测试场景可能模拟),它们的 store 互不干扰。 +2. **store 引用稳定**:`useState` 的 lazy initializer 只在首次 render 时调用一次,之后 `store` 引用永远不变。这点至关重要——`AppStoreContext.Provider value={store}` 不会因为 store 引用变化而触发下游所有 consumer 重新订阅。 + +反事实推演:如果写成 `const store = createStore(...)`(模块顶层),那么所有 `` 会共享同一个 store,破坏隔离性。如果写成 `const [store] = useState(createStore(...))`(不带 arrow function),每次 render 都会调用 `createStore`,创建新 store,丢失所有订阅者和状态。 + +### `HasAppStateContext` 主动 throw:为什么禁止嵌套 + +`HasAppStateContext` 是一个独立的 `React.createContext(false)`,唯一目的就是检测嵌套。当某个组件树里已经有一个 ``,再 mount 第二个就会触发 throw。 + +这个限制看起来很激进——React Context 本身是允许嵌套的,内层会 shadow 外层。为什么这里禁止? + +打开 `src/state/AppState.tsx:90` 附近看 provider 树: + +```tsx +return ( + + + + {children} + + + +) +``` + +provider 内部还嵌套了 `MailboxProvider` 和 `VoiceProvider`——它们都依赖外层的 store。如果允许嵌套,内层 `` 会创建一个**新的** 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(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, +): InProcessTeammateTaskState | undefined { +``` + +接受 `Pick` 而不是完整 `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` 是 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) diff --git a/docs/outline-output/design/12-acp-bridge-daemon.md b/docs/outline-output/design/12-acp-bridge-daemon.md new file mode 100644 index 000000000..285a77626 --- /dev/null +++ b/docs/outline-output/design/12-acp-bridge-daemon.md @@ -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.` 格式)。两者分离的好处是 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) diff --git a/docs/outline-output/design/13-claudemd.md b/docs/outline-output/design/13-claudemd.md new file mode 100644 index 000000000..787a0dcc2 --- /dev/null +++ b/docs/outline-output/design/13-claudemd.md @@ -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` 追踪已处理文件,遇到重复路径直接返回空数组。同时在 `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,保留行内注释和代码块内的注释不动。未闭合的 `` 写给自己看的备注,这些内容不应该进入模型的上下文——它们是给人读的元信息,不是给模型的指令。 + +## `@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 预算管理的讨论 diff --git a/docs/outline-output/design/14-testing-strategy.md b/docs/outline-output/design/14-testing-strategy.md new file mode 100644 index 000000000..0f25c4dca --- /dev/null +++ b/docs/outline-output/design/14-testing-strategy.md @@ -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 + return { + ...real, + mkdir: (...args: unknown[]) => + useSkillStoreFsStubs + ? mkdirMock(...args) + : (real.mkdir as (...a: unknown[]) => Promise)(...args), + writeFile: (...args: unknown[]) => + useSkillStoreFsStubs + ? writeFileMock(...args) + : (real.writeFile as (...a: unknown[]) => Promise)(...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 错误分类的部分 diff --git a/docs/outline-output/design/15-biome-config.md b/docs/outline-output/design/15-biome-config.md new file mode 100644 index 000000000..115fcf745 --- /dev/null +++ b/docs/outline-output/design/15-biome-config.md @@ -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 && ` 的 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[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) diff --git a/docs/outline-output/design/16-epilogue.md b/docs/outline-output/design/16-epilogue.md new file mode 100644 index 000000000..7ad1d0a74 --- /dev/null +++ b/docs/outline-output/design/16-epilogue.md @@ -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) diff --git a/docs/outline-output/user/01-installation.md b/docs/outline-output/user/01-installation.md new file mode 100644 index 000000000..05f3232f8 --- /dev/null +++ b/docs/outline-output/user/01-installation.md @@ -0,0 +1,210 @@ +# 第一章:从零开始 —— 安装、首次启动与环境要求 + +> 把工具装到本机,跑通第一次对话。 + +## 我需要先装什么?Bun 与 Node.js 的取舍 + +这一份 Claude Code(反编译重建版,下面统一叫 CCB)有两种运行形态,对应两套前置依赖。**普通使用者只需要 Node.js**:通过 npm 装好 `ccb` 命令后,`ccb` 默认就是用 Node.js 跑的(`package.json` 里的 `"ccb": "dist/cli-node.js"`)。Node.js 用 18 或更新的版本即可,没有特别严格的版本要求。 + +如果你打算从源码克隆、改代码、跑测试或调试,那需要装 Bun。源码模式对 Bun 版本有硬性要求:`package.json` 的 `engines.bun` 字段写明 `>=1.3.0`,安装文档进一步建议 `bun upgrade` 升到最新,老版本会触发一些奇怪的 BUG(路径解析、热重载偶发失败等)。装 Bun 的方式: + +```bash +# Linux / macOS +curl -fsSL https://bun.sh/install | bash + +# Windows (PowerShell) +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +为什么要同时支持两个运行时?源码构建出的产物是同一份 JS(`dist/cli.js` 加几百个 chunk 文件),两个 shebang 入口只是把它喂给不同的解释器: + +- `dist/cli-bun.js` 头部是 `#!/usr/bin/env bun`,启动快、内存占用低(`--version` RSS 大约 35MB) +- `dist/cli-node.js` 头部是 `#!/usr/bin/env node`,兼容性更好,不依赖 Bun + +`build.ts` 末尾会自动生成这两个入口,所以你不用纠结——装好哪个运行时就调对应那个命令即可。 + +## 三种安装方式:npm 全局、源码 dev、构建产物 + +### 方式一:npm 全局安装(最常用) + +一行命令搞定,适合只想把工具用起来的人: + +```sh +npm i -g claude-code-best +ccb --version # 应输出类似 2.7.0 (Claude Code) +``` + +装完后会得到三个全局命令:`ccb`(Node 形态,默认推荐)、`ccb-bun`(Bun 形态,启动更快)、`claude-code-best`(与 `ccb` 等价的别名)。日常调用用 `ccb` 就行;如果你的机器装了 Bun 且追求更冷启动,可以试 `ccb-bun`。 + +更新到最新版的命令是: + +```sh +ccb update +``` + +> 不推荐 `bun i -g claude-code-best`:bun 全局安装在部分平台有路径冲突。如果一定要用 bun,先跑 `bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge` 解除信任限制。 + +### 方式二:源码 dev 模式(贡献者/想折腾的人) + +需要 Bun ≥ 1.3.0: + +```bash +git clone https://github.com/claude-code-best/claude-code.git +cd claude-code +bun install +bun run dev +``` + +`bun run dev` 实际执行的是 `scripts/dev.ts`:它通过 `bun -d MACRO.X:Y` 把版本号等常量在启动时注入,再通过 `--feature ` 把 `DEFAULT_BUILD_FEATURES` 列表里的功能开关逐个打开。也就是说,dev 模式默认启用全部功能,最贴近"完整体验"。 + +带调试器启动: + +```bash +BUN_INSPECT=9229 bun run dev:inspect +``` + +`BUN_INSPECT` 环境变量被 `scripts/dev.ts` 读取后会自动加上 `--inspect-wait=9229`,浏览器或 VS Code 连上 `chrome://inspect` 即可断点调试。 + +一次性管道调用: + +```bash +echo "say hello" | bun run src/entrypoints/cli.tsx -p +``` + +### 方式三:构建产物 + +把源码编出 `dist/` 目录,之后既可以用 bun 也可以用 node 跑: + +```bash +bun run build # 默认走 Bun.build,输出 dist/cli.js + chunks/ +# 或 +bun run build:vite # 备选 Vite 链,chunk 体积更小 + +node dist/cli.js --version +``` + +`build.ts` 做了四件事:用 `Bun.build` 切分代码(`splitting: true`),把 `import.meta.require` 改写成 Node 兼容版本,给第三方依赖里 `var { x } = globalThis.Bun` 这类解构加 `typeof` 守卫,再把 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 拷到 `dist/vendor/` 下。 + +## 第一次启动会发生什么:trust dialog、init 流程 + +进入任意项目目录后启动: + +```bash +cd my-project +ccb +``` + +第一次(或换了新目录)会依次经过这几个阶段: + +**1. 信任对话框(Trust Dialog)** + +弹出一个红框问你 "Is this a project you trust?",下面列出当前目录绝对路径,选项只有两个: + +- `Yes, I trust this folder` —— 把"已信任"标记写到当前项目的 `.claude/settings.json`(`hasTrustDialogAccepted: true`),之后这个目录不再询问 +- `No, exit` —— 直接退出,进程返回码 1 + +这一步不是仪式感的,它真在守门:在用户确认信任之前,CLAUDE.md 预读、系统上下文预取、MCP server 拉起等副作用统统不会跑。`src/main.tsx` 里通过 `checkHasTrustDialogAccepted()` 判断,未信任时只 prefetch 安全内容。源码在 `src/components/TrustDialog/TrustDialog.tsx`。 + +**2. 初始化(init)** + +信任通过后,`src/entrypoints/init.ts` 的 `init()` 接管。它会:启用配置系统(`enableConfigs`)、应用 `.claude/settings.json` 里的环境变量(先应用"安全"子集,再在信任后应用全集)、配置代理与 mTLS、初始化 Sentry 与 Langfuse(没配 key 就是 no-op)、对 Anthropic API 做 TCP+TLS 预连接(重叠 100~200ms 握手时间)。`init()` 用 `lodash-es/memoize` 包了一层,整个进程只会跑一次。 + +**3. 进入 REPL** + +最后渲染欢迎框: + +``` +╭─────────────────────────────────────────────╮ +│ ✻ Welcome to Claude Code Best │ +│ /help for commands, ctrl+c to exit │ +╰─────────────────────────────────────────────╯ +> +``` + +第一次还没配 API 的话,发任何消息都会被引导去 `/login` 配置 Provider(见第二章)。 + +## 快速路径命令一览 + +`src/entrypoints/cli.tsx` 的 `main()` 按优先级串了十几条"快速路径",意图是让某些子命令几乎零模块加载就返回,避免动辄加载几 MB 代码。 + +最常用、最快的就是看版本: + +```bash +ccb --version +# 或 +ccb -v +``` + +`cli.tsx` 第 80 行的判断只匹配参数数量为 1 且就是 `--version` / `-v` / `-V`,命中后直接 `console.log` MACRO 里编译期注入的版本号就 return,**完全不加载任何其他模块**。版本号的单一来源是 `package.json`(`scripts/defines.ts` 的 `getMacroDefines()` 读它注入 `MACRO.VERSION`),避免多处写死产生漂移。 + +其他快速路径包括: + +```bash +ccb --dump-system-prompt # 输出渲染后的系统提示(feature-gated,外部构建会被 DCE 掉) +ccb --computer-use-mcp # 启动 Computer Use MCP server 模式 +ccb --chrome-native-host # Chrome native messaging host 模式 +ccb remote-control # Bridge / Remote Control 模式(也叫 rc / remote / sync / bridge) +ccb daemon start # Daemon 长驻模式 +ccb ps / logs / attach / kill # 后台会话管理 +ccb --resume # 恢复上次会话 +ccb -c # 继续当前目录最近一次会话 +``` + +完整子命令列表在 `src/main.tsx`(Commander.js 注册),想看自己关心的命令用法: + +```bash +ccb --help +ccb mcp --help +ccb doctor --help +``` + +## 把 `ccb` 设为全局命令:`cli-bun.js` 与 `cli-node.js` 双入口 + +如果走 npm 全局安装,`ccb` 命令已经自动注册好。如果走源码模式想让全局也能直接调,可以手动把 `dist/cli-bun.js` 或 `dist/cli-node.js` 软链到 PATH 里。两个入口内容极简: + +```js +// 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`(以及几百个按需加载的 chunk)。两个入口只是换运行时,`build.ts` 最后用 `chmodSync(path, 0o755)` 给它们加了可执行位。 + +选哪个?机器装了 Bun 就用 `cli-bun.js`(启动更快、内存占用更低,`--version` 的 RSS 实测从 966MB 降到 35MB),没装或不想装就用 `cli-node.js`(兼容性更好)。注意 `package.json` 里的 `"bin"` 字段默认把 `ccb` 指向 `dist/cli-node.js`——也就是说普通用户拿到的是 Node 版本,想用 Bun 版本要么显式调 `ccb-bun`,要么自己改 bin。 + +## 环境自检:`ccb doctor` + +`ccb doctor`(在 `src/main.tsx` 第 5282 行注册)是一个独立的健康检查子命令,会跳过信任对话框、跳过启动 `setup()`,专门用来诊断安装状态。它渲染的是 `src/screens/Doctor.tsx`,列出: + +- 当前版本号、npm 上最新版本与稳定版本(通过 `getNpmDistTags()` 拉取) +- 安装类型(`native` 还是 npm 全局) +- 当前生效的设置文件路径与解析错误 +- MCP server 连接状态、agent 定义加载情况 +- 沙箱状态(`SandboxDoctorSection`) +- 文件锁状态(`getAllLockInfo`、`cleanupStaleLocks`) + +跑一次: + +```bash +ccb doctor +``` + +它的描述里有句重要警告:"The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust."(信任对话框被跳过,stdio 类 MCP server 会被拉起做检查,**只在信任的目录里跑**)。 + +升级到最新版: + +```bash +ccb update +``` + +`ccb update` 对应 `src/cli/updateCCB.ts`:先看当前是不是 bun 装的(看 `process.execPath` 是不是落在 bun 的全局路径),是的话用 `bun update -g`,否则用 `npm install -g`。 + +## 下一步 + +- 想配 API(Anthropic / OpenAI 兼容 / Gemini / Grok / 国产大模型),看 [第二章:让 Claude 听你的 —— 配置 Provider 与模型](./02-providers.md) +- 想直接发消息、看流式回复、切权限模式,看 [第三章:日常对话 —— 交互式 REPL 怎么用](./03-repl.md) +- 想知道有哪些 slash 命令、按场景找,看 [第四章:slash 命令速查](./04-slash-commands.md) diff --git a/docs/outline-output/user/02-providers.md b/docs/outline-output/user/02-providers.md new file mode 100644 index 000000000..ab849b9f0 --- /dev/null +++ b/docs/outline-output/user/02-providers.md @@ -0,0 +1,239 @@ +# 第二章:让 Claude 听你的 —— 配置 Provider 与模型 + +> 把 CCB 接到你自己想用的那家 API 上:怎么选、怎么切、为什么没生效。 + +## 一张表看懂 7 个 Provider + +CCB 不绑定 Anthropic 官方账号,内置了 7 条 API 通道。`src/commands/provider.ts` 里硬编码的有效值就这 7 个,对应 `/provider ` 能接的参数: + +| Provider | `modelType` 值 | 适合谁 | +|----------|---------------|--------| +| Anthropic 官方 | `anthropic`(默认,内部叫 `firstParty`) | 有 Anthropic API key 或 Claude 订阅的人 | +| OpenAI 兼容 | `openai` | DeepSeek、Ollama、vLLM、智谱、通义、Moonshot、Cerebras、Groq 等任何 OpenAI Chat Completions 协议端点 | +| Gemini | `gemini` | Google Gemini 系列 | +| Grok | `grok` | xAI Grok 系列(`GROK_API_KEY` 或 `XAI_API_KEY` 都行) | +| Bedrock | `bedrock` | AWS 用户,走 `CLAUDE_CODE_USE_BEDROCK=1`,依赖 `AWS_REGION` 等 | +| Vertex | `vertex` | Google Cloud 用户,走 `CLAUDE_CODE_USE_VERTEX=1`,需要 `ANTHROPIC_VERTEX_PROJECT_ID` 等 | +| Foundry | `foundry` | Azure AI Foundry 用户,走 `CLAUDE_CODE_USE_FOUNDRY=1`,需要 `ANTHROPIC_FOUNDRY_*` 系列 | + +注意一个区别:`anthropic` / `openai` / `gemini` / `grok` 这四个会落到 `~/.claude/settings.json` 的 `modelType` 字段持久化;`bedrock` / `vertex` / `foundry` 三个云厂商只设环境变量,**不写 `settings.json`**——源码注释明确写了 "cloud providers controlled solely by env vars"。 + +想知道当前生效的是哪个: + +``` +/provider +Current API provider: openai +``` + +## 三种切换方式:`/provider`、`/login`、环境变量 + +同一个目标有三条路,按你的场景选。 + +**`/provider ` 最直接**——一行命令立刻切换,写入 `settings.json`。比如刚配完 DeepSeek 的环境变量,想切过去: + +``` +/provider openai +API provider set to openai. +``` + +它还会顺手做体检:切到 `openai` 时如果缺 `OPENAI_API_KEY` 或 `OPENAI_BASE_URL`,会返回 warning 而不是直接报错;切到 `gemini` 缺 `GEMINI_API_KEY` 同理。切到 `grok` 时接受 `GROK_API_KEY` 或 `XAI_API_KEY` 任一存在即可。 + +**`/login` 是引导式表单**——会弹出一个交互界面(`ConsoleOAuthFlow` 组件),让你按栏目填字段、选预设。对第一次配的人最友好,特别是接国产大模型(见下一节)。它除了填表单,还会触发一连串副作用:重置 cost state、刷新 GrowthBook feature flags、清掉 trusted device token 再重新 enroll、把 `authVersion` 自增让其他 hook 重新拉数据。所以 `/login` 不只是"写个 key"那么简单。 + +**环境变量是 CI/自动化场景的玩法**——所有 provider 都有对应的 `CLAUDE_CODE_USE_*` 开关,写到 shell 配置或 `.envrc` 里,`ccb` 启动时自动生效: + +```bash +# 临时用 DeepSeek 跑一个会话,不污染全局配置 +CLAUDE_CODE_USE_OPENAI=1 OPENAI_API_KEY=sk-xxx \ + OPENAI_BASE_URL=https://api.deepseek.com/v1 \ + OPENAI_MODEL=deepseek-chat ccb +``` + +三条路里**优先级在 `src/utils/model/providers.ts` 的 `getAPIProvider()`** 里写死:先看 `settings.modelType`(`/provider` 写进去的),再看 `CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY`,再看 `CLAUDE_CODE_USE_OPENAI/GEMINI/GROK`,最后 fallback 到 `firstParty`。这个顺序解释了下一个排错点。 + +## 中国 LLM 引导式登录:DeepSeek、智谱、通义、小米 MiMo + +`/login` 走 "China LLM" 栏目时会用上 `src/utils/chinaLlmProviders.ts` 里的预设表。这张表内置了四家: + +- **DeepSeek** — `https://api.deepseek.com`,注册送 5M tokens(30 天),最便宜。模型有 `deepseek-v4-pro`(推荐)、`deepseek-v4-flash`(快)。 +- **智谱 GLM** — `https://open.bigmodel.cn/api/paas/v4`,`GLM-4.7-Flash` 永久免费,有 Coding Plan(Lite ¥72/mo、Pro ¥216/mo、Max ¥576/mo)。 +- **通义千问** — `https://dashscope.aliyuncs.com/compatible-mode/v1`,开通后 90 天免费 tier,Coding Plan ¥200/mo。 +- **小米 MiMo** — `https://api.xiaomimimo.com/v1`,1M 上下文,Token Plan 四档(Lite ¥39/mo 起)。 + +预设表的好处是:你不用记 base URL、不用查 key 怎么申请、不用猜模型 ID。表单里每个 provider 都带 `apiKeyPage` 字段,直接给你跳转申请 key 的链接;每个模型还标了输入/输出每百万 token 的价格、上下文窗口、推荐 tag。选 Coding Plan 模式时,`resolveChinaProviderBaseURL()` 会自动把 base URL 切到对应 coding endpoint(比如智谱切到 `https://open.bigmodel.cn/api/coding/paas/v4`),key 格式也会提示(如 `tp-...`、`sk-sp-...`)。 + +填完表单后写入 `~/.claude/settings.json` 的 `env` 字段并触发 `applyConfigEnvironmentVariables()`,不用重启 `ccb`。 + +## 用 ChatGPT 订阅当后端:设备码流程与凭证存储 + +如果你有 ChatGPT 订阅,可以让 CCB 直接走 ChatGPT 账号体系,而不是去 OpenAI 平台申请 API key。这套实现在 `src/services/api/openai/chatgptAuth.ts`。 + +启用方式是设置 `OPENAI_AUTH_MODE=chatgpt`(同时把 provider 切到 `openai`)。CCB 会启动 OAuth 设备码流程:调 `https://auth.openai.com/api/accounts/deviceauth/usercode` 拿一个 `user_code` 和验证 URL,你在浏览器里打开 `https://auth.openai.com/codex/device` 输入这个 code 完成登录,CCB 这边轮询 `/api/accounts/deviceauth/token`(最多 15 分钟,每 5 秒一次)拿回 authorization code,再换成 `id_token` / `access_token` / `refresh_token` 三件套。 + +凭证默认存到 `~/.claude/openai-chatgpt-auth.json`(文件权限 `0600`)。**值得注意的兼容点**:如果那个文件不存在,CCB 会 fallback 读 `~/.codex/auth.json`(即 Codex CLI 的凭证文件,路径由 `CODEX_HOME` 环境变量控制,默认 `~/.codex`)。源码里有句日志:`[OpenAI] Using ChatGPT auth from Codex auth.json`。这意味着你在 Codex CLI 登过的账号,CCB 可以无缝接用。 + +刷新偏差窗口是 `REFRESH_SKEW_MS = 5 * 60 * 1000`,即 5 分钟。`getValidChatGPTAuth()` 每次被调用时检查 access_token 的 JWT `exp` 字段,如果距离过期不到 5 分钟就主动 refresh,避免请求途中 token 失效。 + +## 每个 Provider 需要哪些环境变量 + +下面这张清单是从源码逐个挖出来的,配的时候照着对一遍就不会漏。 + +**OpenAI 兼容**(`src/services/api/openai/client.ts`): + +- `OPENAI_API_KEY` — 必填 +- `OPENAI_BASE_URL` — 强烈推荐,比如 `http://localhost:11434/v1`(Ollama) +- `OPENAI_ORG_ID`、`OPENAI_PROJECT_ID` — 可选 +- `OPENAI_AUTH_MODE=chatgpt` — 走 ChatGPT 订阅模式时设 +- `OPENAI_MODEL` — 指定模型 ID(可选,不设 CCB 自己选档位) + +**Gemini 兼容**(`packages/@ant/model-provider/src/providers/gemini/modelMapping.ts`): + +- `GEMINI_API_KEY` — 必填,没有就 `resolveGeminiModel()` 会直接 throw +- `GEMINI_MODEL` — 直接指定模型(最高优先级) +- `GEMINI_DEFAULT_SONNET_MODEL` / `GEMINI_DEFAULT_OPUS_MODEL` / `GEMINI_DEFAULT_HAIKU_MODEL` — 按 anthropic 模型族映射 +- `ANTHROPIC_DEFAULT_SONNET_MODEL` 等 — 向后兼容(已废弃但仍读) + +**Grok 兼容**(`src/services/api/grok/client.ts` + `modelMapping.ts`): + +- `GROK_API_KEY` 或 `XAI_API_KEY` — 任一即可,前者优先 +- `GROK_BASE_URL` — 可选,默认 `https://api.x.ai/v1` +- `GROK_MODEL` — 直接指定(最高优先级) +- `GROK_DEFAULT_OPUS_MODEL` 等 — 按 family 映射 +- `GROK_MODEL_MAP` — JSON 字符串,一次性传完整映射表 + +**Bedrock / Vertex / Foundry**:依赖各家 SDK 的标准环境变量(`AWS_REGION`、`ANTHROPIC_VERTEX_PROJECT_ID`、`ANTHROPIC_FOUNDRY_*`),CCB 自己不额外定义。 + +## 模型映射是怎么决定的 + +CCB 内部统一用 Anthropic 的模型名(`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-haiku-4-5-20251001` 等)做调度,落到具体 provider 时再做一次映射。映射函数遵循同一条优先级链: + +1. `PROVIDER_MODEL`(如 `GEMINI_MODEL`、`GROK_MODEL`、`OPENAI_MODEL`)——直接写死,最高优先级 +2. `PROVIDER_DEFAULT_{FAMILY}_MODEL`——按 sonnet / opus / haiku 三个 family 分别覆盖 +3. `ANTHROPIC_DEFAULT_{FAMILY}_MODEL`——向后兼容的共享环境变量 +4. 内置默认表(Grok 在 `modelMapping.ts` 里有硬编码表,比如 opus family 默认映射到 `grok-4.20-reasoning`) + +举两个具体例子。Gemini 路径下如果你只设了 `GEMINI_DEFAULT_SONNET_MODEL=gemini-2.5-flash`,那么 CCB 调用 sonnet 时会用 flash,调用 opus 时会因为找不到映射抛错:`Gemini provider requires GEMINI_MODEL or GEMINI_DEFAULT_OPUS_MODEL (or ANTHROPIC_DEFAULT_OPUS_MODEL for backward compatibility) to be configured.`。 + +Grok 路径下,没设任何 `GROK_*` 时走默认表:opus family → `grok-4.20-reasoning`,sonnet/haiku family → `grok-3-mini-fast`。模型名带 `[1m]` 后缀(1M 上下文标记)会在映射前被 `replace(/\[1m\]$/, '')` 剥掉。 + +## 为什么切了 Provider 没生效 + +这是 issue 区最高频的困惑之一,根因几乎都在 `getAPIProvider()` 的优先级上。 + +**`settings.modelType` 优先于环境变量**。如果你之前用过 `/provider openai`,那 `~/.claude/settings.json` 里就写死了 `"modelType": "openai"`。后来你想换回 Anthropic 官方,只在 shell 里 `unset CLAUDE_CODE_USE_OPENAI`——没用,因为 settings 的优先级更高。正确做法是用 `/provider unset`,它会清掉 `modelType` 字段并删除所有 `CLAUDE_CODE_USE_*` 环境变量: + +``` +/provider unset +API provider cleared (will use environment variables). +``` + +注意 `/provider unset` **只清 Provider,不清 API key**。`OPENAI_API_KEY`、`GEMINI_API_KEY` 这些是独立保留的,你想彻底换 provider 还得自己清 key。 + +**`isFirstPartyAnthropicBaseUrl()` 有个 TODO 陷阱**(`src/utils/model/providers.ts:43`)。这个函数判断当前是不是走 Anthropic 官方 endpoint,逻辑是看 `ANTHROPIC_BASE_URL` 有没有设、设的是不是 `api.anthropic.com`。但 TODO 注释明确写了:"这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题"。意思是:如果你只设了 `OPENAI_BASE_URL`(指向 DeepSeek)但没设 `ANTHROPIC_BASE_URL`,这个函数会返回 `true`(因为 `ANTHROPIC_BASE_URL` 未设默认 firstParty),让下游某些 firstParty 专属的行为(比如特定 betas 头)泄漏到 OpenAI 兼容路径上。如果遇到奇怪的请求被拒,先检查这个。 + +## 我改了 API key 但没生效 + +另一个高频坑,根因是模块级 client cache。 + +`getOpenAIClient()`(`src/services/api/openai/client.ts:39`)和 `getGrokClient()`(`src/services/api/grok/client.ts`)都是单例缓存:第一次调用时读 `process.env.OPENAI_API_KEY` / `OPENAI_BASE_URL` 构造一个 OpenAI SDK 实例,存到模块级变量 `cachedClient`,之后所有调用直接复用这个实例。 + +```ts +// client.ts 的核心逻辑 +let cachedClient: OpenAI | null = null +export function getOpenAIClient(): OpenAI { + if (cachedClient) return cachedClient + // ... 读 env、new OpenAI(...) + cachedClient = client + return client +} +``` + +这意味着:你在 REPL 里改了 `process.env.OPENAI_API_KEY`(或通过 `/login` 重写了 `settings.json` 的 env),但当前会话的 client 实例还是用旧 key 构造的——下一次请求还是旧 key。两种解法: + +1. **重启 `ccb`**——最简单粗暴,所有模块级 cache 自然清空 +2. **调用 `clearOpenAIClientCache()` / `clearGrokClientCache()`**——程序化清缓存,但你没法在 REPL 里直接调,需要走 `/login` 这类会触发完整副作用的路径 + +`/login` 命令的 `onDone` 回调里调了 `context.onChangeAPIKey()`,这个 hook 会负责让下游感知 key 变了。所以**改 key 的正确姿势是走 `/login`,而不是手改 `settings.json` 后期望立刻生效**。 + +## 本地模型与自托管端点 + +CCB 的 OpenAI 兼容层对本地模型特别友好,因为 Ollama、vLLM、LM Studio 这些工具都暴露 OpenAI Chat Completions 协议。 + +**Ollama**(本地跑 Llama、Qwen 等): + +```bash +# 先启动 ollama 并 pull 一个模型 +ollama serve & +ollama pull qwen2.5-coder:32b + +# 让 CCB 用它 +export CLAUDE_CODE_USE_OPENAI=1 +export OPENAI_API_KEY=ollama # ollama 不校验 key,随便填 +export OPENAI_BASE_URL=http://localhost:11434/v1 +export OPENAI_MODEL=qwen2.5-coder:32b +ccb +``` + +**vLLM**(自托管推理引擎):把 `OPENAI_BASE_URL` 指向你的 vLLM server(默认 `http://localhost:8000/v1`),`OPENAI_MODEL` 填你启动 vLLM 时 `--model` 传的名字。 + +**DeepSeek 自托管**:跟官方 API 一样走 OpenAI 兼容,区别只是 base URL。注意思维模式的请求体格式跟官方 API 略有不同(见下一节)。 + +本地模型的两个实用环境变量: + +- `OPENAI_MAX_TOKENS` —— 显卡显存不够时强制限制 max output tokens(比如 RTX 3060 12GB 跑 65536-token 模型会 OOM,调小这个) +- `API_TIMEOUT_MS` —— 默认 600000(10 分钟),本地模型推理慢的话可以调大 + +## DeepSeek 思维模式:三格式注入与空字符串回显 + +DeepSeek 系列模型(`deepseek-reasoner`、`deepseek-v3`、`deepseek-v4`、`deepseek-chat`、`deepseek-coder`、`deepseek-r1` 等,凡是模型名包含 `deepseek` 的)会自动启用思维模式。检测逻辑在 `src/services/api/openai/requestBody.ts:21` 的 `isOpenAIThinkingEnabled()`: + +```ts +return modelLower.includes('deepseek') || modelLower.includes('mimo') +``` + +想强制关掉?设 `OPENAI_ENABLE_THINKING=0`。想给非 DeepSeek 模型强制开?设 `OPENAI_ENABLE_THINKING=1`。 + +启用思维模式后,CCB 会在请求体里**同时塞三种格式**,因为不同 endpoint 认不同的字段,互不冲突: + +```ts +...(enableThinking && { + thinking: { type: 'enabled' }, // 官方 DeepSeek API + enable_thinking: true, // 自托管 DeepSeek-V3.2 + chat_template_kwargs: { thinking: true, enable_thinking: true }, // 自托管 + MiMo +}), +``` + +这里有个反直觉但关键的细节:**必须把 `reasoning_content: ''`(空字符串)原样回显回去**。DeepSeek v4 在思维模式下,如果模型直接回答(不思考),会在 assistant message 里返回 `reasoning_content: ""`(空字符串而非缺失)。下一次请求必须把这个空字符串原样传回去,否则 DeepSeek 返回 400:`reasoning_content ... must be passed back`。 + +这套回显策略在 `src/services/providerRegistry/providerCompatMatrix.ts` 里按 provider 分了三档: + +- `always-preserve`(DeepSeek)——总是保留,包括空字符串 +- `drop-on-non-thinking`(permissive 默认)——非思维模型时丢掉 +- `strip`(Cerebras / Groq / strict-openai)——总是丢掉 + +所以同一份对话历史,发给 DeepSeek 时带 `reasoning_content`,发给 Groq 时被剥得干干净净,互不污染。 + +## `/effort` 与 `CLAUDE_CODE_EFFORT_LEVEL`:思考强度的四档 + +`/effort` 控制模型在回答前思考多久。`src/commands/effort/effort.tsx` 接受的参数:`low` / `medium` / `high` / `xhigh` / `max` / `auto`。`EFFORT_LEVELS` 在 `src/utils/effort.ts` 里写死是 `['low', 'medium', 'high', 'xhigh', 'max']`(注意 `max` 对外部用户是 session-only,不持久化;只有 `USER_TYPE === 'ant'` 才能 persist)。 + +``` +/effort low # Quick, straightforward implementation with minimal overhead +/effort medium # Balanced approach with standard implementation and testing +/effort high # Comprehensive implementation with extensive testing +/effort xhigh # Extended reasoning beyond high, short of max +/effort max # Maximum capability with deepest reasoning +/effort auto # 跟随模型默认 +``` + +`CLAUDE_CODE_EFFORT_LEVEL` 环境变量覆盖一切,优先级最高。它还接受 `unset` / `auto` 两个特殊值表示"别发 effort 参数"。设了环境变量再跑 `/effort medium`,CCB 会告诉你:"Not applied: CLAUDE_CODE_EFFORT_LEVEL=xxx overrides effort this session"。 + +落地到 ChatGPT 订阅模式(`OPENAI_AUTH_MODE=chatgpt` + 走 Responses API)时,`src/services/api/openai/responsesAdapter.ts` 把 effort 映射成 `reasoning.effort` 参数。Responses API 只认四档(`'low' | 'medium' | 'high' | 'xhigh'`),所以 `/effort max` 在 ChatGPT 模式下会被 `resolveAppliedEffort()` 降级为 `xhigh`(源码注释:"Keep /effort max usable as a familiar alias in ChatGPT subscription mode")。 + +不是所有模型都支持 effort。`modelSupportsEffort()` 在 `src/utils/effort.ts:34` 里维护白名单,目前包含 `opus-4-7`、`opus-4-6`、`sonnet-4-6`、`deepseek-v4-pro`,以及 ChatGPT Codex 推理模型。设 `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1` 可以强制全开(自担 API 报错风险)。 + +## 下一步 + +- 想知道日常发消息、看流式回复、切权限模式怎么操作,看 [第三章:日常对话 —— 交互式 REPL 怎么用](./03-repl.md) +- 想按场景查 slash 命令(`/clear`、`/compact`、`/cost` 等),看 [第四章:slash 命令速查](./04-slash-commands.md) +- 想接入 MCP server、装插件、写 Skill,看 [第五章:扩展 Claude 的能力](./05-extensions.md) diff --git a/docs/outline-output/user/03-repl-daily.md b/docs/outline-output/user/03-repl-daily.md new file mode 100644 index 000000000..de6e3f5d4 --- /dev/null +++ b/docs/outline-output/user/03-repl-daily.md @@ -0,0 +1,184 @@ +# 第三章:日常对话 -- 交互式 REPL 怎么用 + +> 装好 Claude Code 之后,每天打开它会做什么、怎么用。 + +## 发消息、看回复、中断与退出 + +启动 `claude` 之后,你会看到一个输入框。直接输入你想说的话,按 Enter 发送。Claude 的回复会以流式文本逐字出现,就像在聊天。 + +如果你中途觉得方向不对,想停下来,按 **Esc** 可以中断当前正在生成的回复。中断后,已输出的部分仍然保留在对话里,你可以换个方向继续追问。如果 Claude 正在调用某个工具(比如读文件或执行命令),Esc 也会中断工具执行。 + +想要彻底退出 REPL,按 **Ctrl+C**。第一次按会尝试中断当前任务,再次按会退出程序。 + +你也可以在非交互场景下使用 pipe 模式,一次性发送问题并获取结果: + +```bash +echo "解释一下 package.json 里的 scripts 字段" | claude -p +``` + +这种模式下不需要启动完整的交互界面,适合脚本调用和快速提问。 + +## 会话持久化:恢复、历史与清空 + +Claude Code 会把每次对话自动保存为会话日志。下次你想继续上次的对话,有几种方式。 + +**恢复上次对话**:直接在输入框里输入 `/resume`,会弹出一个历史会话列表,你可以按时间或项目筛选。也可以在启动时直接恢复: + +```bash +# 恢复当前项目最近的对话 +claude --continue + +# 恢复指定会话 ID 的对话 +claude --resume + +# 恢复时创建新的会话 ID(不影响原始会话) +claude --continue --fork-session +``` + +`--continue`(简写 `-c`)恢复当前目录下最近一次对话,`--resume` 可以指定会话 ID 或搜索关键词。 + +**查看历史**:输入 `/history`(别名 `/hist`)可以浏览所有历史会话,包括跨项目的记录。 + +**清空当前上下文**:如果对话太长、上下文窗口快满了,输入 `/clear`(别名 `/reset`、`/new`)会清空当前对话历史,从零开始。这不会删除会话日志,只是释放当前上下文窗口。 + +## 切换模型与思考强度 + +不同的任务适合不同的模型。你可以随时在对话中切换。 + +**切换模型**:输入 `/model`,后面跟模型名称即可。不传参数时会显示当前使用的模型: + +``` +/model claude-sonnet-4-20250514 +/model opus +``` + +模型名称支持简写。切换后立即生效,下一轮对话就会使用新模型。 + +**调整思考强度**:用 `/effort` 命令控制 Claude 的推理深度: + +``` +/effort low # 快速响应,适合简单问题 +/effort medium # 默认,平衡速度和质量 +/effort high # 深度思考,适合复杂任务 +/effort xhigh # 扩展推理,超过 high 但不到 max +/effort max # 最大推理强度(外部用户为会话级,不持久化) +/effort auto # 让系统自动决定 +``` + +设置会持久化到用户配置,下次启动仍然生效。你也可以通过环境变量 `CLAUDE_CODE_EFFORT_LEVEL` 设置。 + +**ultrathink 触发词**:当 `ULTRATHINK` feature 启用时,在输入中包含 `ultrathink` 关键词会自动将本轮对话的思考强度提升到 `high`。这是一种"按需激活"的方式,不需要提前切换 `/effort`,只需在需要深度思考的那一轮输入里写上 ultrathink 即可。 + +## 权限模式切换 + +Claude Code 执行文件读写、Shell 命令等操作时,默认会逐个询问你是否允许。你可以通过 `/mode` 命令切换交互模式来调整这个行为。 + +输入 `/mode` 后会弹出一个选择器,可用的模式包括: + +- `default` -- 标准模式,每次工具调用都需要确认 +- `gentle` -- 更温和的交互风格 +- `sharp` -- 精简直接 +- `workhorse` -- 工作流优化,减少不必要的确认 +- `token-saver` -- 节省 token 消耗 +- `super-ai` -- 高自主性模式 + +模式切换会立即生效并持久化到配置中。选择哪种模式取决于你的信任程度和任务类型:在做探索性任务时可以用更自主的模式,在操作关键文件时切回 default。 + +## 查看 token 消耗与费用 + +想知道这一轮对话花了多少钱、用了多少 token,输入 `/usage`(别名 `/cost` 或 `/stats`)。 + +``` +/usage +``` + +这会显示当前会话的费用估算、token 使用量、以及计划限额的剩余情况。对于有 rate limit 的 Provider,还会显示限流相关状态。 + +费用追踪基于 API 响应中的 usage 字段实时计算,每个 Provider 的计费方式不同,但 `/usage` 会统一展示。 + +## 上下文管理与自动压缩 + +长时间对话会让上下文窗口越来越满。Claude Code 有几种机制来管理这个问题。 + +**手动压缩**:输入 `/compact` 可以触发上下文压缩。Claude 会把之前的对话总结成一段摘要,释放上下文空间但保留关键信息。你也可以附上自定义的压缩指令: + +``` +/compact 重点保留代码修改相关的讨论 +``` + +系统在上下文接近上限时也会自动触发 compact,无需手动干预。 + +**强制剪裁**:输入 `/force-snip` 会在当前位置插入一个剪裁边界。边界之前的所有消息将从下一轮对话的模型视角中移除,但 REPL 的 UI 滚动历史仍然保留,你仍然可以往回翻看。与 `/compact` 不同,`/force-snip` 不会生成摘要,而是直接丢弃旧消息。 + +一般来说,优先使用 `/compact`(保留摘要),只有当你确认旧消息完全不再需要时才用 `/force-snip`。 + +## 导出与分享对话 + +有时候你想把对话内容保存下来或分享给同事。 + +**导出到文件**:输入 `/export` 会把当前对话导出为一个文件。支持指定文件名: + +``` +/export my-session.md +``` + +导出内容是纯文本格式,包含完整的对话记录,方便用其他工具查看或归档。 + +**上传分享**:输入 `/share` 会把当前会话日志上传到 GitHub Gist,生成一个可分享的链接: + +``` +/share # 默认创建 secret Gist +/share --public # 创建公开 Gist +/share --mask-secrets # 自动脱敏 API key 等凭证 +/share --summary-only # 只分享摘要(每轮截取前 200 字符) +/share --allow-public-fallback # gh 不可用时回退到 0x0.st +``` + +**隐私提示**:`/share` 上传的 JSONL 文件包含当前会话的所有输入和工具输出。虽然 `--mask-secrets` 可以自动替换常见格式的 API key、Bearer token、AWS key 和 GitHub token,但不保证能覆盖所有敏感信息。分享前请确认内容安全。如果只想让别人了解大概讨论了什么,用 `--summary-only` 是更安全的选择。 + +**生成摘要**:输入 `/summary` 会让 Claude 对当前对话生成一份结构化的会话摘要。这比 `/compact` 更详细,适合在结束一个长会话前做总结归档。 + +## 更换主题、输出风格与语言 + +**主题**:输入 `/theme` 可以更换界面配色方案。适合根据终端背景和个人偏好调整。 + +**输出风格**:`/output-style` 命令可以调整 Claude 的回复风格(该命令目前标记为已废弃,推荐使用 `/config` 代替)。 + +**界面语言**:输入 `/lang` 可以切换显示语言: + +``` +/lang zh # 简体中文 +/lang en # English +/lang auto # 跟随系统语言 +``` + +这个设置影响的是界面提示和部分 UI 文本的显示语言,不影响 Claude 的回复语言(回复语言由对话上下文和 CLAUDE.md 配置决定)。 + +## 配置项目记忆:CLAUDE.md 与 /memory + +Claude Code 每次启动时会自动加载项目目录下的 `CLAUDE.md` 文件作为上下文。你可以在里面写项目约定、代码规范、常用命令等,让 Claude "记住"你的项目特性。 + +CLAUDE.md 支持四层层级,后加载的优先级更高: + +1. **Managed** -- 平台管理的全局指令 +2. **User** -- 用户主目录下的 `~/.claude/CLAUDE.md` +3. **Project** -- 项目根目录的 `CLAUDE.md` +4. **Local** -- 子目录中的 `CLAUDE.md` + +你可以用 `@include` 指令在 CLAUDE.md 里引用其他文件,支持相对路径、家目录路径和绝对路径: + +```markdown +@./docs/coding-standards.md +@~/my-global-rules.md +@/etc/claude/company-rules.md +``` + +支持的文件类型不限于 `.md`,还包括 `.ts`、`.py`、`.rs`、`.sql` 等几十种文本格式。如果引用的文件不存在,会被静默忽略。 + +在对话中输入 `/memory` 可以直接编辑 Claude 的记忆文件,快速追加或修改项目知识。 + +## 下一步 + +- 想快速查找某个 slash 命令,看 [第四章:slash 命令速查](./04-slash-commands.md) +- 想接入外部工具扩展 Claude 的能力,看 [第五章:扩展 Claude 的能力](./05-extensions.md) +- 想了解 token 消耗优化和配置进阶,看 [第九章:省钱、提速、定制](./09-budget-config.md) diff --git a/docs/outline-output/user/04-slash-commands.md b/docs/outline-output/user/04-slash-commands.md new file mode 100644 index 000000000..f89bf5215 --- /dev/null +++ b/docs/outline-output/user/04-slash-commands.md @@ -0,0 +1,318 @@ +# 第四章:slash 命令速查 —— 不用记全部,按场景找 + +> 你想做什么?翻到这里,按场景找到对应命令。 + +在 Claude Code 的交互式 REPL 中,输入 `/` 开头的文本即可触发 slash 命令。命令很多,但不需要死记硬背——按你想做的事情找就行。如果实在不确定要哪个,直接输入 `/help` 会打开一个分类浏览面板,里面有所有可用命令。 + +## 会话与上下文管理 + +当你的对话变长、上下文快满了,或者想换个话题重新开始,这些命令帮你管好会话状态。 + +**`/clear`**(别名 `/reset`、`/new`)清空当前对话历史,从头开始。就像关闭再重新打开一个聊天窗口,之前的消息不会丢失到磁盘上的会话记录里,但不再参与本次对话的上下文。 + +``` +你: /clear +``` + +**`/compact`** 比 `/clear` 更温和——它会用 AI 总结当前对话,然后清除原始消息,只保留总结。这样你可以在不丢失要点的前提下释放上下文空间。你还可以给它自定义总结指令: + +``` +你: /compact 用中文总结,保留所有文件路径和关键决策 +``` + +如果 `DISABLE_COMPACT` 环境变量被设置,`/compact` 会被禁用。 + +**`/force-snip`** 在当前位置插入一个"剪裁边界"。下次查询时,边界之前的消息会被从模型视角移除(REPL 的滚动历史里仍然可见)。这是在 `/compact` 不够用时的手动干预手段。 + +**`/resume`**(别名 `/continue`)恢复之前的对话。可以传会话 ID 或搜索关键词: + +``` +你: /resume abc123 +你: /continue 修复认证bug +``` + +**`/history`**(别名 `/hist`)查看会话历史列表。 + +**`/context`** 可视化当前上下文使用情况——显示一个彩色网格,直观展示上下文窗口里各部分占用了多少空间。非交互模式下会以文本形式展示。 + +**`/rewind`**(别名 `/checkpoint`)将代码和/或对话恢复到之前的某个节点。 + +## 模型与 Provider 切换 + +想换一家 API、换一个模型、或者调整思考强度,用这几个命令。 + +**`/provider`**(别名 `/api`)切换 API 提供商。支持 7 个 provider:`anthropic`、`openai`、`gemini`、`grok`、`bedrock`、`vertex`、`foundry`。不带参数时显示当前 provider,`unset` 清除设置回退到环境变量: + +``` +你: /provider gemini +你: /provider unset +你: /provider +> Current API provider: anthropic +``` + +注意:`bedrock`、`vertex`、`foundry` 通过环境变量控制(`CLAUDE_CODE_USE_BEDROCK=1` 等),不会写入 settings.json。切换到 `openai`、`gemini`、`grok` 时会检查对应的 API key 是否已配置,如果缺失会给出警告。 + +**`/model`** 切换模型。不带参数时显示当前模型及描述,带参数时设置新模型。模型名通常形如 `claude-sonnet-4`、`claude-opus-4` 等: + +``` +你: /model claude-opus-4 +你: /model +``` + +**`/effort`** 设置思考强度,影响模型在推理上的投入程度。支持 `low`、`medium`、`high`、`xhigh`、`max`、`auto` 几档: + +``` +你: /effort high +``` + +**`/login`** 通过引导式流程登录 Anthropic 账号。如果已登录则显示为"切换账号"。设置 `DISABLE_LOGIN_COMMAND=1` 可禁用此命令。 + +**`/logout`** 退出当前账号登录状态。设置 `DISABLE_LOGOUT_COMMAND=1` 可禁用此命令。 + +## 费用、用量与限流 + +想知道花了多少钱、用了多少 token、遇到限流怎么办,看这里。 + +**`/usage`**(别名 `/cost`、`/stats`)显示会话费用、套餐用量和活动统计。三个名字指向同一个命令,用哪个都行: + +``` +你: /usage +你: /cost +你: /stats +``` + +**`/rate-limit-options`** 当你撞到 API 限流时,这个命令会弹出一个菜单,提供几个选项:申请额外用量(extra usage)、升级套餐(upgrade plan)、或等待限流重置。具体可用的选项取决于你的订阅类型——Team/Enterprise 用户看到的是"申请更多",Max 20x 用户看不到升级选项。 + +**`/reset-limits`** 重置限流状态。注意:当前版本此命令是一个 stub(占位),功能尚未实现。 + +如果你使用的是 OpenAI 兼容层,限流追踪是通过响应头 `x-ratelimit-*-requests`/`x-ratelimit-*-tokens` 和 `Reset-After` 自动完成的,不需要手动干预。 + +**`/perf-issue`** 生成一份性能快照报告,包含内存占用、CPU 使用、token 消耗、工具调用次数、缓存命中率、费用估算等信息。默认以 Markdown 格式写入 `~/.claude/perf-reports/` 目录: + +``` +你: /perf-issue +> Perf snapshot written to: +> `~/.claude/perf-reports/perf-2026-06-14T10-30-00-abc12345.md` + +你: /perf-issue --format=json --limit=5000 +``` + +## 配置与个性化 + +让 Claude Code 按你的习惯工作——主题、语言、快捷键、配置面板。 + +**`/config`**(别名 `/settings`)打开配置面板,可以集中管理各种设置项。 + +**`/theme`** 切换终端界面主题。会弹出可选主题列表供你选择。 + +**`/lang`** 设置显示语言,支持 `en`、`zh`、`auto`(自动检测): + +``` +你: /lang zh +你: /lang auto +``` + +**`/keybindings`** 打开或创建你的快捷键配置文件 `~/.claude/keybindings.json`。需要 `isKeybindingCustomizationEnabled()` 返回 true 才可用。 + +**`/env`** 显示当前环境信息快照,包括运行时信息(平台、CWD、PID、Bun/Node 版本、session ID)和关键环境变量。敏感值(匹配 token/password/auth/api_key 等关键词的)会被自动遮掩。只显示 `CLAUDE_*`、`FEATURE_*`、`ANTHROPIC_*`、`BUN_*`、`NODE_*`、`GEMINI_*`、`OPENAI_*`、`GROK_*` 等前缀的环境变量: + +``` +你: /env +> ## Runtime +> platform: darwin arm64 +> cwd: /Users/you/project +> pid: 12345 +> bun: 1.2.0 +> ## Environment Variables (allowlisted prefixes) +> ANTHROPIC_API_KEY=sk-a…d2 (38 chars) +> ... +``` + +**`/output-style`** 修改输出风格。已标记为 deprecated(不推荐使用),建议改用 `/config` 来调整。 + +**`/mode`** 切换交互模式,支持多种预设:`default`、`gentle`、`sharp`、`workhorse`、`token-saver`、`super-ai`: + +``` +你: /mode token-saver +``` + +## 项目与文件操作 + +让 Claude 关注特定的目录、查看文件列表和变更差异。 + +**`/add-dir`** 将一个新目录添加到 Claude Code 的工作范围内: + +``` +你: /add-dir /path/to/another/project +``` + +**`/diff`** 查看未提交的代码变更和每轮对话中的 diff。会以交互式界面展示。 + +**`/files`** 列出当前上下文中包含的所有文件。注意:此命令仅对 Anthropic 内部用户可用(`USER_TYPE=ant`)。 + +**`/context`** 和 **`/ctx_viz`** 都用于可视化上下文使用。`/context` 是主要命令,在交互模式下显示彩色网格,非交互模式下显示文本摘要。`/ctx_viz` 当前是 stub(禁用状态)。 + +## 插件、Skill 与扩展 + +当内置功能不够用,想装插件、浏览技能市场或管理 Skill。 + +**`/plugin`**(别名 `/plugins`、`/marketplace`)管理 Claude Code 插件——浏览、安装、启用、禁用、卸载。可以进入插件市场(Marketplace)浏览社区贡献的插件。 + +``` +你: /plugin +你: /plugins +你: /marketplace +``` + +**`/skills`** 列出当前可用的所有 Skill。Skill 是一种可复用的工作流单元。 + +**`/skill-store`**(别名 `/ss`、`/cloud-skills`)浏览和安装远程技能市场中的 Skill。需要 Claude Pro/Max/Team 订阅。支持 list、get、versions、install 等子命令: + +``` +你: /skill-store list +你: /skill-store get my-skill-id +你: /skill-store install my-skill-id@1.0 +``` + +**`/reload-plugins`** 激活待定的插件变更到当前会话。当你安装或更新了插件后,需要执行此命令让改动生效(SDK 调用方通常通过 `query.reloadPlugins()` 来触发)。 + +**`/hooks`** 查看和管理工具事件的钩子配置。在 `settings.json` 中配置的 hooks 会在特定工具事件发生时自动执行脚本。 + +## 工作流自动化 + +把日常重复操作固化为可重放的工作流。 + +**`/commit`** 让 Claude 帮你生成 git commit。它只被允许执行 `git add`、`git status`、`git commit` 三个命令,会分析你的变更后生成合适的 commit message 并提交。 + +``` +你: /commit +``` + +**`/commit-push-pr`** 一条龙完成 commit、push 和创建 PR。Claude 会自动创建分支、提交代码、推送并在 GitHub 上创建 Pull Request。 + +**`/review`** 让 Claude 审查一个 Pull Request。不带参数时会列出所有开放的 PR,带 PR 编号时直接审查指定 PR: + +``` +你: /review +你: /review 42 +``` + +还有 `/ultrareview` 命令,它会在 Claude Code on the web 上运行一个更深入的 bug 搜索和验证流程,大约需要 10-20 分钟。 + +**`/plan`** 进入 Plan 模式或查看当前计划。先想清楚再动手: + +``` +你: /plan 重构认证模块,将 JWT 逻辑抽到独立 service +你: /plan open +``` + +**`/triggers`**(别名 `/cron`)管理云端定时触发的远程代理任务(cloud cron)。需要 Claude Pro/Max/Team 订阅。支持创建、查看、更新、删除、运行、启用、禁用等操作: + +``` +你: /triggers list +你: /triggers create "*/30 * * * *" "检查 deploy 状态" +你: /triggers run trigger-123 +``` + +注意:命令名叫 `/triggers`(对应底层 API endpoint `/v1/code/triggers`),别名 `/cron`。 + +**`/goal`** 设置一个持续性目标,Claude 会跨轮次自动推进。支持 status、clear、pause、resume、complete 等子命令: + +``` +你: /goal 完成 login 模块的单元测试覆盖 +你: /goal status +你: /goal complete +``` + +**`/workflows`** 打开工作流监控面板,实时显示运行中的 workflow 的 run/phase/agent 进度。 + +## 权限与安全 + +管理工具权限、沙箱模式,控制 Claude 能做什么。 + +**`/permissions`**(别名 `/allowed-tools`)管理工具的 allow/deny 权限规则。可以精细控制哪些工具被允许自动执行,哪些需要每次确认。 + +**`/sandbox`** 切换沙箱模式。沙箱模式下,shell 命令会被限制在一个隔离环境中执行,防止意外修改系统文件。支持配置排除模式——某些命令可以不经沙箱直接执行: + +``` +你: /sandbox +> (sandbox enabled, 可配置 exclude 规则) +``` + +注意:此命令仅在支持的平台上显示,且需要平台在启用列表中。 + +**`/poor`** 切换穷鬼模式——关闭 `extract_memories` 和 `prompt_suggestion` 两个功能来节省 token 消耗。设置会持久化到 `settings.json`: + +``` +你: /poor +> Poor mode enabled — extract_memories and prompt_suggestion disabled. +``` + +## 记忆与会话输出 + +管理 Claude 的记忆文件、导出和分享会话。 + +**`/memory`** 编辑 Claude 的记忆文件(CLAUDE.md 等)。会打开一个编辑界面让你查看和修改 Claude 对你项目的长期记忆。 + +**`/summary`** 手动触发一次会话摘要生成。通常会自动在满足条件时提取,但你可以随时用这个命令主动生成: + +``` +你: /summary +> Session summary updated. +> [摘要内容] +``` + +**`/export`** 将当前对话导出到文件或剪贴板: + +``` +你: /export conversation-backup.md +``` + +**`/share`** 将当前会话日志上传到 GitHub Gist,方便分享给同事或提交 issue。支持多个标志来控制分享方式: + +``` +你: /share --private --mask-secrets +你: /share --public --summary-only +你: /share --mask-secrets --allow-public-fallback +``` + +可选标志: +- `--public`:创建公开 Gist(默认 `--private`) +- `--mask-secrets`:上传前遮掩 API key、token 等敏感信息 +- `--summary-only`:只上传摘要(每轮截取前 200 字符) +- `--allow-public-fallback`:如果 `gh gist` 失败,回退到 0x0.st + +注意:需要安装 `gh` CLI 工具并已登录。 + +## 诊断与帮助 + +遇到问题或需要了解系统状态时。 + +**`/help`** 打开帮助面板。面板有三个标签页:general(通用快捷键和用法)、commands(所有内置命令)、custom-commands(自定义命令)。设置 `DISABLE_DOCTOR_COMMAND=1` 可禁用。 + +``` +你: /help +``` + +**`/doctor`** 诊断和验证你的 Claude Code 安装及配置是否正确。遇到莫名其妙的问题时,先跑这个: + +``` +你: /doctor +``` + +**`/status`** 显示 Claude Code 的综合状态信息:版本号、当前模型、账号信息、API 连通性、工具状态等。 + +**`/version`** 只显示当前运行的版本号和构建时间: + +``` +你: /version +> 2.7.0 (built 2026-06-14T08:00:00Z) +``` + +**`/feedback`**(别名 `/bug`)提交关于 Claude Code 的反馈。注意:在 Bedrock、Vertex、Foundry 或隐私模式下此命令不可用。 + +## 下一步 + +- 想了解 MCP Server、插件和 Skill 的详细用法,看 [第五章:扩展 Claude 的能力](./05-extensions.md) +- 想在 CI 或脚本中无交互调用 Claude,看 [第十一章:自动化与 CI 集成](./11-ci-integration.md) +- 遇到报错或卡住,看 [第十章:可观测性与排错](./10-troubleshooting.md) diff --git a/docs/outline-output/user/05-mcp-plugins-skills.md b/docs/outline-output/user/05-mcp-plugins-skills.md new file mode 100644 index 000000000..ae52f7281 --- /dev/null +++ b/docs/outline-output/user/05-mcp-plugins-skills.md @@ -0,0 +1,383 @@ +# 第五章:扩展 Claude 的能力 -- MCP Server、插件、Skill + +> 内置工具不够用时,用 MCP 接入外部服务,用插件扩展功能,用 Skill 沉淀工作流。 + +## MCP 是什么?什么时候该用它 + +MCP(Model Context Protocol)是一种标准化协议,让 Claude Code 能调用外部程序提供的工具和资源。你可以把它理解成一个"工具插件接口":任何实现了 MCP 协议的程序,都可以把自身能力暴露给 Claude Code 使用。 + +典型的使用场景包括: + +- **数据库操作**:接入一个 MCP server,让 Claude Code 能直接查询和管理数据库 +- **API 交互**:接入 Sentry、GitHub、Slack 等服务的 MCP server,让 Claude Code 能直接操作这些服务 +- **文件系统扩展**:接入特定格式(比如 Figma 设计文件)的 MCP server + +MCP server 通过三种传输方式与 Claude Code 通信: + +| 传输方式 | 适合场景 | 典型命令 | +|----------|---------|---------| +| **stdio** | 本地命令行程序,如 `npx my-mcp-server` | `claude mcp add my-server -- npx my-mcp-server` | +| **SSE** | 远程服务,通过 Server-Sent Events 通信 | `claude mcp add --transport sse my-server https://example.com/sse` | +| **HTTP** | 远程 HTTP 端点 | `claude mcp add --transport http sentry https://mcp.sentry.dev/mcp` | + +如果你只是想让 Claude Code 做一些固定的本地操作(比如跑测试、读日志),通常写一个 Skill(见本章后半部分)更轻量。MCP 更适合需要持续运行的外部服务或复杂的多工具场景。 + +## 用 `claude mcp add` 接入现成 MCP server + +最常见的方式是通过 `claude mcp add` 命令,把一个现成的 MCP server 注册到 Claude Code。 + +### 添加 stdio 类型的 server + +stdio 类型适用于本地可执行程序,比如通过 `npx` 运行的 Node.js 包: + +```bash +# 添加一个 stdio server,自动以 -- 后面的内容为子命令 +claude mcp add my-server -- npx my-mcp-server + +# 带环境变量 +claude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server + +# 带额外参数 +claude mcp add my-server -- my-command --some-flag arg1 +``` + +### 添加 HTTP 或 SSE 类型的 server + +对于远程 MCP 服务,需要指定传输方式: + +```bash +# HTTP server +claude mcp add --transport http sentry https://mcp.sentry.dev/mcp + +# 带认证头的 HTTP server +claude mcp add --transport http corridor https://app.corridor.dev/api/mcp \ + --header "Authorization: Bearer your-token" + +# SSE server +claude mcp add --transport sse my-server https://example.com/sse +``` + +### 配置范围 + +`-s` 参数控制配置写入的位置,影响谁能看到这个 MCP server: + +| 范围 | 说明 | 配置文件位置 | +|------|------|------------| +| `local`(默认) | 仅当前项目 | 项目目录下的配置文件 | +| `user` | 当前用户所有项目 | 用户主目录下的全局配置 | +| `project` | 项目级共享(可提交到 git) | 项目目录下的配置文件 | + +例如,把一个数据库 MCP server 配置为整个团队可用: + +```bash +claude mcp add -s project my-db -- npx my-db-mcp-server +``` + +## 管理已接入的 server + +注册之后,你可以在对话内或通过 CLI 管理这些 MCP server。 + +### CLI 方式 + +```bash +# 列出所有已配置的 MCP server +claude mcp list + +# 查看某个 server 的详情 +claude mcp get my-server + +# 移除一个 server +claude mcp remove my-server + +# 从特定范围移除 +claude mcp remove -s local my-server +``` + +### 对话内方式 + +在 REPL 中输入 `/mcp` 会打开 MCP 管理面板。你可以在这里: + +- **启用/禁用 server**:`/mcp enable my-server` 或 `/mcp disable my-server` +- **批量操作**:`/mcp enable all` 或 `/mcp disable all` +- **重新连接**:`/mcp reconnect my-server`(当连接断开时) +- **查看 server 提供的工具和资源**:在面板中选择 server 查看详情 + +启用或禁用是会话级别的操作,不会删除配置。重启 Claude Code 后,之前禁用的 server 会恢复启用状态。 + +## 把 Claude Code 自己暴露为 MCP server + +`claude mcp serve` 命令把 Claude Code 自身启动为一个 MCP server,让其他 MCP 客户端(比如 IDE、其他 AI 工具)能调用它的工具: + +```bash +# 启动 Claude Code MCP server +claude mcp serve + +# 带调试信息 +claude mcp serve --debug + +# 详细输出 +claude mcp serve --verbose +``` + +这在你想把 Claude Code 的文件操作、搜索、代码编辑等能力暴露给外部工具时很有用。 + +## MCP server 连接了但工具看不到?排查要点 + +接入 MCP server 后,如果 Claude Code 似乎"不知道"新工具的存在,可能有以下原因: + +1. **server 启动失败**:用 `claude mcp list` 检查 server 状态。stdio 类型的 server 如果命令路径不对或依赖缺失,会静默失败。 +2. **工具是延迟加载的**:非核心工具(包括所有 MCP 工具)默认不会全部加载到上下文。Claude Code 使用 SearchExtraTools 机制,按需搜索和加载延迟工具。当你的请求需要用到 MCP 工具时,它会自动搜索并加载。 +3. **OAuth 认证未完成**:需要 OAuth 的 HTTP/SSE server 如果认证未通过,工具虽然能被发现但调用会失败。 +4. **scope 不对**:`claude mcp add -s local` 添加的 server 只在当前项目目录生效。切换到其他目录后该 server 不可用。 + +## 自己写一个 MCP server 的最小骨架 + +如果你找不到现成的 MCP server 来满足需求,可以自己写一个。核心步骤是: + +1. 使用 MCP SDK 创建一个 Server 实例 +2. 注册工具(ListTools + CallTool) +3. 通过 stdio 或 HTTP 暴露服务 + +下面是一个最简单的 stdio MCP server(TypeScript,使用官方 `@modelcontextprotocol/sdk`): + +```typescript +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +const server = new Server( + { name: "my-mcp-server", version: "1.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "get_weather", + description: "Get the current weather for a city", + inputSchema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { city } = request.params.arguments as { city: string }; + return { + content: [{ type: "text", text: `Weather in ${city}: sunny, 22C` }], + }; +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +写好后注册到 Claude Code: + +```bash +claude mcp add weather-server -- npx tsx my-mcp-server.ts +``` + +接下来在对话里让 Claude Code "查询北京的天气",它就会自动调用你的 `get_weather` 工具。 + +## 内置 MCP 能力:Computer Use、Chrome 控制、语音输入 + +除了接入外部 MCP server,Claude Code 本身也提供了几个内置的 MCP 功能,覆盖屏幕控制、浏览器操作和语音输入。 + +### Computer Use(屏幕控制) + +通过 `--computer-use-mcp` 启动参数加载,提供截屏、键鼠控制和应用管理能力。支持 macOS、Windows、Linux 三大桌面平台,共 38 个工具。 + +```bash +claude --computer-use-mcp +``` + +启用后,Claude Code 可以看到你的屏幕、模拟鼠标键盘操作、管理应用窗口。比如你可以说"打开系统设置,把亮度调到 80%",它会截图、识别界面、操作完成。详见 [Computer Use 文档](../../features/external/computer-use.md)。 + +### Chrome 浏览器控制 + +通过 `--chrome-native-host` 或 `--claude-in-chrome-mcp` 启动参数加载。提供两种方案: + +- **Chrome Use MCP**(社区开源):通过 MCP 扩展接入,适合自托管场景 +- **Claude in Chrome**(原生集成):Anthropic 官方扩展,提供完整能力(截图、网络监控、JS 执行等) + +详见 [Chrome 控制文档](../../features/external/chrome-control.md)。 + +### 语音输入 + +通过 `FEATURE_VOICE_MODE=1` 启用,支持 Push-to-Talk 语音输入。长按空格键录音,释放后自动转录并发送。支持 Anthropic STT 和豆包 ASR 两种后端。 + +```bash +FEATURE_VOICE_MODE=1 claude +``` + +在对话中用 `/voice` 切换语音模式,`/voice doubao` 切换到豆包后端。详见 [Voice Mode 文档](../../features/external/voice-mode.md)。 + +## 插件系统:安装和管理社区插件 + +插件(Plugin)是在 Claude Code 中扩展功能的另一种方式。与 MCP server 不同,插件可以包含工具、命令、设置项等多种能力,更像一个"功能包"。 + +### 浏览和安装插件 + +在 REPL 中输入 `/plugin`(或 `/plugins`、`/marketplace`)打开插件管理界面。你也可以用命令行子操作: + +```bash +# 打开插件菜单 +/plugin + +# 安装插件 +/plugin install plugin-name + +# 从指定 marketplace 安装 +/plugin install plugin-name@marketplace-url + +# 管理已安装的插件 +/plugin manage + +# 启用/禁用/卸载 +/plugin enable plugin-name +/plugin disable plugin-name +/plugin uninstall plugin-name +``` + +### 插件市场 + +插件通过 marketplace 分发。Claude Code 支持管理多个 marketplace 来源: + +```bash +# 添加一个 marketplace +/plugin marketplace add https://my-marketplace.example.com/registry.json + +# 列出已添加的 marketplace +/plugin marketplace list + +# 更新 marketplace 索引 +/plugin marketplace update +``` + +### 验证插件 + +安装前可以验证插件的完整性: + +```bash +/plugin validate path/to/plugin +``` + +### 插件 vs MCP server + +两者的界限有时模糊,但大致可以这样区分: + +| 维度 | MCP Server | 插件 | +|------|-----------|------| +| 通信方式 | 标准协议(stdio/SSE/HTTP) | 直接集成到 Claude Code | +| 包含内容 | 工具和资源 | 工具 + 命令 + 设置 + UI 组件 | +| 来源 | 任何实现了 MCP 的程序 | Claude Code 插件市场 | +| 管理方式 | `claude mcp add/list/remove` | `/plugin install/manage` | + +简单来说:MCP server 适合对接外部服务,插件适合扩展 Claude Code 自身的行为。 + +## Skill 是什么? + +Skill 是一段 Markdown 文本,定义了 Claude Code 在特定场景下应该怎么行动。它不是"可执行代码",而是一份结构化的行为指南,被 Claude Code 读取后影响它的决策和操作方式。 + +你可以把 Skill 理解为给 Claude Code 的一份"操作手册":告诉它在遇到特定类型任务时,应该遵循什么步骤、用什么工具、注意什么事项。 + +### 查看可用 Skill + +在 REPL 中输入 `/skills` 打开 Skill 列表面板。你会看到所有可用的 Skill 及其简要描述。每个 Skill 有一个名称和触发条件(whenToUse),Claude Code 会根据对话上下文自动匹配最相关的 Skill。 + +### Skill Store(远程 Skill 市场) + +`/skill-store` 命令打开远程 Skill 市场,可以浏览和安装社区发布的 Skill。它需要 Anthropic API Key 或 workspace API key: + +```bash +# 打开 Skill Store +/skill-store + +# 列出可用 Skill +/skill-store list + +# 查看某个 Skill 的详情 +/skill-store get skill-id + +# 查看版本历史 +/skill-store versions skill-id + +# 安装 Skill +/skill-store install skill-id +``` + +`/skill-store` 也叫 `/ss` 或 `/cloud-skills`,三个别名指向同一个命令。 + +### `/skills` 与 `/skill-store` 的区别 + +| 命令 | 作用 | 数据来源 | +|------|------|---------| +| `/skills` | 查看当前会话可用的所有 Skill(本地 + 远程 + 内置) | 本地 `.claude/skills/` 目录 + 内置 Skill | +| `/skill-store` | 浏览和安装远程社区的 Skill | Anthropic Skill 市场(需 API Key) | + +简单说:`/skills` 看你"有什么",`/skill-store` 去"买新的"。 + +## 写一个自己的 Skill 并复用 + +自定义 Skill 是 Markdown 文件,放在 `.claude/skills/` 目录下(项目级)或 `~/.claude/skills/`(用户级)。 + +### Skill 文件结构 + +一个 Skill 文件就是一份结构化的 Markdown: + +```markdown +--- +name: my-code-review +description: Review code changes with security and performance focus +--- + +# Code Review Skill + +When asked to review code, follow these steps: + +1. Read the changed files using the Read tool +2. Check for common security issues (injection, auth bypass, data leaks) +3. Check for performance anti-patterns (N+1 queries, unnecessary allocations) +4. Verify error handling is complete +5. Report findings in a structured table format +``` + +把这个文件保存为 `.claude/skills/my-code-review.md` 后,Claude Code 在处理代码审查请求时就会参考这个 Skill 的指导。 + +### Skill 搜索与自动匹配 + +启用 Skill Search(`/skill-search start`)后,Claude Code 会在每轮对话中自动搜索并加载与当前任务最相关的 Skill。搜索基于 TF-IDF 向量余弦相似度算法,支持英文词干化和中文 bi-gram 分词。 + +```bash +/skill-search start # 启用自动匹配 +/skill-search stop # 禁用自动匹配 +/skill-search status # 查看当前状态 +``` + +启用后,Skill Search 会索引 `.claude/skills/` 和 `~/.claude/skills/` 下的所有 Markdown 文件,根据对话内容自动匹配并注入相关 Skill 指导。 + +### 延迟工具加载:SearchExtraTools 与 ExecuteExtraTools + +Claude Code 的工具系统采用延迟加载策略。核心工具(Read、Edit、Write、Bash、Glob、Grep 等 38 个)始终可用,其余工具(包括所有 MCP 工具)需要按需发现和加载。 + +当你让 Claude Code 执行一个需要非核心工具的操作时,它会自动完成两步: + +1. **SearchExtraTools** -- 搜索并发现需要的工具 +2. **ExecuteExtraTool** -- 加载并执行发现的工具 + +这个过程对用户完全透明。比如你说"帮我创建一个定时任务,每 5 分钟检查部署状态",Claude Code 会自动搜索 `CronCreate` 工具,然后执行它。你不需要手动触发任何操作。 + +## 下一步 + +- 想让 Claude Code 自动执行多步任务,看 [第六章:子代理、Plan 模式、Task 系统](./06-agents-plans-tasks.md) +- 想省钱和优化性能,看 [第九章:穷鬼模式、缓存、Hooks、配置文件](./09-budget-caches-hooks.md) +- 遇到 MCP server 连接问题,看 [第十章:可观测性与排错](./10-observability-troubleshooting.md) diff --git a/docs/outline-output/user/06-agents-plan-tasks.md b/docs/outline-output/user/06-agents-plan-tasks.md new file mode 100644 index 000000000..48e765ae2 --- /dev/null +++ b/docs/outline-output/user/06-agents-plan-tasks.md @@ -0,0 +1,300 @@ +# 第六章:让 Claude 帮你跑大任务 -- 子代理、Plan 模式、Task 系统 + +> 当任务太大、一步做不完时,如何让 Claude 自己拆分、规划和并行推进。 + +## 什么时候该"升级"任务处理方式 + +日常对话中,你给 Claude 一条指令,它执行一次就完成了。但有些任务天然不适合一口气干完:代码库迁移涉及几十个文件、排查 bug 需要同时在多处搜索、重构需要先理清依赖再动手。在这些场景下,三个工具能帮你把大任务拆小:**子代理(Agent)** 处理独立的子任务,**Plan 模式** 先想清楚再动手,**Task 系统** 跟踪进度和依赖。 + +一个简单的判断标准:如果你发现自己反复对 Claude 说"再看看那个文件"、"顺便改一下这个",说明这个任务已经需要拆分了。 + +## 子代理:让 Claude 派一个"分身"去干活 + +Claude Code 有一个 Agent 工具,允许 Claude 在对话过程中派生出子代理来处理子任务。子代理有自己的上下文和工具集,完成后会把结果汇报给主对话。你不需要手动启动子代理 -- 当 Claude 判断某个子任务适合独立处理时,它会自动使用 Agent 工具。 + +### 内置的子代理类型 + +Claude Code 内置了几种专门化的子代理,各有分工: + +- **general-purpose** -- 通用子代理,能使用全部工具。当你让 Claude 做一个需要多步骤研究或跨文件分析的任务时,它可能会派这个子代理去执行。 +- **Explore** -- 只读搜索专家,不能修改任何文件。擅长用 Glob、Grep、FileRead 快速在代码库中定位文件和关键词。当你问"这个功能在代码里怎么实现的"时,Explore 子代理会并行搜索多个路径然后汇总结果。 +- **Plan** -- 架构规划师,同样只读。它会深入阅读代码、理解现有架构,然后输出一份包含步骤和关键文件的实施方案。 + +你不需要记住这些类型名。Claude 会根据你的问题自动选择合适的子代理。 + +### 子代理是怎么工作的 + +当你给 Claude 一个复杂指令时,它可能会这样处理: + +``` +你: 把 src/utils/ 下所有用到 deprecatedFunction 的地方重构掉 + +Claude: [使用 Agent 工具,派出一个 Explore 子代理搜索所有引用] + + > Agent (Explore): 正在搜索 src/utils/ 下的 deprecatedFunction 引用... + + [子代理完成,返回搜索结果] + +Claude: 找到了 12 处引用,分布在 5 个文件中。让我逐一替换... +``` + +子代理运行时,你会看到一个带颜色的进度提示,标明子代理的类型和当前状态。每个子代理的输出会作为一条消息出现在对话中,Claude 主线程会基于这些结果继续工作。 + +子代理之间也可以形成层级:主对话可以派子代理,子代理在复杂场景下还能继续派更小的子代理(取决于配置和 feature flag)。 + +### 关于并行子代理 + +当你使用一些 Skill(如 `ultra-batch` 或 `dispatching-parallel-agents`)时,可以显式要求 Claude 同时派发多个子代理并行处理不同子任务。这在需要同时探索多个代码路径、或同时修改多个独立文件时特别高效。 + +``` +你: 用 3 个子代理同时查一下 authentication、authorization 和 session 管理的实现 + +Claude: [同时派出 3 个 Explore 子代理,并行搜索] + > Agent #1 (Explore): 搜索 authentication 相关文件... + > Agent #2 (Explore): 搜索 authorization 相关文件... + > Agent #3 (Explore): 搜索 session 管理相关文件... + + [三个子代理各自返回结果,Claude 汇总] +``` + +## Task 系统:跟踪任务进度和依赖 + +Task 系统提供了一套完整的任务管理工具,让 Claude 能够像使用待办清单一样跟踪工作进度。这套工具包括 `TaskCreate`、`TaskUpdate`、`TaskList` 和 `TaskGet`。 + +### 任务是怎么创建的 + +Claude 会在拆分大任务时自动使用 `TaskCreate` 创建子任务。你也可以在对话中明确要求它创建任务清单: + +``` +你: 帮我把用户模块重构拆成任务清单 + +Claude: [使用 TaskCreate 创建任务] + + > Task #1 created: 提取用户验证逻辑到独立 service + > Task #2 created: 重构用户数据模型,添加类型约束 + > Task #3 created: 更新 API 路由使用新的 service 层 + > Task #4 created: 编写单元测试覆盖新 service +``` + +每个任务包含标题(subject)、描述(description)和状态(pending / in_progress / completed)。创建任务时,Claude 还可以设置 `activeForm`(进行中时显示的活动描述,如"Running tests"),以及任意的 `metadata` 键值对。 + +### 任务状态流转 + +任务创建后默认是 `pending` 状态。Claude 会用 `TaskUpdate` 把任务标记为 `in_progress`(开始执行)、`completed`(完成)或 `deleted`(删除)。 + +``` +你: 继续执行任务清单 + +Claude: [使用 TaskUpdate 更新状态] + + > Updated task #1 in_progress, status + [开始执行第一个任务...] + + > Updated task #1 completed, status + > Updated task #2 in_progress, status + [继续下一个任务...] +``` + +用 `TaskList` 可以随时查看所有任务的当前状态: + +``` +#1 [in_progress] 提取用户验证逻辑到独立 service +#2 [pending] 重构用户数据模型,添加类型约束 +#3 [pending] 更新 API 路由使用新的 service 层 +#4 [pending] 编写单元测试覆盖新 service +``` + +`TaskGet` 则用于查看单个任务的完整详情,包括描述和依赖关系。 + +### 任务依赖 + +任务之间可以设置阻塞关系。如果任务 A 阻塞了任务 B(task B 被 task A blocked),那么在 A 完成之前,B 无法开始。Claude 在规划执行顺序时会参考这些依赖关系: + +``` +#1 [completed] 提取用户验证逻辑到独立 service +#2 [in_progress] 重构用户数据模型,添加类型约束 +#3 [blocked by #2] 更新 API 路由使用新的 service 层 +#4 [blocked by #3] 编写单元测试覆盖新 service +``` + +### 任务钩子 + +Task 系统支持 hooks 集成。你可以在 `settings.json` 中配置 hooks,让特定事件(如任务创建、任务完成)触发自定义逻辑。比如,当某个关键任务完成时自动运行测试套件。 + +## Plan 模式:先想清楚再动手 + +Plan 模式是处理复杂任务的最佳实践。进入 Plan 模式后,Claude 会切换到只读状态:它可以搜索代码、阅读文件、分析架构,但不能修改任何文件。等方案设计完毕并获得你的批准后,才退出 Plan 模式开始实际编码。 + +### 进入 Plan 模式 + +有两种方式进入 Plan 模式: + +**方式一:用 `/plan` 命令** + +``` +/plan 重构用户模块的数据层 +``` + +这会启用 Plan 模式,并把你给出的描述作为规划目标。Claude 会在只读状态下探索代码库、设计实施方案。 + +**方式二:让 Claude 自动进入** + +对于足够复杂的任务,Claude 会自动调用 `EnterPlanMode` 工具进入 Plan 模式。你不需要显式要求,但可以用 `/plan` 强制进入。 + +### Plan 模式下发生了什么 + +进入 Plan 模式后,权限模式会切换为 `plan`。在这个模式下: + +- Claude 只能使用只读工具(Read、Glob、Grep、Bash 的只读操作) +- 文件编辑工具(Edit、Write)被禁用 +- Claude 会深入探索代码库,理解现有模式和架构 +- Claude 可能会问你一些问题来澄清需求 + +Claude 会把规划结果写入一个计划文件。你可以随时用 `/plan` 查看当前计划,或用 `/plan open` 在你的默认编辑器中打开并手动修改计划内容。 + +### 退出 Plan 模式并开始执行 + +当 Claude 完成规划后,它会调用 `ExitPlanMode` 工具。这时你会看到一个审批对话框,显示完整的计划内容。你可以: + +- **批准** -- Claude 立即开始按计划编码 +- **编辑** -- 在编辑器中修改计划,然后批准修改后的版本 +- **拒绝** -- 让 Claude 重新规划 + +批准后,权限模式会恢复到进入 Plan 模式之前的状态(通常是 `default` 或 `auto`),Claude 开始按计划执行。 + +### VerifyPlanExecution:确认计划已正确执行 + +在较新的版本中,Claude 在退出 Plan 模式之前可能会调用 `VerifyPlanExecution` 工具,对计划的执行情况进行校验。它会检查: + +- 计划中的所有步骤是否都已完成 +- 测试是否通过 +- 关键文件是否已正确创建或修改 + +如果某些步骤被跳过或失败了,验证结果中会包含说明。 + +## Goal 命令:给 Claude 一个自主推进的目标 + +`/goal` 命令让你给 Claude 设定一个长期目标,然后 Claude 会自主地持续工作直到目标达成,而不是等你一轮一轮地下指令。 + +### 设置和查看目标 + +``` +/goal 把所有 API 端点从 REST 迁移到 GraphQL +``` + +这会设定一个目标。Claude 会开始自主规划、拆分任务、执行修改,每完成一轮会自动继续下一轮。你可以随时查看目标状态: + +``` +/goal status +``` + +输出会显示目标描述、当前状态、已用 token 数量、活跃时间和已执行的轮次。 + +### 控制目标进度 + +`/goal` 支持几个子命令来控制执行: + +- `/goal pause` -- 暂停自动继续 +- `/goal resume` -- 从暂停恢复 +- `/goal continue` -- 在达到最大轮次限制后重置计数器继续 +- `/goal complete` -- 手动标记目标已完成 +- `/goal clear` -- 清除当前目标 + +当 Claude 遇到无法自行解决的阻塞时,它会用 `GoalTool` 将目标标记为 `blocked` 并说明原因。连续 3 轮遇到同样的阻塞条件后,目标会被自动标记为阻塞状态,等待你介入。 + +### Goal 与 Task 系统的配合 + +`/goal` 通常会与 Task 系统配合使用。Claude 设定目标后会自动创建任务清单,然后逐个执行。你可以在 `/workflows` 面板中同时看到目标和任务的状态。 + +## Worktree 隔离:在独立分支里做实验 + +当你需要做一些可能有风险或需要独立验证的改动时,Claude 可以用 `EnterWorktree` 工具创建一个 git worktree -- 一个独立的工作目录,关联到一个新的 git 分支。 + +### Claude 如何使用 Worktree + +你不需要手动管理 worktree。当你告诉 Claude 做一些需要隔离的操作时,它可能会自动调用 `EnterWorktree`: + +``` +你: 试试把 ORM 从 Prisma 换成 Drizzle,先在一个独立分支上验证可行性 +``` + +Claude 会创建一个 worktree,在新分支上做实验,完成后汇报结果。你的主工作目录完全不受影响。 + +### Worktree 的生命周期 + +- **创建**:`EnterWorktree` 创建 worktree 和关联分支,会话切换到 worktree 目录 +- **退出 -- 保留**:`ExitWorktree(action: "keep")` 保留 worktree 和分支,会话切回原目录 +- **退出 -- 删除**:`ExitWorktree(action: "remove")` 删除 worktree 和分支。如果有未提交的改动或未合并的 commit,Claude 会先列出它们,需要你确认并传入 `discard_changes: true` 才会执行删除 + +你也可以在启动 Claude 时直接指定 worktree 模式: + +```bash +claude --tmux --worktree +``` + +这会自动创建一个 worktree 并在 tmux 会话中运行。 + +## Coordinator 模式:多 Worker 协作 + +Coordinator 模式是一个高级特性(需要 `COORDINATOR_MODE` feature flag),把 Claude 变成一个任务调度器。启用后,Claude 只能使用 Agent(派发子任务)、SendMessage(给子代理发消息)和 TaskStop(停止子任务)三个工具,不再直接执行任何操作。 + +### 启用 Coordinator 模式 + +``` +/coordinator +``` + +启用后,Claude 会变成一个编排者。你给它一个大任务,它会拆分成多个子任务,然后派发给不同的 worker 子代理并行执行: + +``` +你: 把这个 monorepo 的所有包从 CommonJS 迁移到 ESM + +Claude (Coordinator): 我把这个任务拆成 5 个子任务,派发给 worker 执行... + + > Agent (worker #1): 迁移 packages/utils/ ... + > Agent (worker #2): 迁移 packages/core/ ... + > Agent (worker #3): 迁移 packages/api/ ... + ... + + [Worker 完成后汇报结果,Coordinator 汇总] +``` + +再次输入 `/coordinator` 可以关闭 Coordinator 模式,恢复正常的工具使用。 + +## Workflow 脚本:固化可重放的多步工作流 + +`/workflows` 命令打开一个监控面板,实时显示当前工作流的运行状态、各阶段进度和子代理活动。这个面板适合在 Claude 执行复杂多步任务时监控整体进展。 + +在面板中,你可以看到: + +- 当前工作流的运行状态(running / paused / completed) +- 各阶段的进度 +- 活跃的子代理列表及其状态 +- 用键盘快捷键控制工作流(暂停、继续、取消) + +``` +/workflows +``` + +面板以终端 UI 形式展示,不阻塞主对话。 + +## 如何选择合适的方式 + +面对一个大任务,选择哪种方式取决于任务的性质: + +| 场景 | 推荐方式 | +|------|----------| +| 需要先理解代码再动手 | Plan 模式 | +| 需要长时间自主推进 | `/goal` | +| 需要并行处理多个独立子任务 | Agent 子代理 / Coordinator 模式 | +| 需要在隔离环境做实验 | Worktree | +| 需要跟踪多个子任务的进度 | Task 系统 | +| 需要监控复杂工作流的执行 | `/workflows` 面板 | + +这些方式可以组合使用。比如,你可以先用 Plan 模式设计方案,然后 Claude 自动创建 Task 清单,再派子代理并行执行各任务,同时你在 `/workflows` 面板中监控进度。 + +## 下一步 + +- 想了解如何让 Claude 定时或后台执行任务,看 [第七章:Daemon、Background Sessions、Schedule](./07-daemon-bg-schedule.md) +- 想了解如何通过 Bridge 或 RCS 远程控制这些任务,看 [第八章:跨机器与跨团队协作](./08-bridge-remote-acp.md) +- 想了解权限模式如何影响子代理和 Plan 模式的行为,看 [第九章:省钱、提速、定制](./09-budget-caching-hooks.md) diff --git a/docs/outline-output/user/07-daemon-bg-schedule.md b/docs/outline-output/user/07-daemon-bg-schedule.md new file mode 100644 index 000000000..feebc7eba --- /dev/null +++ b/docs/outline-output/user/07-daemon-bg-schedule.md @@ -0,0 +1,272 @@ +# 第七章:让 Claude 长时间帮你干活 -- Daemon、Background Sessions、Schedule + +> 任务跑太久、需要定时执行、想让 Claude 在后台默默干活 -- 这章讲三种长时间运行的方式。 + +## Daemon 是什么?跟普通对话的区别 + +你在终端里敲 `claude` 启动的是一个交互式 REPL 会话:你打字、Claude 回复、你继续问。关掉终端,会话就没了。Daemon 是一种不同的运行模式 -- 它在后台启动一个常驻进程(supervisor),由 supervisor 管理若干 worker 子进程,每个 worker 负责一种长时间任务。 + +目前 daemon 内置的 worker 类型是 `remoteControl`,它运行一个 headless bridge 循环,用于接受远程会话连接。supervisor 会监控 worker 的存活状态:如果 worker 崩溃了,supervisor 会自动重启它,使用指数退避策略(从 2 秒开始,每次翻倍,上限 120 秒)。如果 worker 在 10 秒内连续崩溃 5 次,supervisor 会把 worker "停泊"(park),不再尝试重启,避免无限重启循环。 + +Daemon 需要启用 `DAEMON` feature flag。启动 daemon 后,它会在 `~/.claude/daemon/` 下写一个状态文件(`remote-control.json`),记录 PID、工作目录、启动时间和 worker 类型,方便其他 CLI 进程查询状态或发送停止信号。 + +## 启停 Daemon + +在终端中用 `claude daemon` 命令管理 daemon 的生命周期: + +```bash +# 启动 daemon supervisor(默认启动 remoteControl worker) +claude daemon start + +# 查看 daemon 和后台会话的统一状态 +claude daemon status +# 或者 +claude daemon ps + +# 停止 daemon(发送 SIGTERM,超时后 SIGKILL) +claude daemon stop +``` + +启动时可以指定工作目录、worker 模式、并发容量等参数: + +```bash +claude daemon start --dir /path/to/project --spawn-mode worktree --capacity 4 --permission-mode auto-accept --sandbox +``` + +也可以在 REPL 里用 `/daemon` 斜杠命令执行同样的操作: + +``` +/daemon status +/daemon start +/daemon stop +/daemon bg -p "run the test suite" +``` + +注意,`/daemon attach` 不能在 REPL 内使用 -- attach 是一个阻塞交互操作,需要在外部终端执行。 + +## Background Sessions:让 Claude 在后台跑 + +Daemon 管的是 supervisor + worker 的架构,如果你只是想让 Claude 在后台跑一个任务、不想折腾 supervisor,background sessions 是更轻量的选择。 + +### 启动后台会话 + +```bash +# 最简方式:用 -p 参数传入提示词 +claude daemon bg -p "run the full test suite and report results" + +# 也可以用 --bg 快捷标志 +claude --bg -p "check for lint errors and fix them" + +# 带命名参数 +claude daemon bg --name nightly-audit -p "audit all TODO comments in src/" +``` + +后台会话支持两种引擎: + +- **tmux 引擎**(macOS/Linux,需要安装 tmux):启动一个 tmux session 来运行 Claude,你可以随时 attach 进去看到交互界面。 +- **detached 引擎**(Windows 或没有 tmux 的环境):进程脱离终端运行,日志写到文件,attach 时查看日志。 + +引擎选择是自动的:在 macOS/Linux 上优先用 tmux,如果 tmux 不可用则回退到 detached;Windows 上直接用 detached。如果使用 detached 引擎,必须带 `-p` 或 `--pipe` 参数,因为 detached 模式没有交互式终端。 + +```bash +# 安装 tmux(macOS) +brew install tmux + +# 安装 tmux(Linux) +sudo apt install tmux +``` + +启动成功后会输出会话信息: + +``` +Background session started: claude-bg-a1b2c3d4 + Engine: tmux + Log: ~/.claude/sessions/logs/claude-bg-a1b2c3d4.log + +Use `claude daemon attach claude-bg-a1b2c3d4` to reconnect. +Use `claude daemon status` to check status. +Use `claude daemon kill claude-bg-a1b2c3d4` to stop. +``` + +### 管理后台会话 + +```bash +# 列出所有活跃会话 +claude daemon status + +# 输出示例: +# 2 active sessions: +# +# PID: 12345 +# Kind: bg +# Engine: tmux +# Session: claude-bg-a1b2c3d4 +# CWD: /home/user/myproject +# Name: nightly-audit +# Started: 6/14/2026, 9:00:00 AM +# +# PID: 12346 +# Kind: daemon-worker +# Engine: tmux +# Session: claude-bg-e5f6g7h8 +# CWD: /home/user/myproject +# Started: 6/14/2026, 9:00:05 AM + +# 重新连接到会话(tmux 引擎直接进入交互界面) +claude daemon attach claude-bg-a1b2c3d4 + +# 查看会话日志 +claude daemon logs claude-bg-a1b2c3d4 + +# 终止会话(SIGTERM -> 2秒等待 -> SIGKILL) +claude daemon kill claude-bg-a1b2c3d4 +``` + +如果没有指定目标名称,`attach` 和 `kill` 会列出可用的会话让你选择。会话的元数据(PID、session ID、名称、启动时间等)保存在 `~/.claude/sessions/` 目录下的 JSON 文件中,进程退出后对应的 JSON 文件会被自动清理。 + +## Template Jobs:模板化任务 + +如果你经常重复执行某种结构的任务 -- 比如每天写日报、每周做代码审查 -- 可以用模板把任务的结构固定下来,之后只需 `claude job new