Book 第十四章:REPL 为什么不是原始工具直通
第三部分:本地执行面

第十四章:REPL 为什么不是原始工具直通

拆 REPLTool 这层透明包装器,理解交互模式为什么不是直接裸跑原始工具。

1. 先说边界

这一篇和前面几篇不太一样。

原因很直接:

这份源码快照里没有 src/tools/REPLTool/REPLTool.ts 主体实现。

当前能直接看到的只有:

  • src/tools/REPLTool/constants.ts
  • src/tools/REPLTool/primitiveTools.ts

以及一大圈外围配套代码:

  • src/tools.ts
  • src/hooks/useMergedTools.ts
  • src/utils/toolPool.ts
  • src/constants/prompts.ts
  • src/utils/collapseReadSearch.ts
  • src/components/messages/AssistantToolUseMessage.tsx
  • src/components/messages/CollapsedReadSearchContent.tsx
  • src/state/AppStateStore.ts
  • src/utils/sessionStorage.ts
  • src/services/tools/toolHooks.ts
  • src/services/extractMemories/extractMemories.ts

所以这一篇不是“按主体实现逐行拆”,而是:

  • 根据外围代码反推 REPLTool 的设计角色和运行方式。

这个边界我先写清楚,免得后面误以为是我没找到。

2. 先说结论

我对 REPLTool 的总体判断是:

Claude Code 的 REPL 不是一个普通工具,而是一个透明包装器。

它真正做的事,不是新增一种业务能力,而是把原本可以直接调用的 primitive tools:

  • Read
  • Write
  • Edit
  • Glob
  • Grep
  • Bash
  • NotebookEdit
  • Agent

统一包进一个持久化 VM 上下文里执行。

也就是说,REPL 模式下 Claude Code 的设计不是:

  • 模型直接调一堆原始工具

而是:

  • 模型先调 REPLTool
  • REPLTool 再在 VM 里重放内部 primitive 调用
  • 宿主 UI 和 transcript 尽量仍然显示成原生工具调用的样子

所以它本质上不是“另一个工具”,而是:

交互模式下的一层执行外壳。

3. 源码锚点

这次主要看的源码是:

  • src/tools/REPLTool/constants.ts
  • src/tools/REPLTool/primitiveTools.ts

配套还看了:

  • src/tools.ts
  • src/hooks/useMergedTools.ts
  • src/utils/toolPool.ts
  • src/constants/prompts.ts
  • src/utils/collapseReadSearch.ts
  • src/components/messages/AssistantToolUseMessage.tsx
  • src/components/messages/CollapsedReadSearchContent.tsx
  • src/state/AppStateStore.ts
  • src/utils/sessionStorage.ts
  • src/services/tools/toolHooks.ts
  • src/services/extractMemories/extractMemories.ts
  • src/hooks/useLogMessages.ts
  • src/Tool.ts

4. 总体结构图

flowchart TD
    A["interactive CLI"] --> B["REPL mode enabled"]
    B --> C["工具池里保留 REPLTool"]
    B --> D["隐藏 REPL_ONLY_TOOLS"]

    D --> D1["Read / Write / Edit / Glob / Grep / Bash / NotebookEdit / Agent"]
    C --> E["模型只直接看到 REPLTool"]

    E --> F["REPL VM context"]
    F --> G["registeredTools"]
    F --> H["console stdout/stderr buffer"]
    F --> I["inner primitive tool calls"]

    I --> J["共享 canUseTool / hook / permission 语义"]
    I --> K["发 repl_tool_call progress"]
    I --> L["产出 isVirtual 原生工具消息"]

    L --> M["UI 折叠 / 摘要 / transcript 变换"]

这张图里最重要的一点是:

REPL 模式没有取消原始工具,而是把它们从“模型直接调用”改成了“REPL 内部调用”。

5. REPL 模式什么时候开:它是交互 CLI 默认行为,不是全局默认行为

constants.ts 里这块很关键。

isReplModeEnabled() 的逻辑不是“只要是 ant 就开”,而是:

  • CLAUDE_CODE_REPL=0 可以显式关闭
  • CLAUDE_REPL_MODE=1 可以显式打开
  • 默认只在
    • USER_TYPE === 'ant'
    • CLAUDE_CODE_ENTRYPOINT === 'cli' 时开启

