Hi,大家好,欢迎来到维元码簿。
本文属于 《Claude Code 源码 Deep Dive》 系列,专注于 CLI 交互模块中的 REPL 架构 板块。如果你想了解整个系列,可以先看系列开篇 | Claude Code 源码架构概览:51万行代码的模块地图。
Claude Code 的 REPL 可以在本地终端和 claude.ai 远程控制之间无缝切换,用的是同一套代码。 怎么做到的?答案是一个精心设计的桥接架构------UI 层(Ink React)、Bridge 层(消息桥接)、Engine 层(QueryEngine)三层分离,Bridge 层作为可选模块,拔掉就不影响本地运行。
读完全文,你将能回答这几个问题:
- REPL 怎么在不阻塞终端的情况下等待 API 响应? AsyncGenerator + Ink React 渲染------query 是生成器,Ink 用事件循环驱动渲染。
- 远程控制(claude.ai)和本地 REPL 共用同一套核心代码吗? 是的------
initBridgeCore是核心,initReplBridge是 REPL 专用包装,daemonBridge是远程专用包装。 - 用户从 claude.ai 发来的消息怎么注入到正在运行的 REPL 里? 入站消息经过消息队列(
enqueue)排队,优先级now,在合适的时机被消费。 - 后台任务(TodoWrite、Bash spawn)怎么和主 REPL 共存? 文件持久化 + 信号通知------JSON 文件存在磁盘上,
createSignal()发射事件更新 UI。
本篇覆盖的源码范围
| 模块 | 核心文件 | 核心代码行 | 文件总行 | 职责 |
|---|---|---|---|---|
| REPL 桥接核心 | src/bridge/replBridge.ts |
L260 起(initBridgeCore) + L1851 起(startWorkPollLoop) | 2407 行 | 桥接核心:消息写入、工作轮询、断线重连 |
| 桥接主循环 | src/bridge/bridgeMain.ts |
--- | 3000 行 | 工作轮询主循环、会话创建、令牌刷新 |
| REPL 桥接初始化 | src/bridge/initReplBridge.ts |
--- | 570 行 | REPL 专用参数组装(cwd、git、OAuth、标题生成) |
| REPL Hook | src/hooks/useReplBridge.tsx |
L53-L723 | 723 行 | React 层桥接管理、入站消息注入、权限回调 |
| 会话进程 | src/bridge/sessionRunner.ts |
--- | 551 行 | 子进程生成、stdout JSON 行解析、活动跟踪 |
| 传输层 | src/cli/transports/HybridTransport.ts + SSETransport.ts + WebSocketTransport.ts |
--- | ~4 文件 | SSE / WebSocket / Hybrid 混合传输 |
| 任务系统 | src/Task.ts + src/utils/tasks.ts |
L44-125 + --- | 126+863 行 | 任务类型定义、文件持久化、信号通知 |
| REPL UI | src/screens/REPL.tsx + src/hooks/ |
--- | 5006+ 行 | Ink React 渲染和 80+ hooks |
什么是 REPL?为什么 Claude Code 也叫 REPL
动手拆 Claude Code 的"三层架构"之前,先把标题里的 REPL 这个词讲清楚------它不是 Claude Code 发明的,而是贯穿了半个世纪编程语言和交互系统设计的经典概念。
REPL = Read-Eval-Print Loop
四个单词对应四个阶段:读取 用户输入 → 求值 (执行)→ 打印 结果 → 循环 回到第一步。和一次性 CLI 命令(ls、curl)的本质区别是:进程不退出,状态在循环里一直活着。
这个模型最早出现在 1960 年代的 Lisp------人类历史上第一个真正意义上的交互式编程环境。之后半个世纪里,它在各种语言和工具里反复复现:
| 场景 | 实例 |
|---|---|
| 语言交互解释器 | python、node、irb、Scheme REPL |
| Unix Shell(广义 REPL) | bash、zsh、fish------read command → exec → print → loop |
| 浏览器 DevTools Console | 每个前端开发者每天都在用的 JS REPL |
| 科学计算笔记本 | IPython、Jupyter------有状态的富交互 REPL |
| Web 应用运行时挂钩 | Rails console、Django shell------在运行中的进程里接一个交互入口 |
| 数据库客户端 | psql、mongo shell------SQL / 文档 REPL |
共同的形态:一个持久化进程 + 一个等待输入的循环------和"一次性命令跑完就退出"的 CLI 相对立。
为什么 Agent 选择 REPL
Claude Code 把 Agent 做成 REPL,不是复古,是约束推出来的必然:
- Read:用户敲字、远程消息、斜杠命令都要进入同一个循环------必须有"一直等着收输入"的进程
- Eval:一次对话要跑多轮 API 调用、工具执行、中断、续跑------必须是长任务而非瞬时返回
- Print:模型流式输出需要实时刷新、工具进度需要渐进展示------必须是增量渲染而非一次打印
- Loop:会话状态(历史、记忆、权限决定)要在轮次间延续------必须有活着的进程持有这些状态
和传统 REPL 的关键差异在 Eval 阶段 :Python REPL 的 eval 是本地求值 ;Claude Code 的 eval 是**"调远程模型 + 本地执行工具 + 处理权限弹窗"------Agent REPL 把经典 REPL 的 eval 从"纯计算"升维成了"和外部世界的一次协作 "。循环结构没变,eval 的语义彻底变了------这是 Agent 时代 REPL 相对于语言 REPL 的本质跃迁**。
搞清楚这一点,后面讲三层架构时就能直接落到每一层对应 REPL 的哪个阶段:UI 层做 Read + Print,Engine 层做 Eval,Bridge 层让 Read 的入口可以从远端接入(而不仅仅是本地键盘)。
前情提要:三层架构全景
QueryEngine 编排 query 生命周期、命令系统分发斜杠命令------这两件事是 Agent 执行内核的"大脑"。但谁把用户敲的字符送给大脑、把大脑的输出渲染回终端?谁来处理远程会话的进程管理?REPL 的三层架构回答这三个问题:
| 层级 | 职责 | 核心文件 | 是否可选 |
|---|---|---|---|
| UI 层 | 终端渲染、输入捕获、hooks 驱动 | src/screens/REPL.tsx |
否(REPL 的"脸") |
| Bridge 层 | 消息桥接、远程连接、会话管理 | src/bridge/ |
是(可关闭远程控制) |
| Engine 层 | 查询执行、API 编排 | src/QueryEngine.ts |
否(REPL 的"大脑") |
关键设计:Bridge 层是可选模块------如果不启用远程控制,整个 bridge 目录的代码都不会初始化。本地 REPL 可以直接从 UI 层跳到 Engine 层。

