大家好,欢迎来到维元码簿。本文是《Claude Code 源码 Deep Dive》系列中关于 CLI 交互模块 REPL 架构的深度解析。我们将探讨 Claude Code 如何通过三层架构(UI、Bridge、Engine)实现本地终端与远程控制的无缝切换,以及其背后基于 React 和 Ink 的前端设计哲学。
什么是 REPL?为什么 Claude Code 选择它?
REPL(Read-Eval-Print Loop)是一个经典概念,源自 1960 年代的 Lisp 语言。它由四个阶段组成:读取用户输入 → 求值执行 → 打印结果 → 循环回到第一步。与一次性 CLI 命令(如 git commit、npm install)不同,REPL 的进程不会退出,状态在循环中持续存活。Claude Code 选择 REPL 架构是因为:
- Read:用户敲字、远程消息、斜杠命令都需要进入同一个循环
- Eval:一次对话涉及多轮 API 调用、工具执行、中断和续跑
- Print:模型流式输出需要实时刷新,工具进度需渐进展示
- Loop:会话状态(历史、记忆、权限)需要在轮次间延续
与传统 REPL 的关键差异在 Eval 阶段:Python REPL 的 eval() 是本地求值,而 Claude Code 的 eval() 是“调远程模型 + 本地执行工具 + 处理权限弹窗”——Agent REPL 把经典 REPL 的 Eval 从“纯计算”升维成了“与外部世界的一次协作”。
️ 三层架构全景:UI、Bridge、Engine
Claude Code 的 REPL 架构分为三层:
- UI 层(Ink React):负责用户输入的读取和结果的渲染
- Bridge 层(消息桥接):可选模块,支持远程控制接入
- Engine 层(QueryEngine):核心执行引擎,处理 query 生命周期和命令分发
关键设计在于 Bridge 层是可选模块——如果不启用远程控制,整个 bridge 目录的代码都不会初始化。本地 REPL 可以直接从 UI 层跳到 Engine 层,保持轻量高效。

️ UI 层:Ink React 驱动的终端界面
Claude Code 的终端界面并非用传统的 ANSI escape code 手写,而是使用了 Ink——一个专为命令行设计的 React 渲染器。这意味着:
- 熟悉的组件模型:
Repl是一个 React 组件,拆分为Input、MessageList、StatusBar等子组件 - 响应式更新:消息数组更新 → React diff → 自动增量渲染终端变化部分
- 80+ hooks 的 modularity:每个功能(粘贴处理、文件建议、Diff 预览)都是一个独立 hook
Repl.tsx 有 5006 行——这不是一个“大杂烩”,而是把 REPL 的所有交互逻辑放在同一个 React 组件树中管理。这种设计让 UI 开发变得像使用 Vue 或 React 构建 Web 应用一样直观。
关键 Hook:终端交互的模块化组件
在 Claude Code 中,“hook”有两种类型:React Hook 和 Agent 生命周期 Hook。本小节聚焦于 UI 层的 React Hook。hooks/ 下 80+ 个文件,每个对应一个 hook;Repl.tsx 本身不写输入捕获、也不写历史浏览——它只负责按 UI 状态把这些 hook 组合起来。
| Hook | 职责 |
|---|---|
| 终端输入捕获、光标管理、补全 | |
| 上下箭头浏览历史命令 | |
| 输入建议(文件路径、命令名) | |
| 权限检查——弹窗确认工具调用 | |
| 大消息列表的虚拟滚动 | |
| Ctrl+C 中断处理 | |
| 全局快捷键(Ctrl+R 搜索历史等) |
每个 hook 只做一件事、彼此独立、可单独测试。这就是为什么 REPL 组件树看着庞杂,每一块却能单独替换、单独测试——UI 层的复杂度被 80+ hook 分摊了,没有任何一个 hook 是“全能巨无霸”。
Bridge 层:消息桥接与远程控制
Bridge 层的存在目的只有一个:让 REPL 可以接受来自 claude.ai 的远程控制。你只在本地跑终端时 bridge 完全可以不启动;但你想在手机上回复 Claude Code 的请求,bridge 就是必需的。
核心思想:本地 REPL 是 Agent 的“身体”,claude.ai 是“遥控器”。Bridge 层就是连接身体和遥控器的通信线。这种架构将 execution plane(执行平面)和 control plane(控制平面)彻底解耦:Agent 进程在哪里运行,和“谁在什么设备上向它发指令”,不再被绑死在同一个会话里。
initBridgeCore:桥接核心的六步初始化
// src/bridge/replBridge.ts L260
export async function initBridgeCore(
params: BridgeCoreParams,
): Promise<BridgeCoreHandle | null> {
// 1. 读取 crash-recovery pointer(如果 perpetual 模式)
const prior = perpetual ? await readBridgePointer(dir) : null
// 2. 创建 BridgeApiClient(HTTP 客户端)
const api = createBridgeApiClient({
baseUrl, getAccessToken, onAuth401, ...
})
// 3. 注册 bridge 环境
const bridgeConfig: BridgeConfig = {
dir, machineName, branch, gitRepoUrl,
bridgeId: randomUUID(), environmentId: randomUUID(),
workerType, ...
}
// 4. 创建 transport(消息传输通道)
const transport = createReplTransport({...})
// 5. 创建 bridge session
const sessionId = await createSession({...})
// 6. 启动工作轮询循环
startWorkPollLoop({...})
// 返回 handle
return { writeMessages, writeSdkMessages, teardown, ... }
}

