第十四章:REPL 为什么不是原始工具直通
拆 REPLTool 这层透明包装器,理解交互模式为什么不是直接裸跑原始工具。
1. 先说边界
这一篇和前面几篇不太一样。
原因很直接:
这份源码快照里没有 src/tools/REPLTool/REPLTool.ts 主体实现。
当前能直接看到的只有:
src/tools/REPLTool/constants.tssrc/tools/REPLTool/primitiveTools.ts
以及一大圈外围配套代码:
src/tools.tssrc/hooks/useMergedTools.tssrc/utils/toolPool.tssrc/constants/prompts.tssrc/utils/collapseReadSearch.tssrc/components/messages/AssistantToolUseMessage.tsxsrc/components/messages/CollapsedReadSearchContent.tsxsrc/state/AppStateStore.tssrc/utils/sessionStorage.tssrc/services/tools/toolHooks.tssrc/services/extractMemories/extractMemories.ts
所以这一篇不是“按主体实现逐行拆”,而是:
- 根据外围代码反推
REPLTool的设计角色和运行方式。
这个边界我先写清楚,免得后面误以为是我没找到。
2. 先说结论
我对 REPLTool 的总体判断是:
Claude Code 的 REPL 不是一个普通工具,而是一个透明包装器。
它真正做的事,不是新增一种业务能力,而是把原本可以直接调用的 primitive tools:
ReadWriteEditGlobGrepBashNotebookEditAgent
统一包进一个持久化 VM 上下文里执行。
也就是说,REPL 模式下 Claude Code 的设计不是:
- 模型直接调一堆原始工具
而是:
- 模型先调
REPLTool REPLTool再在 VM 里重放内部 primitive 调用- 宿主 UI 和 transcript 尽量仍然显示成原生工具调用的样子
所以它本质上不是“另一个工具”,而是:
交互模式下的一层执行外壳。
3. 源码锚点
这次主要看的源码是:
src/tools/REPLTool/constants.tssrc/tools/REPLTool/primitiveTools.ts
配套还看了:
src/tools.tssrc/hooks/useMergedTools.tssrc/utils/toolPool.tssrc/constants/prompts.tssrc/utils/collapseReadSearch.tssrc/components/messages/AssistantToolUseMessage.tsxsrc/components/messages/CollapsedReadSearchContent.tsxsrc/state/AppStateStore.tssrc/utils/sessionStorage.tssrc/services/tools/toolHooks.tssrc/services/extractMemories/extractMemories.tssrc/hooks/useLogMessages.tssrc/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
里面包括:
ReadWriteEditGlobGrepBashNotebookEditAgent
这几乎就是 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 的模型是:
- 模型调
REPLTool REPLTool在 VM 里注册一组 primitive wrappers- 内部再实际调用真实
Read/Bash/Edit/... - 外层 UI / transcript 用这些 primitive definitions 做显示和分类
8. 它不是简单“执行脚本”,而是一个持久化 VM 会话
这点是从 AppStateStore.ts 反推出来的,而且非常关键。
appState.replContext 里明确存着:
vmContextregisteredToolsconsole
其中 console 不是原生控制台,而是一个带缓冲接口的对象:
logerrorwarninfodebuggetStdout()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.ts 对 REPL_TOOL_NAME 的判断也非常直接:
isCollapsible: trueisREPL: trueisAbsorbedSilently: 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.tssessionStorage.tsuseLogMessages.ts
几处都能相互印证。
所以 REPL 更像是:
- 一个会重放内部工具调用并生成虚拟 transcript 的执行器
而不是:
- 一个只返回 stdout/stderr 的脚本盒子
11. 权限和 hook 语义并没有被 REPL 绕过去
这是我认为最重要的设计点之一。
11.1 toolHooks.ts 明确说 REPL 内部调用和主查询循环共享同一套权限解析
resolveHookPermissionDecision() 上面的注释写得非常明确:
- 这套逻辑被
toolExecution.ts和REPLTool/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
这句话非常关键。
它说明了两件事:
- REPL 的确会在内部重新分发 primitive tool 调用
- 真正的权限检查并没有因为外面只看到
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_useid - 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 的主体大概率在做什么
虽然主体文件缺失,但我觉得已经可以比较稳地反推出它的骨架。
大概率包括这几层:
- 建立或复用
appState.replContext.vmContext - 往 VM 里注册
registeredTools - 给每个 primitive tool 创建 wrapper
- wrapper 内部重新走:
- input parse
- hook
- permission
- tool.call
- progress
- virtual message 回灌
- 最终 REPL 本体自己尽量不渲染,只把内部活动吐给 UI
换句话说,REPL 更像是:
- tool orchestrator inside a persistent VM
而不是传统意义上的 shell REPL。
15. 如果翻成 C#,我建议怎么拆
这层如果以后要做 C# 版,我不建议直接叫它 ReplTool 然后只实现一个脚本执行器。
更适合的拆法大概是:
15.1 包装层和原始工具层分开
IPrimitiveToolCatalogReplExecutionShell
其中:
- 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 宿主设计里单独处理,而不是混进普通工具系统里。
因为它本质上已经不只是一个工具了。