UI 层:Ink React 驱动的终端界面
为什么用 React 写命令行?
Claude Code 的终端界面不是用 ANSI escape code 手写的------它用 Ink(一个专门为命令行设计的 React 渲染器)。这意味着:
- 熟悉的组件模型 :
<REPL>是一个 React 组件,拆成<Input>、<MessageList>、<StatusBar>等子组件 - 响应式更新:消息数组更新 → React diff → 自动增量渲染终端变化部分
- 80+ hooks 的 modularity:每个功能(粘贴处理、文件建议、Diff 预览)都是一个独立 hook
src/screens/REPL.tsx 有 5006 行------这不是一个"大杂烩",而是把 REPL 的所有交互逻辑放在同一个 React 组件树中管理。
关键 Hook:终端交互的模块化组件
先辨析:Claude Code 里的"hook"有两种
动手读 REPL 源码之前先辨清楚------"hook"在 Claude Code 里指向两套完全不同的东西,同名同源、边界截然不同:
| 类别 | 典型入口 | 目录 | 作用域 | 负责什么 |
|---|---|---|---|---|
| React Hook(本小节) | useTextInput、useCanUseTool |
src/hooks/ |
组件 render 内的闭包 | UI 状态与副作用的复用 |
| Agent 生命周期 Hook | registerPostSamplingHook、PreToolUse |
src/utils/hooks/、toolHooks.ts |
跨请求的 pub‑sub | 在 query / tool 生命周期某个时点触发的回调(如 08 系列讲的会话记忆提取、 03 系列讲的 Pre/PostToolUse) |
读源码时扫一眼路径(src/hooks/ vs src/utils/hooks/)就能区分,但读文章时容易被名字误导。本小节在讲的是第一类------REPL UI 层的 React Hook。
设计哲学:UI 复杂度靠"切细 + 组合"分摊
src/hooks/ 下 80+ 个文件,每个对应一个 hook;REPL.tsx 本身不写输入捕获、也不写历史浏览------它只负责按 UI 状态把这些 hook 组合起来。举几个典型:
| Hook | 职责 |
|---|---|
useTextInput |
终端输入捕获、光标管理、补全 |
useArrowKeyHistory |
上下箭头浏览历史命令 |
useTypeahead |
输入建议(文件路径、命令名) |
useCanUseTool |
权限检查------弹窗确认工具调用 |
useVirtualScroll |
大消息列表的虚拟滚动 |
useCancelRequest |
Ctrl+C 中断处理 |
useGlobalKeybindings |
全局快捷键(Ctrl+R 搜索历史等) |
每个 hook 只做一件事 、彼此独立、可单独测试。这就是为什么 REPL 组件树看着庞杂,每一块却能单独替换、单独测试------UI 层的复杂度被 80+ hook 分摊了,没有任何一个 hook 是"全能巨无霸"。
回头看前面说的 Agent 生命周期 Hook------registerPostSamplingHook 也是把"每次采样后要做的事"拆成一组独立的小函数(会话记忆提取、技能自检、MagicDocs 更新)让各自挂载。"切细 + 组合"这条哲学在 UI 层和 Agent 生命周期层以同样的姿态反复出现------这不是巧合,是 Claude Code 用组合而非继承来管理复杂度的副产品。
Bridge 层:消息桥接与远程控制
为什么需要 Bridge 层?
Bridge 层直接的存在目的只有一个:让 REPL 可以接受来自 claude.ai 的远程控制------你只在本地跑终端时 bridge 完全可以不启动;但你想在手机上回复 Claude Code 的请求,bridge 就是必需的。
核心思想:本地 REPL 是 Agent 的"身体",claude.ai 是"遥控器"。 Bridge 层就是连接身体和遥控器的通信线。
一个更大的命题:远程控制是 Agent 时代的必然基座
但把 Bridge 层只当作"claude.ai 移动端的后端",就看小了这件事------Bridge 层的本质是把 execution plane(执行平面)和 control plane(控制平面)彻底解耦:Agent 进程在哪里运行(本地终端、云端容器、远程开发机),和"谁在什么设备上向它发指令",不再被绑死在同一个会话里。
这种解耦在 Agent 走向成熟的过程中,会从"锦上添花"变成"必需品"------至少下面这四条路径都指向同一个结论:
- Multi‑Agent 协作:一个 Agent 调度另一个 Agent,被调度方对调度方来说就是"远程身体"------任务委派 = 远程消息注入 + 远程回调;没有 Bridge 级别的消息桥接,Agent 之间只能靠文件或共享内存传话,无法跨机跨网络协作
- 用户多设备接入:开发者在 IDE 里启动 Agent,开会时从手机继续追问,回家从平板收尾------同一个 Agent 会话、多个入口设备,本质就是 Bridge 层现在在做的事
- 数字员工 / 常驻 Agent:7×24 长期运行的 Agent(监控、运维、知识库维护),用户不是"启动它",而是**"连接到一个早已运行的它"**------这正对应 Bridge 层 perpetual 模式 + crash‑recovery pointer 的使用场景
- AGI 的物理隔离:真要有通用 Agent,它一定不只跑在用户的笔记本上------计算在云端、感知在各种终端、控制可以来自任何输入设备;"本体"和"入口"分离是工程必然
把这四条压到一条技术抽象里:只要"Agent 执行进程长生命周期 + 多入口接入 + 可被另一个 Agent 委派"这三件事里有任一件成立,就必然需要一层 Bridge 。Claude Code 今天做的只是给远程控制开了个头,但这条路一旦走通,所有严肃的 Agent 系统最终都会长出一个类似 Bridge 的模块------它不是某个产品的 feature,是 Agent 作为软件形态的基础设施。
这也是为什么这一层值得拆开讲:看懂 Bridge,就是在看懂 Agent 未来长什么样。
initBridgeCore:桥接核心的六步初始化
typescript
// 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),组装成 BridgeCoreParams,然后委托给核心函数:
typescript
// 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,它只需写自己的包装(daemonBridge),不需要碰核心逻辑。
useReplBridge:React 层的桥接 Hook
useReplBridge 是 React 层的桥接管理器------它监控 replBridgeEnabled flag,当 flag 变为 true 时动态 import initReplBridge 并初始化:
typescript
// src/hooks/useReplBridge.tsx L53
export function useReplBridge(messages, setMessages, abortControllerRef, commands, mainLoopModel) {
const handleRef = useRef<ReplBridgeHandle | null>(null)
const lastWrittenIndexRef = useRef(0)
useEffect(() => {
if (!replBridgeEnabled) return
// 动态 import------bridge 初始化代码只在需要时才加载
initReplBridge({...}).then(handle => {
handleRef.current = handle
})
}, [replBridgeEnabled])
// 每个消息更新后,写增量消息到 bridge
useEffect(() => {
if (!handleRef.current) return
const newMessages = messages.slice(lastWrittenIndexRef.current)
handleRef.current.writeMessages(newMessages)
lastWrittenIndexRef.current = messages.length
}, [messages])
}
消息双向流动:出站和入站
出站方向(REPL → 远程):
- 每个新消息到达时,
useReplBridge用lastWrittenIndexRef跟踪已发送的消息索引 writeMessages()调用toSDKMessages()把内部 Message 转成 SDKMessage 格式- Transport 层负责最终的网络发送
入站方向(远程 → REPL):
startWorkPollLoop()后台轮询等待远程消息- 收到消息后调用
onInboundMessage回调 - 回调中调用
enqueue({priority: 'now'})注入消息队列 - REPL 在合适的时机(当前 query 轮结束后)消费队列中的命令
typescript
// src/hooks/useReplBridge.tsx
onInboundMessage: (msg: SDKMessage) => {
enqueue({
prompt: msg.message.content,
priority: 'now',
source: 'remote',
})
}