initReplBridge:REPL 专用包装
initReplBridge 是 initBridgeCore 的 REPL 专用包装——它负责读取 REPL 特有的状态(cwd、session ID、git context、OAuth token、session title),然后委托给核心函数:
// src/bridge/initReplBridge.ts
export async function initReplBridge(options: InitBridgeOptions) {
// 读取 bootstrap state
const cwd = getOriginalCwd()
const sessionId = getSessionId()
const branch = await getBranch(cwd)
const gitRepoUrl = await getRemoteUrl(cwd)
const accessToken = getBridgeAccessToken()
const title = generateSessionTitle(...) // 或从 session storage 读
// 委托给核心
return initBridgeCore({
dir: cwd, branch, gitRepoUrl, title,
workerType: 'repl',
getAccessToken: () => accessToken,
createSession: createBridgeSession,
archiveSession: archiveBridgeSession,
toSDKMessages, // REPL 的消息转换器
onAuth401: handleOAuth401Error,
...
})
}
设计价值:核心逻辑(initBridgeCore)不知道 cwd 是什么、git 怎么读、session title 怎么生成。这些都是 REPL 特定的概念,被隔离在 initReplBridge 中。如果将来 Agent SDK 也要用 bridge,它只需写自己的包装,不需要碰核心逻辑。
消息双向流动:出站和入站
消息在 Bridge 层中双向流动:
- 出站方向(REPL → 远程):每个新消息到达时,
useReplBridge用lastSentMessageIndex跟踪已发送的消息索引,调用messageToSDKMessage把内部 Message 转成 SDKMessage 格式,Transport 层负责最终的网络发送 - 入站方向(远程 → REPL):
RemoteSessionClient后台轮询等待远程消息,收到后调用onMessage回调,回调中调用injectMessage注入消息队列,REPL 在合适的时机(当前 query 轮结束后)消费队列中的命令
// src/hooks/useReplBridge.tsx
onInboundMessage: (msg: SDKMessage) => {
enqueue({
prompt: msg.message.content,
priority: 'now',
source: 'remote',
})
}