注释里还专门强调:

  • SDK 入口默认不开

原因也写得很明白:

  • SDK 用户通常希望脚本直接调 Bash / Read / Edit
  • REPL 模式会把这些工具藏起来

这说明 Claude Code 对 REPL 的定位非常明确:

  • 它是 交互 CLI 的默认执行模式
  • 不是通用 agent runtime 的默认执行模式

6. REPL 最核心的动作:隐藏 primitive tools,但不删除 primitive 能力

6.1 REPL_ONLY_TOOLS 就是被包进去的原始能力

constants.ts 里有个非常关键的集合:

  • REPL_ONLY_TOOLS

里面包括:

  • Read
  • Write
  • Edit
  • Glob
  • Grep
  • Bash
  • NotebookEdit
  • Agent

这几乎就是 Claude Code 最核心的一组本地执行能力。

它们被标成 “only accessible via REPL when REPL mode is enabled”。

这已经把设计意图写死了:

  • REPL 不是补充壳
  • 它是这些原始能力的统一调用入口

6.2 getTools() 会在 REPL 模式下把这些工具从直接工具池里滤掉

tools.ts 里逻辑很清楚:

  • 先正常拿到 built-in tools
  • 如果 isReplModeEnabled()
  • 且工具池里确实有 REPLTool
  • 就把 REPL_ONLY_TOOLS 整组过滤掉

所以 REPL 模式的核心机制不是:

  • 多一个 REPLTool

而是:

  • 多一个 REPLTool,同时隐藏一整组 primitive tools

6.3 simple mode 下甚至会直接返回 REPLTool

tools.ts 里还有一个更强烈的信号:

  • simple mode 本来只想给 Bash / Read / Edit
  • 但如果 simple mode + REPL mode 同时开启
  • 就直接返回 REPLTool

注释写得非常直接:

  • REPL wraps Bash/Read/Edit/etc inside the VM

这已经基本坐实了我们对主体缺失部分的判断:

