Hi,大家好,欢迎来到维元码簿。
本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,专注于 消息路由与传输层------理解一条用户消息如何从 claude.ai 穿越 WebSocket/SSE,经过回声消除和消息去重,最终注入本地 Agent 进程;权限请求又如何反向穿越,在网页弹窗后把"允许/拒绝"传回本地。
本文聚焦一件事:Bridge 的消息中枢如何用不到 500 行代码实现一个可靠的分布式消息系统------包括回声消除、去重、控制指令协议、V1/V2 双传输适配和客户端镜像。
读完全文,你将能回答这几个问题:
- 为什么每条发出的消息会"回声"回来?怎么消除? 答案:SSE 是全双工镜像------你发出的消息也会通过读流回来。
BoundedUUIDSet环形缓冲区用 O(2000) 的固定内存解决了这个问题。 - 服务端发给客户端的 control_request,不回复会怎样? 服务端等待 ~10-14 秒,超时就杀 WebSocket。这是一个硬性约定------客户端必须尽快响应。
- V1 和 V2 的传输层差异如何在代码中隔离?
ReplBridgeTransport统一接口------V1 适配HybridTransport,V2 组合SSETransport+CCRClient。上层代码不关心底层用的是什么。 - RemoteSessionManager 和 Bridge 是什么关系? 镜像关系------Bridge 面向本地 Agent("把远程消息注入本地"),RemoteSessionManager 面向远程用户("把本地消息发送到远程")。
阅读提示 :如果你刚开始了解远程模式,推荐先读本系列的远程模式专栏完整版。本文聚焦消息路由与传输层,可独立阅读,不依赖其他姊妹篇上下文。
本篇覆盖的源码范围
| 模块 | 核心文件 | 核心代码行 | 职责 |
|---|---|---|---|
| 消息路由 | src/bridge/bridgeMessaging.ts |
L1-462 | 入站消息解析、回声消除、control_request 处理 |
| 传输层抽象 | src/bridge/replBridgeTransport.ts |
L1-371 | V1/V2 适配器、统一 ReplBridgeTransport 接口 |
| 客户端会话管理 | src/remote/RemoteSessionManager.ts |
L1-344 | WebSocket 连接、权限请求响应、消息收发 |
| 写栅栏 | src/bridge/flushGate.ts |
L1-72 | transport 重建期间的消息排队状态机 |
前情提要:消息路由层解决的是什么问题
在 V1 篇我们拆解了环境注册和轮询------那是"连接怎么建立"。在 V2 篇我们拆解了直连和 transport 重建------那是"连接怎么维护"。在鉴权篇我们拆解了七步准入链------那是"谁能连进来"。
本文进入最后一层:消息流。 连接建立之后,消息怎么流动?这不是一个简单的"转发数据帧"问题------因为你需要同时面对回声消除(全双工镜像)、消息去重(网络重传)、控制指令协议(服务端到客户端的反向通道)、V1/V2 双传输适配(同一份上层代码)四重挑战。
答案是 Bridge 的消息中枢------不到 500 行代码,用三个设计(环形缓冲区去重、超时约定的 control_request 协议、统一传输接口)撑起了一个可靠的分布式消息系统。
第一章:入站消息路由器------为什么需要三种消息类型的分流
在深入代码之前,先理解一个设计决策:为什么 handleIngressMessage() 要先分类型再处理,而不是统一解析?
因为从 WebSocket/SSE 收到的一条数据帧可能对应三种完全不同的业务语义:① 远程用户点击了"允许/拒绝"按钮(control_response)------需要立即回调给等待中的 Agent;② 服务端发来初始化/切模型/中断指令(control_request)------需要尽快响应,超时 ~10-14 秒否则 WebSocket 被杀;③ 用户输入的对话消息(SDKMessage)------需要去重后再转发。
这三种消息的处理策略完全不同:control_response 是同步应答 (Agent 在等),control_request 是超时敏感 (不回复就断连),SDKMessage 是可容错去重(重复消息丢弃即可)。如果统一解析再分发,就不得不按最严格的条件处理所有消息------比如对普通对话消息也加超时检查,完全是浪费。
理解了这个设计动机,再来看 handleIngressMessage() 的三种分流:
1.1 三种消息类型