⚙️ 会话管理:sessionRunner 的进程生命周期
子进程模型:每个会话一个独立进程
远程控制模式下的 Claude Code 不是“一个 daemon 同时跑多个会话”——而是 daemon + N 个子进程:daemon 是管家(sessionRunner 驱动,常驻后台、统管所有会话),每个会话都是一个独立的 Claude Code 子进程,由 daemon 通过 Node.js child_process.fork() 启动:
// src/bridge/sessionRunner.ts
const child = spawn(execPath, scriptArgs, {
env: { ...process.env, ...sessionEnv },
stdio: ['pipe', 'pipe', 'pipe'],
})
子进程里跑的是一个完整的 Claude Code 实例——自己的 QueryEngine、自己的上下文和消息历史、自己的工具执行环境。这也解释了为什么 Claude Code 的“本地 REPL 模式”和“远程控制模式”能共用同一套核心代码:远程模式只是在本地 REPL 外面又套了一层 daemon。
为什么一定是子进程?
把会话做成子进程而不是同进程里的多个对象,付出的是进程启动成本,换来的是四件东西:
- 故障隔离:一个会话崩溃,操作系统直接回收子进程,daemon 和其他会话毫发无损
- 资源回收:会话结束,子进程退出,所有内存、文件句柄被一次性清理
- 并发无锁:每个会话各跑各的 V8 引擎,daemon 不需要复杂的并发安全处理
- 协议天然标准化:父子之间通过 stdin/stdout 说话,倒逼出一套干净、版本独立的通信协议
JSON 行协议与工具活动映射
daemon 读子进程输出的方式极其朴素:一行一个 JSON 对象(NDJSON / JSON Lines),靠换行符分隔消息边界。为什么选这个方案而不是 gRPC、MessagePack 或共享内存?
- 简单:stdin/stdout 是 UNIX 进程通信最老也最稳的方式
- 语言无关:daemon 不 care 子进程是 Node、Python 还是 Rust 写的
- 零 framing 开销:换行符天然给出消息边界
- 可回放:每一行 JSON 都是一条完整事件,抓下来就能用作崩溃现场还原
// stdout 逐行读取——每行是一条 JSON 格式的 SDKMessage
const rl = createInterface({ input: child.stdout })
rl.on('line', (line) => {
const message = jsonParse(line)
onActivity(sessionId, parseActivity(message))
})
daemon 并不是透明转发。子进程吐出来的是给模型看的原始工具调用消息,但 claude.ai 页面上的用户只想看到“Claude 正在 Running Bash”——中间的翻译由工具活动映射表完成:
// src/bridge/sessionRunner.ts L70-89
const TOOL_VERBS: Record<string, string> = {
Read: 'Reading', Write: 'Writing', Edit: 'Editing',
Bash: 'Running', Glob: 'Searching', Grep: 'Searching',
WebFetch: 'Fetching', WebSearch: 'Searching',
Task: 'Running task', ...
}
这张表放在 activityMapper.ts 里不是偶然——它正好夹在“子进程吐 JSON”和“daemon 推送给 UI”的中间,是 daemon 能触及的最早一个“人类可读化”环节。
总结与展望
Claude Code 的 REPL 架构通过 UI 层(Ink React)、Bridge 层(消息桥接) 和 Engine 层(QueryEngine) 的三层分离,实现了本地终端与远程控制的无缝切换。这种设计不仅让前端开发人员可以利用熟悉的 React 组件模型构建命令行界面,还为 Agent 系统的多设备接入、多 Agent 协作和长期运行提供了基础设施级支持。Bridge 层作为可选模块,体现了“按需加载”的设计哲学——它不是一个产品的 feature,而是 Agent 作为软件形态的基础设施。
[AFFILIATE_SLOT_1] [AFFILIATE_SLOT_2]| 协议 | 方向 | 特点 | 对应的产品形态 |
|---|---|---|---|
| SSE(Server-Sent Events) | 单向(服务器→客户端) | 连接建立后不可重连 | 派活式:claude.ai 单向下发任务、daemon 被动接收(早期简单场景) |
| WebSocket | 双向全双工 | 支持重连、消息确认 | 对讲机式:daemon 和 claude.ai 实时双向对话(需要连贯上下文的场景) |
| Hybrid(SSE + HTTP POST) | SSE 接收、HTTP POST 发送 | 两条通道独立故障 | 工程妥协式:CCR v2 默认——用 SSE 吃推送的低延迟、用 HTTP POST 吃请求的可靠性 |
useTextInputuseArrowKeyHistoryuseTypeaheaduseCanUseTooluseVirtualScrolluseCancelRequestuseGlobalKeybindings
浙公网安备 33010602011771号