diff --git "a/docs/27-\347\273\204\344\273\266\344\270\216\350\256\276\350\256\241\347\263\273\347\273\237.md" "b/docs/27-\347\273\204\344\273\266\344\270\216\350\256\276\350\256\241\347\263\273\347\273\237.md" index d09b09e..bf29780 100644 --- "a/docs/27-\347\273\204\344\273\266\344\270\216\350\256\276\350\256\241\347\263\273\347\273\237.md" +++ "b/docs/27-\347\273\204\344\273\266\344\270\216\350\256\276\350\256\241\347\263\273\347\273\237.md" @@ -8,6 +8,8 @@ Claude Code 面对的正是这样的复杂度。它的 UI 不是静态的信息打印,而是一个**高度动态的交互界面**。在第 26 章中我们看到了 forked Ink 框架如何在终端中运行 React;本章则关注在这个框架之上,团队如何构建了一套**可复用的组件化设计系统**。 +> 体例:本书的"章"与"篇"为同一概念,凡跨章引用统一写作"第 N 章"。 + 这套设计系统回答三个核心问题: 1. **颜色从哪来?** — 6 套主题 × 80+ 语义化颜色 token 的主题系统 2. **组件怎么组合?** — 15 个设计系统组件 + 1 个颜色工具函数的职责划分 @@ -53,7 +55,12 @@ export type Theme = { // Agent 色板(8 色,用于区分多个并行 Agent) red_FOR_SUBAGENTS_ONLY: string blue_FOR_SUBAGENTS_ONLY: string - // ... 还有 green, yellow, purple, orange, pink, cyan + green_FOR_SUBAGENTS_ONLY: string + yellow_FOR_SUBAGENTS_ONLY: string + purple_FOR_SUBAGENTS_ONLY: string + orange_FOR_SUBAGENTS_ONLY: string + pink_FOR_SUBAGENTS_ONLY: string + cyan_FOR_SUBAGENTS_ONLY: string // 彩虹色(用于 ultrathink 关键词动画,7 色 + 7 shimmer) rainbow_red: string @@ -64,7 +71,7 @@ export type Theme = { 几个值得注意的设计决策: -**命名即约束**:`red_FOR_SUBAGENTS_ONLY` 这种"尖叫式"命名不是偶然的。它在**类型层面**告诉消费者:这个颜色只该用于子 Agent 的视觉区分,不要在其他地方用红色。这比注释更有效 —— 你在代码审查中会立刻注意到 `theme.red_FOR_SUBAGENTS_ONLY` 出现在非 Agent 的上下文中。 +**命名即约束**:`red_FOR_SUBAGENTS_ONLY` 这种"尖叫式"命名不是偶然的。`Theme` 类型本身只规定它是 `string`(见 utils/theme.ts:40-47),约束并不在类型系统里;约束写在字段名上:这个颜色只该用于子 Agent 的视觉区分,不要在其他地方用红色。比起把语义放在注释里,命名能在代码审查中直接被人眼捕捉到 —— 当 `theme.red_FOR_SUBAGENTS_ONLY` 出现在非 Agent 的上下文中,差异是肉眼级别的。 **Shimmer 配对**:几乎每个主色都有一个对应的 `*Shimmer` 变体(更浅的版本)。这是为微光动画效果准备的 —— 在两个颜色之间交替切换产生闪烁效果。 @@ -372,14 +379,15 @@ export function Dialog({ ### 5.1 Tabs:三层焦点协作模型 -Tabs 组件是设计系统中最复杂的组件,它实现了一个**三层焦点协作模型**: +Tabs 组件是设计系统中最复杂的组件,它实现了一个**三层焦点协作模型** —— Header 是默认层;Content 通过 `useTabHeaderFocus()` 注册为第二层;`navFromContent` 进一步把切换权放给 Content(第三层): ``` -┌─ Header focused ─────────────────────┐ +┌─ ① Header focused(默认层)──────────┐ │ Model [Config] Permissions Stats │ ← Tab/←/→ 切换标签 ├──────────────────────────────────────┤ -│ Content area │ ← ↓ 进入内容(需 opt-in) +│ ② Content focused(opt-in 第二层) │ ← ↓ 进入(需 useTabHeaderFocus 注册) │ (Select list, form, etc.) │ ← ↑ 回到 Header +│ ③ navFromContent=true(第三层) │ ← Content 聚焦时仍能 Tab/←/→ 切标签 └──────────────────────────────────────┘ ``` diff --git "a/docs/28-Keybindings-Vim\344\270\216Voice\350\276\223\345\205\245.md" "b/docs/28-Keybindings-Vim\344\270\216Voice\350\276\223\345\205\245.md" index eb3ab29..1e98369 100644 --- "a/docs/28-Keybindings-Vim\344\270\216Voice\350\276\223\345\205\245.md" +++ "b/docs/28-Keybindings-Vim\344\270\216Voice\350\276\223\345\205\245.md" @@ -276,6 +276,22 @@ export type VimState = INSERT 里没有任何状态机,就是普通的文本输入;NORMAL 里挂着上面那个 11 变体的子状态机。模式之间靠 `Escape` 与 `i` / `a` / `o` 等键切换。 +下面把 11 个变体一一对到一个最小输入序列,方便边读边复现: + +| # | 变体 | 触发序列 | 含义 | +|---|------|--------|------| +| 1 | `idle` | (初始) | NORMAL 下未按任何键 | +| 2 | `count` | `3` | 已输入计数前缀,等下一个动作 | +| 3 | `operator` | `d` | 已输入算子,等 motion | +| 4 | `operatorCount` | `d3` | 算子之后再输入计数(最终如 `d3w`) | +| 5 | `operatorFind` | `df` | 算子 + `f`/`F`/`t`/`T`,等下一个字符(如 `df,`) | +| 6 | `operatorTextObj` | `di` | 算子 + `i`/`a`,等 text object(如 `diw`、`ci"`) | +| 7 | `find` | `f` | NORMAL 下 `f`/`F`/`t`/`T`,等目标字符 | +| 8 | `g` | `g` | `g` 前缀,等下一个键(如 `gg`) | +| 9 | `operatorG` | `dg` | 算子 + `g` 前缀(如 `dgg`) | +| 10 | `replace` | `r` | `r` 前缀,等替换字符 | +| 11 | `indent` | `>` 或 `<` | 缩进算子,等 motion(如 `>>`、` = { `rollFrom(rng)` 在 `buddy/companion.ts:91-102` 把这一切串起来:先 `rollRarity` 决定稀有度档位,再从 `SPECIES`、`EYES` 各掷一个下标拿物种和眼神;帽子的有无完全由稀有度档位决定——`common` 永远是字符串 `'none'`,非 common 才在 `HATS` 里掷一个下标;接下来 `shiny = rng() < 0.01` 是一道独立的 1% 闪光判定;最后 `rollStats(rng, rarity)` 把五维填齐。 -帽子这一档是 hard branch(硬分支),不是概率门。注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`——没有"18% 概率给戴帽子"这种事。`shiny` 是 1% 的独立概率,五维属性最后再掷一遍收尾。 +帽子这一档是 hard branch(硬分支),不是概率门。注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`——没有"18% 概率给戴帽子"这种事。`shiny` 是一道**和稀有度无关**的独立 1% 概率:`buddy/companion.ts:98` 直接写 `shiny: rng() < 0.01`,common 到 legendary 五档都同一概率,没有"只有传说级才能闪光"这种额外门。五维属性最后再掷一遍收尾。 帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸。它在渲染时需要避开主体本身就有的纹理,所以帽子和物种像素画是要做空间互让的——这件事 §三 会接着看。 @@ -355,6 +355,8 @@ case 'companion_intro': > Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load. +(译:用本地日期而不是 UTC——24 小时跨时区滚动波。让 Twitter 上的讨论像滚雪球一样持续,而不是在 UTC 午夜出现一个集中尖峰,对后端"灵魂生成"的负载也更温和。)"Twitter buzz" 在原注释里就是字面上的"推上讨论度"——把"发现彩蛋"这件事铺到 24 个时区里慢慢发酵,比所有人在同一秒挤上去更有传播效果。 + 用本地时区铺开 24 小时滚动波,能让东亚和美西错峰孵化,避开一个 UTC 午夜的集中尖峰。`isBuddyTeaserWindow` 决定"要不要弹那个发现公告":2026 年 4 月 1 日到 7 日(`getDate() <= 7`)这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用":2026 年 4 月以后一直能用。 两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。 diff --git "a/docs/30-Doctor\345\261\217\344\270\216OutputStyle\344\275\223\351\252\214.md" "b/docs/30-Doctor\345\261\217\344\270\216OutputStyle\344\275\223\351\252\214.md" index 98066d2..54aa9ed 100644 --- "a/docs/30-Doctor\345\261\217\344\270\216OutputStyle\344\275\223\351\252\214.md" +++ "b/docs/30-Doctor\345\261\217\344\270\216OutputStyle\344\275\223\351\252\214.md" @@ -1,6 +1,6 @@ # 第 30 章:screens/ 三屏 — Doctor、Output Style 与 ResumeConversation -> 本章是《深入 Claude Code 源码》系列对终端 UI 一族的最后一篇。前面几章把 Ink 怎么把 React 搬进终端、设计系统如何收敛颜色与边距、键盘事件如何注入 React 树都讲透了。这一篇换一个角度:当 CLI 真的出问题时,用户怎么自己看清"问题在哪里";当用户想换一种说话方式时,怎么用一份 markdown 把模型的开场白替换掉。 +> 本章是《深入 Claude Code 源码》系列对终端 UI 一族的最后一篇。前面几章已经讲清楚三件事:Ink 怎么把 React 搬进终端、设计系统怎么收敛颜色与边距、键盘事件怎么注入 React 树。这一篇换一个角度:当 CLI 真的出问题时,用户怎么自己看清"问题在哪里";当用户想换一种说话方式时,怎么用一份 markdown 把模型的开场白替换掉。 ## 为什么把这三屏放在同一章? @@ -42,10 +42,10 @@ const doctor: Command = { 3. **跑一遍 `checkContextWarnings()`**,把"CLAUDE.md 过大、agent 描述超 token 阈值、MCP 工具超 token 阈值、permission rule 被遮蔽"这四类警告一次性算出来。 4. **如果启用了 PID-based locking,跑一次 `cleanupStaleLocks` 并读出当前 `LockInfo[]`**——这是 native installer 的并发版本锁,Doctor 顺便替你扫尸。 -值得停一下的是第 1 步里那条预热 `Promise`: +值得停一下的是第 1 步里那条预热 `Promise`。下面贴的不是源码本身,而是 **React Compiler 自动生成的 memoization 包装**——`$[2]` 是组件的 memo 缓存槽位(slot 2),`_temp6` 是被提到外部、跨次 render 复用的回调,整个 if 块的语义就是"如果这个槽位还没缓存,就调一次 `getDoctorDiagnostic()` 并把 promise 存进去"。原始代码作者只写了 `const distTagsPromise = useMemo(() => getDoctorDiagnostic().then(handler), [])`,编译器替它展开成下面这种显式槽位的形式: ```typescript -// Doctor.tsx:124-131 +// Doctor.tsx:124-131(React Compiler 输出形态) let t2; if ($[2] === Symbol.for("react.memo_cache_sentinel")) { t2 = getDoctorDiagnostic().then(_temp6); @@ -54,7 +54,7 @@ if ($[2] === Symbol.for("react.memo_cache_sentinel")) { const distTagsPromise = t2; ``` -`_temp6` 是 React Compiler 编译出来的稳定回调,做的事是根据 `diag.installationType` 决定调 `getGcsDistTags`(native 装法走 GCS)还是 `getNpmDistTags`(其余走 npm registry),失败时降级成 `{ latest: null, stable: null }`。这个 promise 在外层被 `` 包住,走的是 React 18 的 `use(promise)` 通道。主屏不会因为它阻塞,但一旦它落地,"Latest version: ..." / "Stable version: ..." 两行就会无感地插进版面里。Doctor 把"诊断本体"和"对版本号"做了一次轻量的并发拆分,这是它写得最讨喜的细节之一。 +`_temp6` 这个被提升出去的回调做的事是根据 `diag.installationType` 决定调 `getGcsDistTags`(native 装法走 GCS)还是 `getNpmDistTags`(其余走 npm registry),失败时降级成 `{ latest: null, stable: null }`。这个 promise 在外层被 `` 包住,走的是 React 18 的 `use(promise)` 通道。主屏不会因为它阻塞,但一旦它落地,"Latest version: ..." / "Stable version: ..." 两行就会无感地插进版面里。Doctor 把"诊断本体"和"对版本号"做了一次轻量的并发拆分,这是它写得最讨喜的细节之一。 ### 1.3 `getDoctorDiagnostic()`:把"我是怎么被装上来的"翻一遍 @@ -66,7 +66,7 @@ const distTagsPromise = t2; **第三段是配置警告**——`detectConfigurationIssues()`(`doctorDiagnostic.ts:317-485`)。这一段是为"装好了但用不上"准备的:native 装了但 `~/.local/bin` 不在 PATH 里,于是根据用户的 shell 类型给出该改哪个 rc 文件的具体命令;npm-local 装了但 PATH 里既找不到 `claude` 也没有有效 alias;npm-global 装了但同时存在一个 local 安装;npm-global 没有写权限,提示用户要么重装 node 不用 sudo、要么换 native installer。最特别的是开头那段对 `managed-settings.json` 的 `strictPluginOnlyCustomization` 字段校验——managed settings 的 schema 用 `.catch(undefined)` 兜了一手未来才会有的枚举值,但 Doctor 不能让管理员对此一无所知,所以它直接读 raw JSON 自己做一遍差分,把"你写了 N 个我不认识的 surface 名"显式列出来。 -**第四段是 ripgrep 状态与 Linux glob warning**。ripgrep 在 Claude Code 里既可能是 system 路径上的,也可能是 vendor 进来的,还可能是 bundled 进二进制里的,Doctor 把这三种模式各自展示成 `bundled` / `vendor` / `system path` 三种字面量。Linux 上 sandbox 的 glob pattern 支持不全,这一块也会被翻译成一条用户能看懂的 fix 建议。 +**第四段是 ripgrep 状态与 Linux glob warning**。ripgrep 在 Claude Code 里有三种来源:和二进制一起打包的(bundled)、随安装包附带在邻近 vendor 目录里的(vendor),或是从系统 PATH 上找到的(system path);这三种来源对应 `ripgrepStatus` 字段的三种字面量取值,下面字段表里写的"ripgrep 三态"指的就是这三种。Linux 上 sandbox 的 glob pattern 支持不全,这一块也会被翻译成一条用户能看懂的 fix 建议。 把这四段事实揉合后,最后那个 `DiagnosticInfo` 对象(`doctorDiagnostic.ts:54-71`)就是 Doctor 主屏要消费的全部数据: @@ -155,7 +155,7 @@ You are an interactive agent that helps users ${outputStyleConfig !== null : 'with software engineering tasks.'} ``` -这就是 Output Style 的真正作用域——**它换的是模型的"人格开场白"以及"具体回话风格",但不会替你换掉工具列表、不会换掉权限提示词、不会换掉 BashTool 的安全规约**。后面这些都在 prompts.ts 里另外几段被无条件拼上。Output Style 是一个**风格层的旁路**:它让用户能换 tone,但不让用户能扩权。 +这就是 Output Style 的真正作用域——**它换的是模型的"人格开场白"以及"具体回话风格",但不会替你换掉工具列表、不会换掉权限提示词、不会换掉 BashTool 的安全规约**。后面这些都在 prompts.ts 里另外几段被无条件拼上。Output Style 是一个**风格层的旁路**:它让用户能换"说话语气",但不让用户能扩权。 `getSimpleIntroSection(outputStyleConfig)` 和那个 `outputStyleConfig === null || outputStyleConfig.keepCodingInstructions === true` 的分支(`constants/prompts.ts:564-566`)补上了第二个细节。`default` 这一档在 `constants/outputStyles.ts:42` 直接被映射成 `null`,靠 `=== null` 的左半边保留 coding instructions;内置 Explanatory 和 Learning 不是 `null`,而是分别在 `constants/outputStyles.ts:48` 与 `:61` 标了 `keepCodingInstructions: true`,靠右半边保留;只有用户自己写的、且没开这个开关的 output style,coding 指令才会被整段拿掉。这一刀切得很谨慎——默认情况下用户换风格不会丢失 Claude Code 作为 coding agent 的硬约束。 diff --git "a/docs/31-Memory\345\255\220\347\263\273\347\273\237\345\205\250\346\231\257.md" "b/docs/31-Memory\345\255\220\347\263\273\347\273\237\345\205\250\346\231\257.md" index 33e0355..0b60325 100644 --- "a/docs/31-Memory\345\255\220\347\263\273\347\273\237\345\205\250\346\231\257.md" +++ "b/docs/31-Memory\345\255\220\347\263\273\347\273\237\345\205\250\346\231\257.md" @@ -78,7 +78,7 @@ CLAUDE.md 支持 `@path` 语法引用外部文件,实现指令的模块化组 @~/global-rules.md ``` -但 include 有严格的安全约束:只支持 70+ 种文本文件扩展名(`TEXT_FILE_EXTENSIONS`),防止二进制文件被加载到上下文中。include 也有循环引用检测和深度限制。值得注意的是,`processMemoryFile()` 的函数注释写着"includes first, then main file",但实际实现是**parent before children**(`claudemd.ts:663-664`,先 `result.push(memoryFile)` 再递归处理 include 文件)。 +但 include 有严格的安全约束:只支持 70+ 种文本文件扩展名(`TEXT_FILE_EXTENSIONS`),防止二进制文件被加载到上下文中。include 也有循环引用检测和深度限制。值得注意的是,`processMemoryFile()` 的函数注释写着"includes first, then main file",但实际实现是**parent before children**(`claudemd.ts:663-664`,先 `result.push(memoryFile)` 再递归处理 include 文件)—— 注释与代码的不一致是注释陈旧、代码后来改了的痕迹(不是 bug,也不是有意为之的设计),运行时以代码为准:父文件先入数组,被 include 的子文件接在其后。 ### 1.4 注入到 System Prompt @@ -417,7 +417,7 @@ export function shouldExtractMemory(messages: Message[]): boolean { ### 4.3 与 Compact 的协同 -Session Memory 的核心价值在 compact(上下文压缩)时体现。当 auto-compact 触发时,Session Memory 提供了一个比让 LLM 重新总结更低成本的替代方案 —— `sessionMemoryCompact.ts`(第 7 章已详述)可以直接复用后台已经提取好的 Session Memory 作为 compact 后的总结,**免去额外的 compact 总结 API 调用**。需要注意的是,Session Memory 自身的维护仍然是通过 forked agent 完成的(每次提取都要调用一次 API),但这些提取是在后台增量进行的,代价远低于在 compact 时从头生成摘要。 +Session Memory 的核心价值在 compact(上下文压缩)时体现。当 auto-compact 触发时,Session Memory 提供了一个比让 LLM 重新总结更低成本的替代方案 —— `sessionMemoryCompact.ts`(第 7 章已详述)可以直接复用后台已经提取好的 Session Memory 作为 compact 后的总结,**免去 compact 时再调一次 API 让模型把整段对话重新读一遍写摘要**。Session Memory 自身的维护当然要花 API 调用,但代价是"在 token 阈值触发时**异步**抽取一次增量",而 compact 时省下的是"对整个会话**同步**地完整摘要一遍"——两者在时机、调用次数、所读 token 量上都不在同一个量级,所以净收益是清楚的。 提取完成后会等待(`waitForSessionMemoryExtraction()`,15 秒超时)确保 compact 能拿到最新的笔记。 @@ -694,7 +694,9 @@ graph TD ## 附 · 最后一块拼图:远程会话历史的分页回放 -前九节讲的是"本机文件系统上的记忆"。但 Claude Code 还支持把会话状态保存到云端,然后在另一台设备上接着聊(Resume Conversation 屏的远端模式就走这条路径)。负责把云端 session 的事件流捞回本地的,是 `assistant/sessionHistory.ts` —— 整个模块只有 87 行,干净到值得整段读完,但它在这里被点名是因为它定义了"什么算是这场会话的可访问历史"。 +> 注:本节讲的不是记忆层,是**会话事件流的回放接口**。开篇七层表里的"七层"指的全是 memory 子系统本身;这一节单列出来,因为读者经常把"上次聊到哪了"和"上次让我记住了什么"两件事混在一起问。两者的数据通路完全不同——memory 写在本机文件系统上、由后台抽取或巩固,sessionHistory 走远端 HTTP、返回的是 SDK 消息流;放在一起,是为了让 Resume Conversation 屏的整套数据来源一次讲清。 + +前九节讲的是"本机文件系统上的记忆"。Claude Code 还支持把会话状态保存到云端,然后在另一台设备上接着聊(Resume Conversation 屏的远端模式就走这条路径)。负责把云端 session 的事件流捞回本地的,是 `assistant/sessionHistory.ts` —— 整个模块只有 87 行,干净到值得整段读完,但它在这里被点名是因为它定义了"什么算是这场会话的可访问历史"。 接口设计是典型的反向分页(`sessionHistory.ts:7-22`): diff --git "a/docs/32-\345\221\275\344\273\244\347\263\273\347\273\237\345\205\250\346\231\257.md" "b/docs/32-\345\221\275\344\273\244\347\263\273\347\273\237\345\205\250\346\231\257.md" index 43ca949..b73948d 100644 --- "a/docs/32-\345\221\275\344\273\244\347\263\273\347\273\237\345\205\250\346\231\257.md" +++ "b/docs/32-\345\221\275\344\273\244\347\263\273\347\273\237\345\205\250\346\231\257.md" @@ -218,7 +218,7 @@ const agentsPlatform = : null ``` -`USER_TYPE === 'ant'` 是运行时检查(非编译期),区分内部版和外部版。 +`USER_TYPE === 'ant'` 在源码里写成 `process.env.USER_TYPE === 'ant'`(`commands.ts:216`、`commands.ts:237`),看起来像一次 runtime env 读取,但和第 22 章、第 34 章讲的"`--define` 编译期替换"是同一个口径:Bun 的打包步骤会把 `process.env.USER_TYPE` 直接替换成构建时的字面量(外部构建为 `''`、内部构建为 `'ant'`),整个等式因此在编译期被折叠成 `false` 或 `true`,由打包器进一步 DCE 掉用不到的分支。所以本节看到的所有 `USER_TYPE === 'ant'` 都不会真的在运行期读环境变量,而是编译期门——与第 22 章 §2 模式 1、第 34 章模式 1 描述一致。 ### 2.2 COMMANDS() 注册表 diff --git "a/docs/33-\347\212\266\346\200\201\347\256\241\347\220\206\344\270\216\350\267\250\350\277\233\347\250\213\346\241\245.md" "b/docs/33-\347\212\266\346\200\201\347\256\241\347\220\206\344\270\216\350\267\250\350\277\233\347\250\213\346\241\245.md" index 8161092..eaaf33f 100644 --- "a/docs/33-\347\212\266\346\200\201\347\256\241\347\220\206\344\270\216\350\267\250\350\277\233\347\250\213\346\241\245.md" +++ "b/docs/33-\347\212\266\346\200\201\347\256\241\347\220\206\344\270\216\350\267\250\350\277\233\347\250\213\346\241\245.md" @@ -47,11 +47,13 @@ graph LR style TC fill:#fff3e0 ``` -| 层次 | 文件 | 生命周期 | 核心用途 | -|------|------|---------|---------| -| Session 全局 | `bootstrap/state.ts` | 进程级,整个 session 存活 | sessionId、CWD、成本统计、遥测 | -| AppState Store | `state/store.ts` + `AppStateStore.ts` + `AppState.tsx` | REPL 级,跟随 React 树 | UI 状态、权限、工具、插件、MCP | -| ToolUseContext | `Tool.ts:158-254` | 每次交互/工具执行级 | 工具执行所需的全部运行时上下文 | +| 层次 | 文件 | 生命周期 | 核心用途 | 是否落进 Store | +|------|------|---------|---------|---------------| +| Session 全局 | `bootstrap/state.ts` | 进程级,整个 session 存活 | sessionId、CWD、成本统计、遥测 | 模块级 module-scoped 变量,不走 Store | +| AppState Store | `state/store.ts` + `AppStateStore.ts` + `AppState.tsx` | REPL 级,跟随 React 树 | UI 状态、权限、工具、插件、MCP | 是,走 `createStore` | +| ToolUseContext | `Tool.ts:158-254` | 每次交互/工具执行级 | 工具执行所需的全部运行时上下文 | 否,是参数容器而非 Store | + +前两层是"状态"——一份**可被读、可被改、变更需要广播**的数据。第三层 ToolUseContext 不是 Store,而是**为一次工具执行打包的参数容器**:它由调用方在 query 循环或 REPL 路径里构造,按值(含 getter)传给工具,工具调完它就被丢弃,不会被 React 订阅、不会触发 re-render。把它和前两层并列,是因为读者在追"这个字段从哪里来"时必然会撞到它,把它落到第 4 节单独讲清。 这三层的设计原则是**向下依赖,向上隔离**:ToolUseContext 引用 AppState 的 getter/setter;AppState 可以读取 bootstrap/state 的值;但反过来不成立。 @@ -119,7 +121,7 @@ export function addToTotalCostState( 你可能会问:为什么不把这些状态也放进 AppState Store 里? -答案在于 **import DAG(依赖有向无环图)的约束**。`bootstrap/state.ts` 处于 import 树的最底部(叶子节点),几乎不 import 其他业务模块。需要澄清的是,`state/store.ts` 和 `state/AppStateStore.ts` 本身并不依赖 React —— 真正引入 React 的是 `state/AppState.tsx`。但问题的核心不在于 React 依赖,而在于**分层约束**:如果 bootstrap/state 反向依赖了更高层的应用状态模块(无论是 Store 还是 AppStateStore),就会破坏 DAG 的叶子节点地位,极易引入循环依赖。源码注释也明确提到这一点: +答案在于 **import DAG(依赖有向无环图)的约束**。`bootstrap/state.ts` 处于 import 树的最底部(叶子节点),几乎不 import 其他业务模块。`state/store.ts`(35 行,无任何 import)和 `state/AppStateStore.ts` 都不依赖 React —— 真正引入 React 的是 `state/AppState.tsx`。所以"不能把 bootstrap 状态搬进 Store"和"React 是不是边界"无关,真正的约束是**分层**:bootstrap 要被所有更高层的模块引用,自己不能反过来引用任何更高层的东西,否则 DAG 就有了环。源码注释也明确点出这一点: ```typescript // bootstrap can't import listeners directly (DAG leaf), so @@ -478,7 +480,7 @@ export function onChangeAppState({ **文件**:`Tool.ts:158-254` -`ToolUseContext` 不是存储在 Store 中的状态,而是**面向一次交互或一次工具执行的运行时上下文容器**。它最常见的构建场景是 query 对话循环,但不限于此 —— REPL 命令执行(`screens/REPL.tsx`)、权限弹窗流转、side question fallback(`utils/queryContext.ts:142-170`)等路径也会构建 ToolUseContext。它是连接所有状态层的"传话人"。 +`ToolUseContext` 不在 Store 里、不被 React 订阅,它是**一次工具执行的参数容器**:调用方(query 循环最常见,REPL 命令执行 `screens/REPL.tsx`、权限弹窗流转、side question fallback `utils/queryContext.ts:142-170` 也会构造)一次性把工具运行所需的所有外部依赖打包进去——abortController、messageId、agentId、readFileState、对 AppState 的若干 getter/setter、以及 `options.tools`/`options.commands` 这种 session 级配置的快照。工具拿到它、用完就丢,不会触发 re-render。第 1 节的三层表把它列在 Store 之外、单独占一行,就是这个意思:它和前两层一起"承上启下",但它本身不是状态,是为状态服务的传话人。 ```typescript // Tool.ts:158-254(核心字段) diff --git "a/docs/34-\346\236\266\346\236\204\346\250\241\345\274\217\346\200\273\347\273\223.md" "b/docs/34-\346\236\266\346\236\204\346\250\241\345\274\217\346\200\273\347\273\223.md" index 586fcea..600b9d1 100644 --- "a/docs/34-\346\236\266\346\236\204\346\250\241\345\274\217\346\200\273\347\273\223.md" +++ "b/docs/34-\346\236\266\346\236\204\346\250\241\345\274\217\346\200\273\347\273\223.md" @@ -264,6 +264,8 @@ export function buildTool(def: D): BuiltTool { 新增一个工具时,你只需定义必要的方法,其余由默认值兜底。注意这里的"保守"是分层的:`isConcurrencySafe` 默认 `false` 意味着新工具默认串行执行——并发需要显式 opt-in;但 `checkPermissions` 默认 `allow`,因为工具级别的权限判定只是外层 7 步管线(见模式 7)的一个环节,真正的安全兜底由管线的 deny 规则、safety check 和模式级变换提供。 +(这两条默认值的设计原则其实是 buildTool 子模式的脚注,但它直接决定了"工具注册表"对新接入工具的安全态度,所以放在这里。) + ### 迁移要点 - **适用场景**:任何需要管理多个可插拔模块的系统(API 路由、中间件、事件处理器、AI 工具) @@ -280,7 +282,7 @@ export function buildTool(def: D): BuiltTool { ### Claude Code 的解法 -System Prompt 的缓存设计实际上是**三层结构**,而非简单的二分。边界由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记(`constants/prompts.ts:105-115`): +System Prompt 的缓存设计在 boundary 之外其实还有一档区分,整体是**三层结构**(第 8 章按"静态 / 动态"分两段是为了讲缓存策略时的主线足够直观,本章把动态段再拆成"session 级 memoized"与"逐轮 volatile"两小段,以解释为什么 boundary 之后并不等于"每轮都丢缓存")。边界由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记(`constants/prompts.ts:105-115`): 1. **静态段**(boundary 之前):跨用户、跨请求不变的内容(介绍、编码规范、工具使用指引等),可以设置 `scope: 'global'` 做最高级别缓存 2. **Memoized 动态段**(boundary 之后,`systemPromptSection()`):包含用户/会话特定信息(环境信息、MCP 指令、CLAUDE.md 内容等),**不能走 global cache,但在 session 内只计算一次**,缓存直到 `/clear` 或 `/compact` @@ -854,7 +856,7 @@ description: 不要解释,直接给答案 ## 11 个模式的全景关系 -这 11 个模式(编译期 DCE、极简 Store、工具注册表、Prompt 分段缓存、多层配置、Agent 隔离、安全防线、Bridge IPC、Coordinator-Agent、Migration-as-Code、Output-Style-as-Plugin)并非孤立存在,它们在 Claude Code 中形成了一个协作网络: +这 11 个模式(编译期 DCE、极简 Store、工具注册表、Prompt 分段缓存、多层配置、Agent 隔离、安全防线、Bridge IPC、Coordinator-Agent、Migration-as-Code、Output-Style-as-Plugin)并非孤立存在,它们在 Claude Code 中形成了一个协作网络。下图节点多、交叉边密,**不必逐边背诵**——把它当成一张"按模式编号查邻居"的索引表用就行:每条边的语义都在边标签里、每个节点对应上文的一节。建议挑你最关心的某个模式作为入口(譬如做 IDE 插件时从 BRIDGE 进、做权限审计时从 PERM 进),顺着出/入边读两三跳就够。 ```mermaid graph TD