REPLTool` 内部一定是在 VM context 里包装并重新暴露这些 primitive calls。

7. primitiveTools.ts 说明它不是替代品,而是被 REPL 重新托管的原始工具集合

primitiveTools.ts 做的事很简单,但意义很大。

它提供了:

  • getReplPrimitiveTools()

返回的正是那组被隐藏的原始工具。

注释里有几句很关键:

  • 这些工具在 REPL mode 下对模型隐藏
  • 但仍然在 REPL VM context 里可用
  • display-side code 还需要它们来分类和渲染 virtual messages

也就是说,这些 primitive tools 并没有消失,只是执行入口变了。

从这里我们几乎可以确定 REPL 的模型是:

  1. 模型调 REPLTool
  2. REPLTool 在 VM 里注册一组 primitive wrappers
  3. 内部再实际调用真实 Read/Bash/Edit/...
  4. 外层 UI / transcript 用这些 primitive definitions 做显示和分类

8. 它不是简单“执行脚本”,而是一个持久化 VM 会话

这点是从 AppStateStore.ts 反推出来的,而且非常关键。

appState.replContext 里明确存着:

  • vmContext
  • registeredTools
  • console

其中 console 不是原生控制台,而是一个带缓冲接口的对象:

  • log
  • error
  • warn
  • info
  • debug
  • getStdout()
  • getStderr()
  • clear()

这说明 REPL 并不是“每次调用新建一个瞬时 sandbox”。

它更像是:

  • 一个跨多次 REPL 调用持续存在的 VM 上下文

这样做的直接好处是:

  • 可以跨调用共享状态
  • 可以在交互场景下保留脚本上下文
  • console 输出和错误输出可以统一缓冲

9. UI 语义上,REPL 是透明包装器,不该抢走内部工具的可见性

这点从多个地方都能看出来。

9.1 Tool.ts 里已经有“透明包装器”这个概念

Tool.ts 专门留了:

  • isTransparentWrapper?()

注释写得很直白:

  • 透明包装器,比如 REPL
  • 自己不渲染
  • 渲染交给 progress handler 里发出的内部工具块

这几乎就是给 REPLTool 量身定做的接口。

9.2 AssistantToolUseMessage.tsx 对 transparent wrapper 直接走特殊分支

这个组件里如果检测到:

  • tool.isTransparentWrapper?.() === true

那么:

  • queued 或 resolved 的包装器消息直接不显示
  • in-progress 时走专门的 progress 渲染路径

换句话说,Claude Code 在宿主 UI 上明确不想让用户看到:

  • “调用了一个黑盒 REPL”

而是更想让用户看到:

  • “它实际在里面跑了哪些原生动作”

9.3 CollapsedReadSearchContent.tsx 里 REPL 本身也是 silent wrapper

collapseReadSearch.tsREPL_TOOL_NAME 的判断也非常直接:

  • isCollapsible: true
  • isREPL: true
  • isAbsorbedSilently: true

注释里还明确说:

  • REPL 自己不贡献计数
  • 内部 primitive calls 会作为 isVirtual: true 的消息流出来
  • 连续 REPL 调用还能合并

这再次说明:

  • REPL 在产品层不是一个要被强调的“动作”
  • 它只是一个让真实原生动作看起来仍然像原生动作的壳

10. progress 和 virtual messages 说明它内部一定有“工具重放层”

主体文件虽然缺失,但外围证据已经很够了。

10.1 活跃 REPL 调用会发 repl_tool_call progress

CollapsedReadSearchContent.tsx 里有明确逻辑:

  • 活跃 REPL 调用会发 repl_tool_call
  • progress 数据里带当前 inner tool 的 name + input
  • 在 virtual messages 真正落地之前,UI 就靠它显示当前内部在跑什么

也就是说,REPL 内部不是只执行一个大脚本字符串然后一次性吐结果。

它至少还做了一层:

  • 把内部 primitive tool 调用过程转成结构化 progress 事件

10.2 完成后会吐出 isVirtual 的原生工具消息

多处注释都写明了:

  • REPL 内部 primitive calls 会作为 virtual messages 出现

这点在:

  • collapseReadSearch.ts
  • sessionStorage.ts
  • useLogMessages.ts

几处都能相互印证。

所以 REPL 更像是:

  • 一个会重放内部工具调用并生成虚拟 transcript 的执行器

而不是:

  • 一个只返回 stdout/stderr 的脚本盒子

11. 权限和 hook 语义并没有被 REPL 绕过去

这是我认为最重要的设计点之一。

11.1 toolHooks.ts 明确说 REPL 内部调用和主查询循环共享同一套权限解析

resolveHookPermissionDecision() 上面的注释写得非常明确:

  • 这套逻辑被 toolExecution.tsREPLTool/toolWrappers.ts 共享
  • 目的是让权限语义保持 lockstep

这说明 REPL 并不是一个权限后门。

恰恰相反,它内部调用 primitive tools 时,应该仍然复用:

  • hook 预处理
  • allow / ask / deny 解析
  • rule-based permission

11.2 extractMemories.ts 还专门给 REPL 做了说明

在 auto-memory 的 createAutoMemCanUseTool() 里,作者专门写了一个 REPL 特例:

  • REPL mode 下 primitive tools 被隐藏
  • forked agent 会先调 REPL
  • REPL 的 VM wrapper 再对每个 inner primitive 重新调用 canUseTool

这句话非常关键。

它说明了两件事:

  1. REPL 的确会在内部重新分发 primitive tool 调用
  2. 真正的权限检查并没有因为外面只看到 REPLTool 就失效

所以 Claude Code 的 REPL 不是“总授权壳”,而是:

  • 透明外壳 + 内部逐工具复用原权限链

12. Prompt 侧也专门适配了 REPL 模式

constants/prompts.ts 里对 REPL 模式有一段非常关键的分支。

正常模式下,Claude Code 会教模型:

  • 读文件优先 Read
  • 编辑优先 Edit
  • 搜文件优先 Glob
  • 搜内容优先 Grep
  • Bash 只留给真正必须跑 shell 的事

但 REPL 模式下,这整段 guidance 会被裁掉。

注释写得很清楚:

  • 这些 primitive tools 在 REPL mode 下已经不对模型直接可见
  • “优先 dedicated tools 而不是 Bash” 这套建议在这里已经不适用
  • REPL 自己的 prompt 会覆盖如何在脚本里调用它们

这说明 Claude Code 在 prompt 层也在认真区分两种执行模型:

  • normal mode: 直接工具调用
  • repl mode: 通过 REPL 包装调用

13. transcript 持久化也把 REPL 当成“可隐藏的包装层”

这一点很有意思,而且特别能体现产品思路。

13.1 对 external 用户,持久化 transcript 时会把 REPL wrapper 去掉

sessionStorage.ts 里专门有:

  • transformMessagesForExternalTranscript()

逻辑是:

  • 收集所有 REPL tool_use id
  • assistant 侧把 REPL tool_use 去掉
  • user 侧把对应的 REPL tool_result 去掉
  • isVirtual 消息提升成真实消息

结果是什么?

外部用户落盘后的 transcript 看到的不是:

  • REPL -> inner call -> REPL result

而是:

  • 像直接调用了 Bash
  • 像直接调用了 Read
  • 像直接调用了 Grep

13.2 但 ant transcript 会保留 wrapper

注释里也写得很明确:

  • ant transcript 保留 REPL wrapper
  • 这样 /share 的训练数据能看到 REPL 使用情况

这说明 Claude Code 在这里做的是双视图设计:

  • 对产品使用体验,REPL 可以被隐藏
  • 对内部训练和行为分析,REPL 又是值得保留的真实动作

这比“统一记录原始行为”更成熟。

14. 从这些外围代码反推,REPLTool 的主体大概率在做什么

虽然主体文件缺失,但我觉得已经可以比较稳地反推出它的骨架。

大概率包括这几层:

  1. 建立或复用 appState.replContext.vmContext
  2. 往 VM 里注册 registeredTools
  3. 给每个 primitive tool 创建 wrapper
  4. wrapper 内部重新走:
    • input parse
    • hook
    • permission
    • tool.call
    • progress
    • virtual message 回灌
  5. 最终 REPL 本体自己尽量不渲染,只把内部活动吐给 UI

换句话说,REPL 更像是:

  • tool orchestrator inside a persistent VM

而不是传统意义上的 shell REPL。

15. 如果翻成 C#,我建议怎么拆

这层如果以后要做 C# 版,我不建议直接叫它 ReplTool 然后只实现一个脚本执行器。

更适合的拆法大概是:

15.1 包装层和原始工具层分开

  • IPrimitiveToolCatalog
  • ReplExecutionShell

其中:

  • primitive catalog 负责列出被 REPL 托管的原始工具
  • shell 负责注册 VM 上下文、转发调用、输出 progress 和 virtual transcript

15.2 权限层一定要复用原始工具权限,不要给 REPL 单独开总权限

这是最重要的一条。

最好让:

  • REPL 外层只拥有“执行脚本”的壳权限
  • 每个 inner primitive call 仍然走原来的 canUseTool / hooks / permission

否则你做出来的会是一个很危险的后门。

15.3 transcript 层要保留 virtual message 概念

如果没有:

  • virtual assistant message
  • virtual user tool result
  • transparent wrapper

那最后 REPL 要么太黑盒,要么会把 transcript 搅得很乱。

Claude Code 这一层其实给了一个很好的参考:

  • 执行时允许有包装器
  • 展示时优先展示内部动作
  • 持久化时还可以按用户类型做视图变换

16. 最后收一下

REPLTool 这一层最重要的结论是:

Claude Code 在交互 CLI 下,并不总是让模型直接调用 primitive tools。

它更偏向于:

  • REPLTool 承担统一外壳
  • 在内部 VM 里重放 Read / Edit / Bash / Grep / Agent 等原始能力
  • 再通过 progress、virtual messages、transparent wrapper UI,把体验尽量还原成“像在直接调用原始工具”

所以这层真正的设计思路不是:

  • “多一个 REPL 功能”

而是:

  • 把交互式多步执行,包装成一个既能持久化状态、又不破坏原始工具语义的运行时外壳。

如果后面做 C# 版,我会把这层放在 CLI 宿主设计里单独处理,而不是混进普通工具系统里。

因为它本质上已经不只是一个工具了。