typescript
export function handleIngressMessage(
data: string,
recentPostedUUIDs: BoundedUUIDSet,
recentInboundUUIDs: BoundedUUIDSet,
onInboundMessage: ...,
onPermissionResponse?: ...,
onControlRequest?: ...,
): void {
const parsed = normalizeControlMessageKeys(jsonParse(data))
// 类型 ①:control_response --- 远程用户对权限请求的回答
if (isSDKControlResponse(parsed)) {
onPermissionResponse?.(parsed)
return
}
// 类型 ②:control_request --- 服务端发来的控制指令
if (isSDKControlRequest(parsed)) {
onControlRequest?.(parsed)
return
}
// 类型 ③:SDKMessage --- 实际的对话消息(user/assistant/system)
if (!isSDKMessage(parsed)) return
// ...去重和转发逻辑
}
三种消息类型的处理顺序有讲究:
| 顺序 | 类型 | 来源 | 作用 | 处理方式 |
|---|---|---|---|---|
| ① | control_response |
claude.ai 前端 | 远程用户点击了"允许/拒绝" | 直接回调 |
| ② | control_request |
服务端 | 初始化、切模型、切权限、中断 | 必须快速响应 |
| ③ | SDKMessage |
服务端镜像 | 远程用户输入 + 本地消息回声 | 去重后转发 |
1.2 回声消除------为什么要去重
SSE/WebSocket 是全双工镜像------你通过 write path 发送的消息会通过 read path 回到客户端。如果不消除,会形成死循环:
用户发消息 → writeBatch 发送 → SSE 读回 → 当作新消息转发 → 又 writeBatch 发送 → ...
源码用两层防御解决这个问题:
第一层:recentPostedUUIDs(容量 2000 环形缓冲区)
typescript
// bridgeMessaging.ts --- BoundedUUIDSet
export class BoundedUUIDSet {
private readonly capacity: number
private readonly ring: (string | undefined)[]
private readonly set = new Set<string>()
private writeIdx = 0
add(uuid: string): void {
if (this.set.has(uuid)) return
const evicted = this.ring[this.writeIdx]
if (evicted !== undefined) this.set.delete(evicted)
this.ring[this.writeIdx] = uuid
this.set.add(uuid)
this.writeIdx = (this.writeIdx + 1) % this.capacity
}
has(uuid: string): boolean { return this.set.has(uuid) }
}
这不是普通的 Set------它是环形缓冲区 + Set 的组合。当容量满时,最早的消息 UUID 被淘汰。对于长时间运行的会话,2000 条消息的缓冲足够覆盖回声窗口。
第二层:initialMessageUUIDs(无界 Set 回退)
如果 recentPostedUUIDs 因为大量实时写入而淘汰了初始消息 UUID,initialMessageUUIDs 作为回退确保初始消息的回声也能被过滤。
1.3 入站去重
除了回声消除,还有一层入站去重:
typescript
// 防御性去重:跳过已转发的入站消息
if (uuid && recentInboundUUIDs.has(uuid)) {
// SSE 序列号协商可能失败,服务端可能重放历史
return
}
SSE 序列号传递(initialSequenceNum)是主要的防重放机制。但序列号协商可能因 edge case 失败------服务端忽略 from_sequence_num、transport 在收到任何帧之前挂掉等。recentInboundUUIDs 是 safety net。
1.4 只转发 user 类型消息
typescript
if (parsed.type === 'user') {
if (uuid) recentInboundUUIDs.add(uuid)
void onInboundMessage?.(parsed) // fire-and-forget
} else {
// 忽略 assistant、system 等非 user 消息
}
为什么只转发 user 类型? 因为 assistant 消息(模型回复)和 system 消息(内部事件)由本地 Agent 自己产生。远程端只需要发送用户输入------其他一切都是本地的。
第二章:Control Request 协议------为什么服务端需要一个"反向控制通道"
WebSocket/SSE 的主要方向是服务端→客户端推送消息。但如果只有这个方向,服务端无法"命令"客户端做任何事------切模型、中断执行、切换权限模式,这些都只能由本地用户操作。
control_request 就是为此而生的。它是服务端到客户端的反向控制通道------不只是被动推送,而是主动指令+等待回复。这个协议的硬性约束是 ~10-14 秒超时------不回复=杀掉连接。下面看五种指令类型:
2.1 五种指令
| subtype | 作用 | 响应内容 |
|---|---|---|
initialize |
握手初始化 | { commands: [], models: [], account: {}, pid } |
set_model |
切换模型 | 触发 onSetModel 回调 |
set_max_thinking_tokens |
设置思考深度 | 触发 onSetMaxThinkingTokens |
set_permission_mode |
切换权限模式 | 需策略检查,可能返回 error |
interrupt |
中断当前执行 | 触发 onInterrupt 回调 |
关键约束:服务端等待 control_response 的超时是 ~10-14 秒。 如果不回复,WebSocket 会被杀死。所以客户端收到 control_request 必须尽快响应。源码作者把这个约束写进了注释:
Must respond promptly or the server kills the WS (~10-14s timeout).
2.2 initialize 的特殊性
initialize 是所有指令中唯一必须成功 的(即使在 outboundOnly 模式下)。其他可变请求(interrupt、set_model 等)在 outboundOnly 模式下返回 error,只有 initialize 必须成功------否则服务端杀掉连接。
响应内容虽然是最小化的(空 commands、空 models、空 account),但格式必须完整。pid(进程 ID)是唯一填充的有效字段。
2.3 set_permission_mode 的策略检查
typescript
case 'set_permission_mode': {
const verdict = onSetPermissionMode?.(request.request.mode) ?? {
ok: false,
error: 'set_permission_mode is not supported in this context'
}
if (verdict.ok) { /* success */ }
else { /* error with verdict.error */ }
}
不是用户想切什么模式就切什么模式------onSetPermissionMode 回调会检查 isAutoModeGateEnabled 和 isBypassPermissionsModeDisabled。如果组织策略禁用了某个模式,服务端会收到 error 而不是假成功。
2.4 outboundOnly 模式的特殊处理
typescript
if (outboundOnly && request.request.subtype !== 'initialize') {
response = {
type: 'control_response',
response: {
subtype: 'error',
request_id: request.request_id,
error: 'This session is outbound-only. Enable Remote Control locally to allow inbound control.'
}
}
// 返回 error 而非假成功------claude.ai 上看到明确的错误信息
}
返回 error 而不是假成功是关键。 如果返回假成功,claude.ai 上显示"模型已切换",但本地什么都没发生------用户体验非常困惑。error 明确告知"这个会话是只出不进的"。
第三章:传输层抽象------V1 和 V2 的统一接口
replBridgeTransport.ts 定义了 ReplBridgeTransport 统一接口,屏蔽了 V1 和 V2 的传输差异。
3.1 统一接口
typescript
type ReplBridgeTransport = {
write(msg): Promise<void> // 单条发送
writeBatch(msgs): Promise<void> // 批量发送
close(): void // 关闭
isConnectedStatus(): boolean // 连接状态
setOnData(cb): void // 读回调
setOnClose(cb): void // 关闭回调
setOnConnect(cb): void // 连接就绪回调
connect(): void // 建立连接
getLastSequenceNum(): number // SSE 序列号(V2 用)
reportState(state): void // 状态汇报(V2 用)
reportMetadata(metadata): void // 元数据汇报(V2 用)
reportDelivery(eventId, status): void // 投递确认(V2 用)
flush(): Promise<void> // 排空写队列(V2 用)
}
这个接口的设计遵循了 "上层不关心传输协议" 的原则。replBridge.ts 和 remoteBridgeCore.ts 的代码只操作 ReplBridgeTransport,不关心底层是 WebSocket 还是 SSE。
3.2 V1 适配器:createV1ReplTransport
typescript
export function createV1ReplTransport(hybrid: HybridTransport): ReplBridgeTransport {
return {
write: msg => hybrid.write(msg),
writeBatch: msgs => hybrid.writeBatch(msgs),
close: () => hybrid.close(),
// V1 不用 SSE 序列号------始终返回 0
getLastSequenceNum: () => 0,
// V1 不用的 V2 功能------no-op
reportState: () => {},
reportMetadata: () => {},
reportDelivery: () => {},
flush: () => Promise.resolve(),
// ...
}
}
V1 适配器本质上是 HybridTransport 的透传包装。getLastSequenceNum 始终返回 0(V1 不需要序列号传送),V2 专属的 reportState、reportMetadata、reportDelivery、flush 都是 no-op。
3.3 V2 适配器:createV2ReplTransport
V2 适配器比 V1 复杂得多------它组合了两个独立组件:
typescript
export async function createV2ReplTransport(opts): Promise<ReplBridgeTransport> {
// ① SSE 流(读):服务端 → 客户端
const sse = new SSETransport(sseUrl, {}, sessionId, undefined, initialSequenceNum, getAuthHeaders)
// ② CCRClient(写):客户端 → 服务端
const ccr = new CCRClient(sse, new URL(sessionUrl), {
getAuthHeaders,
heartbeatIntervalMs,
heartbeatJitterFraction,
onEpochMismatch: () => { /* epoch 冲突 → 关闭 + 通知 */ },
})
// ③ connect():sse.connect() 是 fire-and-forget,ccr.initialize(epoch) 异步等待
return {
connect() {
void sse.connect() // 打开读流,不等待
void ccr.initialize(epoch).then(() => {
ccrInitialized = true
onConnectCb?.() // 写路径就绪 → 触发 onConnect
})
},
write(msg) { return ccr.writeEvent(msg) },
writeBatch(msgs) {
for (const m of msgs) {
if (closed) break // 逐条发送,closed 时停止
await ccr.writeEvent(m)
}
},
getLastSequenceNum() { return sse.getLastSequenceNum() },
reportState(state) { ccr.reportState(state) },
// ...
}
}
一个值得注意的细节:writeBatch 是逐条发送的。 不是真正的 batch POST------SerialBatchEventUploader 内部做批量化(maxBatchSize=100)。逐条 enqueue 保证顺序,uploader 自动合并。每条之间检查 closed 标志------防止 transport 关闭后继续发送部分批次。
3.4 onEpochMismatch 处理
当 CCRClient 的心跳收到 409(epoch 冲突)时,onEpochMismatch 被触发:
typescript
onEpochMismatch: () => {
try {
ccr.close()
sse.close()
onCloseCb?.(4090) // 4090 = epoch 被取代
} catch { /* ... */ }
throw new Error('epoch superseded') // 必须 throw 以中断调用方
}
为什么必须 throw? handleEpochMismatch 的签名要求 never 返回------调用方(request())在 409 分支后继续执行会导致未定义行为。throw 确保异常传播到 uploader,uploader 将其作为发送失败处理。
第四章:客户端镜像------RemoteSessionManager
src/remote/RemoteSessionManager.ts 是远程客户端 的会话管理器。它不跑在 Bridge 里------它跑在使用 Bridge 的产品中,面向的是连接现有远程会话的"观察者"或"协作者"。
在调用全景中,RemoteSessionManager 服务于两种产品形态:
- 助理模式 (
claude assistant,入口 ⑦):通过useRemoteSession.tshook 创建RemoteSessionManager实例,连接已有 Bridge 会话作为 viewer/controller。 - Direct Connect (入口 ⑧):不经过 REML hook,直接通过
DirectConnectSessionManager建立 WebSocket 连接。
它和 Bridge 是镜像关系:
| 维度 | Bridge(本地代理) | RemoteSessionManager(远程客户端) |
|---|---|---|
| 运行位置 | 用户电脑 | claude.ai 前端 / CLI assistant |
| 连接方式 | SSE + CCRClient(V2)/ HybridTransport(V1) | SessionsWebSocket |
| 入站消息 | 用户输入(从远程来) | Agent 输出(从本地来) |
| 出站消息 | Agent 输出(发送到远程) | 用户输入(发送到本地) |
| 权限处理 | 发送 permission request | 展示权限请求 + 发送响应 |
4.1 核心职责
typescript
class RemoteSessionManager {
connect() → new SessionsWebSocket().connect()
sendMessage(content) → POST /v1/sessions/{id}/events
respondToPermissionRequest(requestId, result)
→ sendControlResponse({ behavior: 'allow'/'deny' })
cancelSession() → sendControlRequest({ subtype: 'interrupt' })
}
4.2 权限请求处理
当远程 Agent 需要权限确认时(比如执行 git push),权限请求通过 WebSocket 到达 RemoteSessionManager:
typescript
private handleControlRequest(request: SDKControlRequest): void {
if (inner.subtype === 'can_use_tool') {
this.pendingPermissionRequests.set(request_id, inner)
this.callbacks.onPermissionRequest(inner, request_id)
} else {
// 不支持的 subtype → 返回 error(避免服务端悬挂等待)
this.websocket?.sendControlResponse({
type: 'control_response',
response: { subtype: 'error', request_id, error: '...' }
})
}
}
不支持的 control_request subtype 也会被处理。 源码不会忽略未知指令------返回 error 响应,避免服务端悬挂等待 reply 直到超时杀死 WebSocket。
4.3 viewerOnly 模式
typescript
viewerOnly?: boolean
当 viewerOnly = true 时(claude assistant 场景):
- Ctrl+C/Escape 不发送 interrupt 到远程 Agent
- 60 秒重连超时被禁用
- 会话标题永不更新
这用于"只读观察者"场景------用户看着 Agent 工作但不干预。
第五章:完整消息流------一条远程消息的旅程
把前面四章串起来,追踪一条远程消息从 claude.ai 到本地 Agent 的完整旅程:
┌──────────────────────────────────────────────────────────────────┐
│ ① claude.ai 前端 │
│ 用户输入 "fix the login bug" │
│ POST /v1/sessions/{id}/events │
└───────────────────────┬──────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ ② 服务端 │
│ 消息 → 通过 SSE 流 或 WebSocket 推送到 Bridge │
└───────────────────────┬──────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ ③ Bridge 收到数据帧 │
│ handleIngressMessage(data, ...) │
│ ├─ 解析 JSON → { type: "user", message: {...}, uuid: "..." } │
│ ├─ control_response? → no │
│ ├─ control_request? → no │
│ ├─ SDKMessage? → yes, type === "user" │
│ ├─ recentPostedUUIDs.has(uuid)? → no(不是回声) │
│ └─ recentInboundUUIDs.add(uuid) → onInboundMessage(msg) │
└───────────────────────┬──────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ ④ 消息注入本地 Agent │
│ REPL 模式:消息注入到现有消息循环中 │
│ 守护进程模式:消息写入子进程 stdin │
│ Agent 开始处理 "fix the login bug" │
└──────────────────────────────────────────────────────────────────┘
如果 Agent 执行时需要权限确认(比如要执行 git push),走一条反向路径:
Agent 需要权限
│
▼
sendControlRequest({ subtype: "can_use_tool", tool_name: "Bash", ... })
│
▼
服务端 → claude.ai 网页弹窗 "Claude 想执行 git push --- 允许/拒绝?"
│
▼
用户点击"允许"
│
▼
服务端 → Bridge SSE 收到 control_response { behavior: "allow" }
│
▼
handleIngressMessage → isSDKControlResponse → onPermissionResponse(res)
│
▼
transport.reportState('running') + 通知 Agent 继续执行
权限回路不依赖轮询。 整个请求-响应过程走 WebSocket/SSE 实时推送------从用户点击到 Agent 继续执行,端到端延迟通常不到 200ms。
本章小结
消息路由层是 Bridge 的"中枢神经"。 它用三个设计保证了分布式消息系统的可靠性:
- 回声消除 :
BoundedUUIDSet环形缓冲区,O(2000) 固定内存解决 SSE 全双工镜像问题 - 去重 :
recentInboundUUIDs作为序列号协商的 safety net------主要防线(序列号)可能失败,但 safety net 不会 - 超时约定:control_response 必须在 ~10-14 秒内响应------这是硬性 API 契约
传输层抽象让 V1 和 V2 的上层代码完全一致。 ReplBridgeTransport 接口只有 ~15 个方法------replBridge.ts 和 remoteBridgeCore.ts 加起来 3000+ 行代码,没有一个地方直接引用 HybridTransport 或 CCRClient 的具体类型。
RemoteSessionManager 和 Bridge 的镜像设计是分布式系统对称性的典范。 两者的消息格式完全一致------都使用 SDKMessage / SDKControlRequest / SDKControlResponse 协议。不同的只是方向------一个是"把远程消息注入本地",一个是"把本地消息发送到远程"。
系列导航:
本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,完整版见本系列远程模式专栏。
本系列其他子篇章覆盖了 V1 环境注册与轮询架构、V2 无环境直连架构、鉴权链与会话生命周期等命题,可独立阅读。
如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