会话管理:sessionRunner 的进程生命周期
子进程模型:每个会话一个独立进程
远程控制模式下的 Claude Code 不是"一个 daemon 同时跑多个会话"------而是 daemon + N 个子进程 :daemon 是管家(bridgeMain.ts 驱动,常驻后台、统管所有会话),每个会话(一个用户当前工作目录 + 一条对话线程)都是一个独立的 Claude Code 子进程 ,由 daemon 通过 Node.js child_process.spawn 启动:
typescript
// src/bridge/sessionRunner.ts
const child = spawn(execPath, scriptArgs, {
env: { ...process.env, ...sessionEnv },
stdio: ['pipe', 'pipe', 'pipe'],
})
子进程里跑的是什么? 一个完整的 Claude Code 实例------自己的 QueryEngine、自己的上下文和消息历史、自己的工具执行环境。换句话说,把本地跑的 claude 命令用子进程的方式再启动一遍 ------daemon 只负责启它、喂消息给它、读它的输出,不关心里面在发生什么。这也解释了为什么 Claude Code 的"本地 REPL 模式"和"远程控制模式"能共用同一套核心代码:远程模式只是在本地 REPL 外面又套了一层 daemon。
为什么一定是子进程?
把会话做成子进程而不是同进程里的多个对象,付出的是进程启动成本,换来的是四件东西:
- 故障隔离:一个会话里 QueryEngine 崩了、工具调用 panic 了、内存占爆了------操作系统直接把这个子进程回收,daemon 和其它会话毫发无损
- 资源回收:会话结束 / 断连 / 用户关掉标签,子进程退出,它吃的所有内存、打开的文件句柄、派生的子子进程被操作系统一次性清理------内存泄漏、句柄泄漏这类 daemon 级的恶梦被降维打击
- 并发无锁:每个会话各跑各的 V8 引擎、各有一份 REPL 状态,daemon 不需要在内存里做复杂的"会话路由 + 并发安全"------隔离边界由进程带走,不需要在代码里重新发明一遭
- 协议天然标准化 :父子之间只能通过 stdin/stdout 说话------这条物理约束倒逼着你设计出一条干净、版本独立、可被别的进程替换的通信协议,而不是让 daemon 和 QueryEngine 共享内存数据结构(那种耦合一升级就炸)
第四条尤其关键------子进程模型直接给下一小节的 JSON 行协议 铺好了路。
JSON 行协议:通信的最小共识
daemon 读子进程输出的方式极其朴素:
typescript
// stdout 逐行读取------每行是一条 JSON 格式的 SDKMessage
const rl = createInterface({ input: child.stdout })
rl.on('line', (line) => {
const message = jsonParse(line)
onActivity(sessionId, parseActivity(message))
})
一行一个 JSON 对象(俗称 NDJSON / JSON Lines),靠换行符分隔消息边界。为什么选这个方案而不是 gRPC、MessagePack、共享内存?
- 简单 :stdin/stdout 是 UNIX 进程通信最老也最稳的方式,四十年没变过,调试时
tail -f一下日志就能看到完整通信,不用抓包 - 语言无关:daemon 理论上不需要 care 子进程是 Node、Python 还是 Rust 写的,只要遵守协议;这给未来 Claude Code 换实现留下了最大灵活度
- 零 framing 开销:换行符天然给出消息边界,不需要自己设计 header / length / prefix
- 可回放:每一行 JSON 都是一条完整事件,抓下来就能用作崩溃现场还原或 regression 测试的输入
这也解释了为什么 daemon 并不关心一条消息的语义------它只看换行符。语义解释要么交给 daemon 自己的活动映射层(下节讲),要么直接转给 claude.ai 前端。
工具活动映射:把 Agent 语言翻译成 UI 语言
上一节说 daemon 逐行读子进程的 JSON------但 daemon 并不是透明转发 。子进程吐出来的是给模型看的原始工具调用消息(类似 {"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}),但 claude.ai 那个 Web 页面上的用户只想看到 "Claude 正在 Running ls -la"------中间的翻译就是这张小表在做:
typescript
// 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', ...
}
这张表放在 sessionRunner 里不是偶然------它正好夹在"子进程吐 JSON"和"daemon 推送给 UI"的中间,是 daemon 能触及的最早一个"人类可读化"环节。换个角度看,这是整个 CLI 交互模块里一个很典型的"两种界面语言 "问题:Agent 内部讲的是 Tool JSON(机器语言),用户最终看到的是自然动词(UI 语言) ,两者之间必须有一个翻译层------本地 REPL 里这个翻译发生在 Ink React 组件的渲染路径上(由 UI 层实时渲染为带样式的活动条),远程模式下则必须提前到 daemon 层完成------因为 daemon 才是向 claude.ai 推送消息的发起方,它推给前端的消息里必须已经带着人类可读的活动标签。
所以 TOOL_VERBS 小表本质上是"远程模式下的第一步渲染层"------不到二十行代码,却是"Agent 的 CLI 交互体验"能在 Web 端成立的关键拼图:没有它,用户看到的就是原生 JSON 的 tool_use payload;有了它,才有 claude.ai 上那种"像在看一个工程师在实时教学"的流畅感。
传输层:SSE / WebSocket / Hybrid 混合传输
先看清楚谁和谁在通信
传输层不是每个 Claude Code 用户都会碰到的组件------它只在"远程控制模式"下才活跃。先把通信双方立起来:
- 本地 REPL 模式 :用户直接在终端和 REPL 对话------输入走 TTY、输出走 stdout,根本不需要网络传输层
- 远程控制模式 :本地(或云端) daemon 和 claude.ai 后端 之间建立一条 client‑server 通信------这时才有协议选择问题,而且这条通道需要同时走两个方向:
两个方向、双向实时、还要撑住"手机信号断一分钟"这类不可抗力------这三条约束组合起来,就是传输层要解决的问题。
三种协议对应三种交互形态的演进
Claude Code 的远程传输层经历了一个小型进化史------每一代协议对应一种产品交互形态的取舍:
| 协议 | 方向 | 特点 | 对应的产品形态 |
|---|---|---|---|
| SSE(Server-Sent Events) | 单向(服务器→客户端) | 连接建立后不可重连 | 派活式:claude.ai 单向下发任务、daemon 被动接收(早期简单场景) |
| WebSocket | 双向全双工 | 支持重连、消息确认 | 对讲机式:daemon 和 claude.ai 实时双向对话(需要连贯上下文的场景) |
| Hybrid(SSE + HTTP POST) | SSE 接收、HTTP POST 发送 | 两条通道独立故障 | 工程妥协式:CCR v2 默认------用 SSE 吃推送的低延迟、用 HTTP POST 吃请求的可靠性 |
单讲 SSE 时需要另外找上行通道;单讲 WebSocket 长连接一断就得重建全部状态------Hybrid 是在真实部署中一点点长出来的:上行不应受限于长连接的生死,下行应享受推送的低延迟,干脆把两条通道拆开各管各的。
HybridTransport:为什么用混合模式?
CCR v2 使用 HybridTransport------用 SSE 接收消息(低延迟推送),用 HTTP POST 发送消息(可靠、不依赖长连接):
typescript
// src/cli/transports/HybridTransport.ts
export class HybridTransport {
private sse: SSETransport // 接收
private uploader: SerialBatchEventUploader // 发送
// SSE 连接断开时,HTTP POST 仍然可用
// HTTP POST 失败时,SSE 仍然能接收
}
设计洞察:两个通道独立故障------一个断了另一个不受影响。这是典型的"不要把所有鸡蛋放一个篮子里"的工程思维。

后台任务系统:文件驱动的轻量调度
这个系统要解决什么问题
REPL 在跑,但有一些活儿不能让主对话等:
- 用户说"帮我跑一下全量编译"------派生一个后台 bash 进程跑
npm run build,主对话继续 - Task 工具启动一个子 Agent 去探索另一个目录------子 Agent 独立工作,不阻塞主对话
- MCP 监控脚本每 30 秒检查一次某个资源
- "dream" 模式在空闲时后台"思考"
这些活儿共享三个特征:跨进程 (主 REPL + 各类子进程 + UI 三方都要能看到同一个任务)、跨时间 (可能主对话早就结束了任务还在跑)、跨生命周期 (用户关了终端再打开,未完成的任务不能丢)。把这组约束扔给任何熟悉后端的人,TA 第一反应大概率是"上 Redis / MySQL / MQ 拉一套"------而 Claude Code 恰恰不走这条路。
约束:零外部依赖
Claude Code 是 npm install -g claude 跑起来的 CLI------不能强制用户部署任何基础设施。这条约束是根本的:Redis 用户得装、PostgreSQL 得配、RabbitMQ 更重------连 SQLite 都嫌重(要引依赖、处理迁移、管数据库文件锁)。
能用的"基础设施"只剩两样:文件系统 和 当前进程内的事件机制。后面整个任务系统都是在这两样东西上搭出来的。
文件驱动的调度:任务 = 磁盘上一个 JSON
核心数据结构只有一个------每个任务对应 ~/.claude/tasks/{taskId}.json 这样一个 JSON 文件:
typescript
// src/utils/tasks.ts
const TASKS_DIR = join(getClaudeConfigHomeDir(), 'tasks')
async function createTask(task: TaskInput): Promise<Task> {
const taskId = generateTaskId(task.type)
const taskFile = join(TASKS_DIR, `${taskId}.json`)
await writeFile(taskFile, jsonStringify(task))
notifyTasksUpdated()
return task
}
文件本身承担跨进程持久化 和跨时间生存 :主进程写一份、子进程读一份、UI 层也读------三方不直接通信,靠文件系统这个"隐形数据总线"串起来。实时性靠另一样东西:notifyTasksUpdated() 内部调用的 createSignal().emit()------轻量的进程内事件发射器,任何人改了文件就顺手 emit 一下,UI 立刻重刷(详细机制见下文「任务生命周期与信号通知」)。
所以为什么不用数据库?
- 跨进程共享 → 文件系统天然提供,所有语言、所有进程都能读写
- 实时变化 →
createSignal在进程内广播,跨进程由文件监听补齐 - 可靠持久化 → 磁盘本来就是持久的
- 零外部依赖 →
~/.claude/tasks/目录mkdir -p就有了,不需要装任何东西
数据库能解决这些问题吗? 能。但代价是:用户装、用户配、用户迁移、用户运维------为了一个 CLI 工具的任务追踪功能,这是典型的"大炮打蚊子"。文件系统在这个约束空间下是最优解,不是将就。
7 种任务类型
typescript
// src/Task.ts L6-14
export type TaskType =
| 'local_bash' // 本地 bash 进程(如后台编译)
| 'local_agent' // 本地子 agent
| 'remote_agent' // 远程子 agent(通过 bridge)
| 'in_process_teammate' // 同进程队友(swarm 模式)
| 'local_workflow' // 本地工作流
| 'monitor_mcp' // MCP 监控任务
| 'dream' // 后台"思考"任务
每种任务都有一个前缀字母------用于任务 ID 生成:
typescript
// src/Task.ts L79-87
const TASK_ID_PREFIXES = {
local_bash: 'b', local_agent: 'a', remote_agent: 'r',
in_process_teammate: 't', local_workflow: 'w',
monitor_mcp: 'm', dream: 'd',
}
// Task ID 格式:前缀 + 8 位 base36 随机数,36^8 ≈ 2.8 万亿种组合
export function generateTaskId(type: TaskType): string {
const prefix = getTaskIdPrefix(type)
const bytes = randomBytes(8)
let id = prefix
for (let i = 0; i < 8; i++) {
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
}
return id
}
说人话 :任务 ID 看起来像 b3kf8m2x------第一个字母告诉你任务类型(b=bash, a=agent),后面 8 位是随机串。
任务生命周期与信号通知
任务状态流转:pending → running → completed/failed/killed
typescript
// src/utils/tasks.ts
const tasksUpdated = createSignal()
export const onTasksUpdated = tasksUpdated.subscribe
export function notifyTasksUpdated(): void {
try {
tasksUpdated.emit()
} catch {
// 监听器异常不能影响任务变更------任务操作必须成功
}
}
React 层通过 useTaskListWatcher hook 订阅 onTasksUpdated------任务状态文件一变化,UI 就自动刷新。
设计价值:文件持久化 + 信号通知 = 0 依赖的可靠任务系统。JSON 文件可以跨进程共享(父进程创建任务,子进程更新状态),信号确保 UI 即时响应。不需要 Redis、不需要 MQ、不需要任何外部基础设施。
本章小结
REPL 架构是 Claude Code 的"神经系统"------它把 UI、桥接、引擎三层串联起来,让 Agent 既能在本地终端对话,也能通过 claude.ai 远程控制:
- 三层架构:UI(Ink React)→ Bridge(可选,消息桥接)→ Engine(QueryEngine),Bridge 层拔掉不影响本地运行
- 桥接初始化 :
initReplBridge(REPL 专用包装)→initBridgeCore(通用核心)→startWorkPollLoop()(后台轮询),六步完成初始化 - 消息双向流动 :出站经 Transport 发往远程,入站经消息队列注入 REPL------入站消息优先级
now,确保及时处理 - 会话管理 :子进程以 JSON 行协议通信,
TOOL_VERBS映射工具名到人类可读动词 - 传输层:SSE(接收)+ HTTP POST(发送)的 Hybrid 模式,两个通道独立故障
- 任务系统 :文件持久化 +
createSignal()信号通知------JSON 文件在磁盘上,跨进程共享,零外部依赖
如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