双架构演进与NDJSON远程协议——Agent突破本地边界

《Claude Code 架构解密》精读笔记 · 第18篇 · 第13章

核心洞察:Agent远程化的关键不是"如何传输",而是"状态归谁所有"——这个决定将导出通信架构、安全模型和故障语义的全部设计。


导语:当 Agent 必须离开终端

前十二章讨论的所有机制——查询循环、工具执行、权限审批、上下文压缩——都假设用户坐在终端前,与本地CLI实时交互。但现实世界的场景远比这复杂:

  • 📱 通勤时用手机查看Agent是否完成了耗时重构任务
  • 🌐 在浏览器中直接向本地机器发起新的编码请求
  • 🔄 同时管理多个并行的编码会话

这带来了一个根本性的架构挑战:如何将一个为本地设计的Agent系统透明地延伸到远程端,同时不降级安全保证、不丢失消息一致性?

Claude Code 的回答是 Bridge/Remote 系统——一套"语义级会话桥接"架构。本章将揭示:为什么不用SSH?v1/v2双架构如何演进?三层消息去重解决了什么问题?权限如何跨端桥接?Token生命周期如何管理?


一、三条路径的抉择:为什么不用SSH?

1.1 远程交互的三种经典方案

面对"远程控制本地Agent"的需求,架构师有三种经典选择:

方案 传输内容 代表产品 延迟敏感度 安全模型
终端流转发 字符/ANSI转义序列 SSH、tmux、Mosh 高(每次按键) 全权委托
UI状态同步 渲染后的UI组件树 VNC、Chrome Remote Desktop 高(帧级) 全权委托
语义消息桥接 协议消息(用户输入、工具审批) Claude Code Bridge 低(消息级) 可选择性审批

Claude Code 选择了第三条路——语义消息桥接,背后有三个深层原因:

1.2 三个不可妥协的理由

理由一:权限模型不可降级。 回顾第5章的权限系统,Claude Code的核心安全承诺是"每个危险操作都需要用户确认"。终端转发方案(如SSH)将远程端视为"完全信任的用户"——按下回车就等同于本地执行,整个权限系统被绕过。语义桥接则保留了消息级别的权限拦截点:远程容器发出 can_use_tool 控制请求,本地TUI弹出审批对话框,审批结果通过 control_response 回传。

理由二:带宽效率。 Agent对话的交互模式是"突发式"——用户发送一个请求,Agent可能执行10分钟然后返回结果。实时字符流转发(SSH/Mosh)的大部分带宽浪费在Spinner动画和进度条这些无语义信息上。语义桥接只传输有意义的消息:用户输入、工具调用、模型响应。

理由三:多端渲染自由。 语义消息是结构化数据(JSON),而非终端字符流。Web端可以用React组件渲染消息,移动端可以用原生UI渲染,不必模拟终端。这种解耦使各客户端体验独立演进。

1.3 Bridge/Remote的架构定位

Bridge/Remote系统横跨编排层(L3)和交互层(L4),是一个"横切层":

Claude Code 五层架构
┌──────────────────────────────────────────────┐
│ L5 入口层: main.tsx / cli.tsx                 │
│     本地路径 ↕ 远程路径                        │
├──────────────────────────────────────────────┤
│ L4 交互层: REPL.tsx ↔ useRemoteSession       │
├──────────────────────────────────────────────┤
│ L3 编排层: QueryEngine ↔ RemoteSessionManager│
├──────────────────────────────────────────────┤
│ Bridge/Remote系统(横切层)                    │
│ src/bridge/(出站方向:本地→云端)             │
│   bridgeMain.ts — 独立桥接服务器              │
│   replBridge.ts — REPL桥接核心(v1)            │
│   remoteBridgeCore.ts — Env-less桥接(v2)      │
│   bridgeMessaging.ts — 消息路由与去重          │
│   sessionRunner.ts — 子进程孵化器              │
│ src/remote/(入站方向:云端→本地)             │
│   RemoteSessionManager.ts — 远程会话管理       │
│   SessionsWebSocket.ts — ws客户端              │
│   sdkMessageAdapter.ts — 协议适配              │
│   remotePermissionBridge.ts — 权限桥接         │
├──────────────────────────────────────────────┤
│ L1 基础设施层:API / OAuth / 状态存储          │
└──────────────────────────────────────────────┘

关键架构决策:Bridge和Remote是两个方向相反的系统。 Bridge是"把本地会话暴露给云端"(出站),Remote是"在本地消费云端会话"(入站)。它们共享消息协议和去重机制,但职责完全独立。

1.4 与主流远程方案的核心对比

维度 Claude Code Bridge VS Code Remote GitHub Codespaces Cursor Remote
传输语义 Agent协议消息(语义级) 完整开发环境(容器级) LSP+文件系统(API级) 编辑器+AI(混合级)
计算位置 本地执行,远程观察 远程执行,本地渲染 全远程 远程推理+编辑
权限模型 跨端审批 隐式信任(SSH) 容器沙箱 编辑器权限
离线能力 断连不中断执行 断连即停止 断连即停止 部分可用
状态所有权 本地拥有 远程拥有 远程拥有 混合

💡 核心洞察:状态所有权决定通信架构。 表中最根本的差异在最后一行。VS Code Remote和Codespaces将状态放在远程,本地只是渲染层。Claude Code恰恰相反——计算留在本地,远程只是交互的另一个入口。这个决策的连锁效应是深远的:

  1. 断连语义不同:远程拥有状态时,断连=工作停止;本地拥有状态时,断连=远程端丢失视图,但Agent继续执行
  2. 一致性要求不同:远程拥有状态时,需要"可靠队列"保证每条消息到达;本地拥有状态时,"尽力同步"就够——丢几条消息不影响执行,重连后可以重新flush完整历史
  3. 安全模型不同:远程拥有状态时,远程端是"完全信任的执行环境";本地拥有状态时,远程端只是"受控的交互通道"

二、双架构并存:v1与v2的演进

2.1 从纯本地到分布式的三阶段

Claude Code的远程能力经历了清晰的三阶段演进:

阶段1:纯本地REPL(初始架构)

用户 → REPL → QueryEngine → [工具执行 + 权限审批]
全部在同一进程内
问题:用户必须守在终端前

阶段2:v1 Env-based Bridge(Daemon模式)

CCR后端 ← bridgeMain(持久进程)→ 环境注册 → Web/App
              ↓ spawn
         子进程CLI
         JWT管理
优势:多会话、持久化、崩溃恢复
代价:冷启动慢(注册→轮询→spawn约5-10秒)

阶段3:v2 Env-less Bridge(快速连接)

Code Session → REPL进程(同进程)→ claude.ai → API/Web/App
              无需spawn,直接JWT
优势:3次HTTP请求+1次SSE建立,2秒内完成
代价:不支持多会话、不支持持久模式

为什么没有直接跳到阶段3?这是一个经典的"先做对,再做快"的架构决策。v1的Daemon模式承担了关键的验证使命——它证明了远程控制的产品价值和安全模型。权限桥接、消息去重、崩溃恢复这些核心能力是否必需?在v1中得到了回答。v2在此基础上做减法——保留已验证的核心能力,去掉REPL场景不需要的复杂性。

2.2 v1与v2的架构对比

维度 v1 Env-based v2 Env-less
入口文件 replBridge.ts remoteBridgeCore.ts
连接模型 注册环境→长轮询→接受工作→建传输 创建会话→换JWT→直接建传输
传输协议 WebSocket或SSE SSE + HTTP POST
认证链 OAuth→Environment Secret→Session JWT OAuth→worker_jwt
多会话 支持(最多32个并发) 不支持
持久模式 支持(perpetual) 不支持
适用场景 Daemon/多会话/claude remote-control REPL快速连接
代码规模 ~2,400行 ~1,000行
功能开关 tengu_ccr_bridge tengu_bridge_repl_v2

2.3 并存的代价:三处代码分叉

v1/v2并存的架构债务体现在三处代码分叉:

① 消息写入路径分叉

  • replBridge.tswriteMessages() — 1,800行闭包中的62行实现
  • remoteBridgeCore.tswriteMessages() — 独立的45行实现
  • 语义相同但去重策略细节不同

② Token刷新策略分叉

  • v1:updateAccessToken → 子进程环境变量热更新
  • v2:rebuildTransport → 重建整个传输层

③ 条件分支蔓延

// initReplBridge.ts
if (isEnvLessBridgeEnabled() && !perpetual) // 选择 v1 还是 v2
// 但 perpetual 的判断散落在多处

⚠️ 设计模式:渐进式架构演进的陷阱。 “先做对,再做快"是正确的策略,但如果v1的某些能力无法被v2覆盖,你得到的不是"渐进式演进”,而是两套需要长期维护的代码路径。解法是在v1阶段就为未来简化预留抽象点——将"多会话管理"和"连接建立"明确分离为独立模块,使v2可以替换后者而复用前者。

2.4 v1连接建立流程

initBridgeCore(params)
  ① 读取崩溃恢复指针(bridge-pointer.json)
  ② createBridgeApiClient(baseUrl, getAccessToken, ...)
  ③ api.registerBridgeEnvironment(config) → environmentId + secret
  ④ [perpetual] tryReconnectInPlace(prior.envId, prior.sessionId)
  ⑤ createSession({environmentId, title, ...}) → sessionId
  ⑥ writeBridgePointer(dir, {sessionId, environmentId})
  ⑦ startWorkPollLoop(pollOpts) ← 核心轮询循环
     pollForWork() → work → decodeWorkSecret() → acknowledgeWork()
     useCcrV2? → createV2ReplTransport(SSE + CCR)
     v1? → createV1ReplTransport(HybridTransport/WS)
     wireTransport → onConnect → flushHistory → ready
  ⑧ registerCleanup(teardown)

步骤⑦的startWorkPollLoop是一个长轮询循环:Bridge向CCR后端发送pollForWork请求,后端挂起连接直到有新的远程工作到来(或超时)。这种"服务端推送"模式虽然增加了连接建立的延迟,但避免了维护持久WebSocket连接的复杂性。

2.5 v2连接建立流程

v2的设计哲学是"能省则省"——去掉环境注册、长轮询和子进程:

initEnvLessBridgeCore(params)
  ① getEnvLessBridgeConfig()         // GrowthBook 配置
  ② withRetry(createCodeSession)     // POST → session_id
  ③ withRetry(fetchRemoteCredentials) // POST → worker_jwt + epoch
  ④ buildCCRv2SdkUrl(api_base_url, sessionId)
  ⑤ createV2ReplTransport(SSE + CCRClient)
  ⑥ 初始化去重Sets + FlushGate
  ⑦ createTokenRefreshScheduler(5min buffer)
  ⑧ wireTransportCallbacks()
  ⑨ transport.connect()              // SSE + CCR initialize
  ⑩ 返回 ReplBridgeHandle

从调用到连接可用只需3次HTTP请求+1次SSE建立,冷启动时间从v1的5-10秒降低到2秒以内。代价是放弃了多会话和持久模式——典型的"场景聚焦"Trade-off。


三、消息传输架构:三层去重的精巧设计

3.1 消息流向拓扑

消息在两个方向上流动,每个方向有独立的处理管线:

出站(本地→云端):
  REPL消息更新(useEffect)
    → Bridge层过滤+转换
      → isEligibleBridgeMessage → toSDKMessages
        → UUID去重
          → /worker/events (HTTP POST)

入站(云端→本地):
  SSE/WebSocket事件流
    → 控制请求路由
      → handleIngressMessage
        → Echo过滤
          → Re-delivery去重
            → REPL渲染(setMessages)

3.2 出站消息过滤

不是所有本地消息都值得同步到远程端。bridgeMessaging.ts中的isEligibleBridgeMessage函数扮演了"消息海关":

消息类型 是否转发 原因
user 用户输入是对话核心
assistant AI响应需要远程可见
system (subtype: local_command) 斜杠命令结果需同步
system (其他) 内部系统消息无需远程展示
虚拟消息(synthetic) 本地渲染辅助,非实际对话

💡 这个过滤策略体现了语义桥接的核心优势——只传输有业务意义的消息,大幅减少网络开销。大量的Spinner更新、进度条和虚拟分隔线都被过滤掉了。

3.3 三层消息去重

消息去重是Bridge系统最精巧的设计之一。为什么需要三层? 因为重复消息来自三个不同的来源,每个来源需要不同的去重策略。

第一层:序列号续传(Sequence Number)

SSE传输支持from_sequence_num/Last-Event-ID,Transport重建时继承lastTransportSequenceNum,防止服务器重放完整历史:

旧Transport(seq=42)—close—→ 新Transport connect(from_sequence_num=42)
                                → 服务器从seq=43开始发送

第二层:BoundedUUIDSet(环形缓冲区去重)

序列号解决了传输层重放,但还有另一种重复:你发出的消息被服务器Echo回来。BoundedUUIDSet用环形缓冲区+Set组合解决:

class BoundedUUIDSet {
  private ring: (string | undefined)[]  // 固定容量2000的环形数组
  private set = new Set<string>()       // O(1)查找
  private writeIdx = 0

  add(uuid: string): void {
    if (this.set.has(uuid)) return
    // 驱逐最旧的UUID,为新UUID腾出空间
    const evicted = this.ring[this.writeIdx]
    if (evicted) 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会随时间无限膨胀;LRU Cache又太重。环形数组+Set的组合用O(capacity)的固定内存实现了O(1)的查找和插入,最旧条目自动被驱逐。容量设为2000,足以覆盖一个典型会话中的往返消息数量。

系统实际维护了两个独立的BoundedUUIDSet

  • recentPostedUUIDs:记录自己发出的消息UUID,过滤Echo回来的副本
  • recentInboundUUIDs:记录收到的入站消息UUID,防御传输切换时的重复投递

v2还额外维护一个initialMessageUUIDs(普通Set),用于兜底初始flush阶段的UUID去重。

第三层:FlushGate门控

前两层解决了单条消息的去重,但还有一个更微妙的问题:批量同步与增量同步的交错

FlushGate时序:
时间 ─────────────────────────────────────────────→
      闸门关闭         闸门打开
      [flush → drain →]
      历史消息          新消息进入队列  排空队列  正常传输
      [批量发送]        [暂时缓冲]      [按序发出] [实时]

当Transport首次连接(或重建)时,Bridge需要将历史消息批量flush到远程端。在flush进行期间,新的实时消息可能已经开始到达。如果不加控制,这些实时消息可能插入到历史消息中间,导致时序错乱。FlushGate的解法是:

  1. 初始历史flush开始时,闸门关闭——新消息进入等待队列
  2. flush完成后,闸门打开——排空队列中的等待消息,恢复正常实时传输
  3. Transport重建时也启动FlushGate,防止旧epoch的消息被静默丢弃

💡 设计模式:分层去重。 当消息重复来自多个独立来源(传输层重放、Echo回环、批量/增量交错)时,一层去重无法覆盖所有情况。正确的做法是:先枚举所有可能的重复来源,再为每个来源设计对应的去重策略。层与层之间互不依赖,任何一层失效不影响其他层的防护。

3.4 为什么不用消息队列?

方案 优势 劣势
当前:尽力同步 低延迟、无持久化开销、代码简单 Transport断开时消息丢弃
替代:本地消息队列 消息不丢失、断线重传 需持久化、需幂等性保证、复杂度高

当前设计的合理性在于:Bridge同步的是REPL会话的"实时视图",不是关键业务数据。即使丢失几条消息,用户在Transport恢复后仍能通过重新flush历史消息获得完整视图。writeMessages函数在无transport时直接丢弃并记录警告,明确接受了这个Trade-off。

这是"状态归本地所有"的又一个推论——既然远程端只是"视图",那视图短暂不完整是可以接受的,不值得为此引入持久化消息队列的复杂性。


四、会话生命周期管理

4.1 独立桥接模式:bridgeMain.ts

bridgeMain.ts是一个近3000行的"微型会话调度器",作为独立的Daemon进程运行,管理多个远程会话的完整生命周期:

bridgeMain()启动流程:
  参数解析 → OAuth认证 → 信任检查
  → 选择spawn模式:
    single-session:一个会话,结束即退出
    same-dir:多会话共享目录
    worktree:多会话各自git worktree隔离
  → 注册环境 → POST /v1/environments/bridge
  → 预创建空会话 → createBridgeSession()
  → 进入runBridgeLoop():
    pollForWork() → 长轮询获取工作项
    decodeWorkSecret() → 提取JWT
    acknowledgeWork() → 确认接受
    safeSpawn() → sessionRunner创建子进程
      子进程: claude --print --sdk-url <url>
              --input-format stream-json
              --output-format stream-json
              --replay-user-messages
      stdout → NDJSON解析 → 活动状态提取
      stdin → Token刷新(update_environment_variables)
      stderr → 环形缓冲区(最近10行诊断)

spawn模式值得关注:worktree模式为每个远程会话创建独立的git worktree,实现了文件系统级别的隔离——多个远程用户可以同时在同一仓库的不同分支上工作,互不干扰。这与第6章讨论的Agent编排中的fork-and-delegate模式异曲同工。

4.2 容量管理与心跳模式

当并发会话达到上限(默认32,受GrowthBook Feature Flag控制)时,Bridge不是简单地拒绝新连接,而是切换到"心跳模式":

容量满载时的行为切换:
  正常模式:pollForWork() → 获取新工作 → spawn子进程
    ↓ activeSessions.size >= maxSessions
  心跳模式:heartbeatActiveWorkItems() → 维持环境存活
    → 某会话结束 → capacityWake.signal()
  正常模式:立即恢复pollForWork()

为什么需要心跳?如果Bridge停止轮询,CCR后端会认为环境已死亡并回收它。心跳模式通过定期调用heartbeatWork()保持环境存活,同时不接受新工作。

容量唤醒机制(capacityWake)是另一个精巧的设计:当任何会话结束时,capacityWake.signal()会立即中断心跳模式的sleep,让Bridge尽快回到pollForWork接受新工作。这避免了用户不必要的等待。

4.3 崩溃恢复:双策略恢复

对于Daemon模式的Bridge,进程崩溃是现实威胁。Bridge使用"指针文件+双策略恢复"来应对:

崩溃恢复链路:
  写入指针: writeBridgePointer(dir, { sessionId, environmentId, source })
    → 在会话创建后立即写入
    → 每小时刷新mtime
    → 4小时TTL过期自动删除

  读取指针: readBridgePointerAcrossWorktrees()
    → --continue参数触发
    → 扫描当前目录及所有git worktree

  恢复流程:
    → 策略1:原地重连(reconnect-in-place)
      reuseEnvironmentId → 后端返回相同envId → reconnectSession()
      优势:快速,复用旧会话UUID和历史消息
    → 策略2:新建会话(fresh session fallback)
      后端返回不同envId → archiveSession(旧) → createSession(新)
      触发条件:环境已被后端GC(4小时后)

双策略恢复体现了一个通用模式:乐观尝试+悲观兜底。先尝试最快的恢复路径(原地重连),如果失败则自动fallback到更安全但更慢的路径(新建会话)。

4.4 优雅关机

Bridge的关机不是简单的process.exit(),而是一个有序的teardown序列:

teardown()流程(非perpetual模式):
  ① transport.write(makeResultMessage)    ← 通知服务端会话结束
  ② api.stopWork(force=true)              ← 释放工作项
  ③ archiveSession(currentSessionId)       ← 归档会话
  ④ transport.close()                      ← 关闭传输
  ⑤ api.deregisterEnvironment(envId)       ← 注销环境
  ⑥ clearBridgePointer(dir)                ← 清理指针文件

持久模式(perpetual)的差异值得注意——“选择性不清理”:

  • 不发送result → 服务端不归档会话(会话保持"活跃")
  • 不注销环境 → 环境保持存活(依赖300秒leaseTTL自动回收)
  • 不清理指针 → 下次--continue可以恢复

这种设计让持久模式的Bridge可以在重启后快速恢复上一个会话。代价是如果Bridge进程意外消失,环境会在300秒后被TTL自动回收——合理的超时窗口。


五、Remote系统:消费端架构

5.1 四种远程模式

模式 实现 用例
–remote (CCR云端会话) RemoteSessionManager → SessionsWebSocket + HTTP POST 连接到远程运行的Claude Code会话
claude assistant (CCR viewerOnly) 同上,但禁用interrupt/标题更新/超时检测 只读观察远程会话
claude connect (Direct Connect) DirectConnectManager → 类似API,连接本地服务器 IDE集成/本地多客户端
claudessh (SSH子进程) SSH tunnel → stdin/stdout传输 通过SSH隧道连接远程机器

REPL通过统一抽象选择当前生效的远程后端:

const activeRemote = sshRemote.isRemoteMode
  ? sshRemote
  : directConnect.isRemoteMode
    ? directConnect
    : remoteSession

四种模式在回调形状(onMessage、sendInput、onPermissionRequest等)上保持一致,使REPL不需要关心消息实际来自哪种远程后端——依赖注入模式的又一个应用。

5.2 WebSocket断线重连策略

不同的关闭原因对应不同的重连行为:

关闭码 含义 重连策略
4003 Unauthorized 永久关闭,不重连
4001 Session not found 独立重试最多3次,递增延迟
其他 网络/服务器错误 仅在之前连接成功时重试,最多5次

4001有独立重试计数器的设计值得特别关注:这是为了应对"compaction期间服务器短暂认为会话不存在"的瞬态错误。如果4001共用通用重连预算,一次compaction导致的3次4001可能耗尽预算,后续真正的网络错误就无法重连了。

5.3 权限跨端桥接:最精妙的设计

权限桥接是Remote系统最精妙的设计——远程容器需要执行工具,但权限确认必须在本地TUI中弹出:

权限桥接流程:
  CCR容器执行工具
    → ws发送 control_request { subtype:'can_use_tool', tool_name, input }
    → RemoteSessionManager.handleControlRequest()
    → pendingPermissionRequests.set(request_id, request)
    → onPermissionRequest(request, request_id)
    → useRemoteSession.onPermissionRequest()
    → findToolByName(tools, tool_name)
    → createToolStub(tool_name)           ← 关键:工具桩
    → createSyntheticAssistantMessage(     ← 关键:合成消息
        request, requestId)
    → 入队 ToolUseConfirm → 本地TUI弹出审批对话框
  用户本地审批
    → onAllow(updatedInput)
    → manager.respondToPermissionRequest(
        requestId, { behavior: 'allow' })
    → SessionsWebSocket.sendControlResponse(response)
    → ws.send() → 回传到CCR容器

两个关键技巧

① createToolStub:为远程工具创建一个最小的本地Tool桩。本地可能没有这个工具的完整定义(远程端可能安装了不同的工具集),但权限UI只需要工具名称和输入参数就能展示审批对话框。桩对象提供了"刚好够用"的接口,避免了在两端同步完整工具注册表。

② createSyntheticAssistantMessage:构造一条"假装来自本地Agent"的AssistantMessage。这条合成消息包含工具调用信息,让权限审批UI与本地工具调用的UI保持完全一致。用户在审批远程工具调用时,看到的界面和审批本地工具调用完全相同——这种"透明化"是Bridge架构的核心价值。

💡 设计模式:合成消息统一入口。 当需要在不同上下文(本地/远程)中复用同一交互流程时,可以通过构造"合成消息"将外部事件伪装成内部事件,共享同一处理管线和UI路径。这避免了为远程场景开发独立的交互流,降低了维护成本并保证了体验一致性。


六、Token生命周期管理

6.1 主动刷新调度器

Bridge不等Token过期后再刷新,而是提前5分钟主动刷新:

createTokenRefreshScheduler({ refreshBufferMs = 5min })
  schedule(sessionId, jwt):
    → 解码JWT exp claim
    → 计算 delayMs = (exp - now - 5min)
      - delayMs ≤ 0:立即刷新
      - delayMs > 0: setTimeout(doRefresh, delayMs)

  doRefresh(sessionId):
    v1: handle.updateAccessToken(oauthToken)
      → 通过子进程stdin发送update_environment_variables
      → 特点:热更新,子进程不中断
    v2: api.reconnectSession(envId, sessionId)
      → 让服务端重派带新JWT的工作项
      → 特点:冷更新,触发rebuildTransport

v1与v2的刷新方式差异值得深思:

  • v1是"热更新"——通过子进程stdin注入新的环境变量,子进程无需中断
  • v2是"冷更新"——触发rebuildTransport,重新建立SSE连接
  • v2的方式更安全(新JWT直接用于新连接,不存在旧JWT残留的风险),但有短暂的断连窗口

6.2 OAuth 401恢复链

当SSE/WebSocket收到401错误,触发精心设计的恢复链:

SSE/WS收到401
  → recoverFromAuthFailure():
    → 检查authRecoveryInFlight(防重入)
    → onAuth401(staleToken) → 刷新OAuth Token
    → fetchRemoteCredentials() → 新worker_jwt + epoch
    → rebuildTransport(newCredentials, 'auth_recovery')
    → seq = transport.getLastSequenceNum() // 继承序列号
    → transport.close()
    → transport = createV2ReplTransport({...})
    → wireTransportCallbacks()
    → transport.connect()
    → drainFlushGate() ← 排空等待中的消息

关键竞态防护authRecoveryInFlight标志用于串行化401恢复与定时刷新。考虑以下场景:

  1. Token在5分钟缓冲期内过期(可能时钟偏移导致)
  2. SSE触发401恢复
  3. 同时,定时刷新也被触发

如果两者并发执行,可能双发/bridge请求导致epoch不一致(HTTP 409冲突)。authRecoveryInFlight确保同一时刻只有一个恢复流程在执行。

6.3 跨进程退避

initReplBridge.ts中还实现了更高层次的保护——跨进程的OAuth退避:

跨进程退避机制:
  进程A:OAuth刷新彻底失败
    → 写入GlobalConfig:
      bridgeOauthDeadExpiresAt = now + backoff_period
      bridgeOauthDeadFailCount = count + 1

  进程B:准备刷新OAuth
    → 读取GlobalConfig
    → if (now < bridgeOauthDeadExpiresAt)
      → 跳过刷新,避免重复尝试

这种"通过共享配置实现跨进程协调"的模式虽然简单,但在实践中非常有效——防止了多个Bridge进程同时反复尝试失败的OAuth刷新。


七、子进程孵化与NDJSON协议

7.1 子进程通信模型

在v1模式下,每个远程会话对应一个本地CLI子进程。sessionRunner.ts使用NDJSON协议通过stdio通信:

桥接主进程 —stdin→ 子进程
  update_environment_variables (Token刷新)
  claude --print --sdk-url <url>
         --session-id <id>
         --input-format stream-json
         --output-format stream-json

子进程 —stdout→ 桥接主进程
  assistant(工具调用/文本响应)
  user(用户消息replay)
  result(会话结束信号)
  control_request(权限请求)

子进程 —stderr→ 环形缓冲区(10行)

为什么选择NDJSON over stdio而不是其他IPC方式?

  1. 跨平台:stdio在所有操作系统上都可用,Unix socket在Windows上不原生支持
  2. 进程隔离:子进程崩溃不会拖主进程(共享内存有此风险)
  3. 可调试:NDJSON是文本协议,stderr输出可以直接阅读
  4. 自然的流控:stdio pipe有内核级别的缓冲区,天然提供背压控制

7.2 活动状态提取

Bridge主进程通过解析子进程stdout中的NDJSON消息提取活动信息:

子进程stdout → NDJSON解析器 → 消息分类
  assistant消息 → 提取工具调用状态
    tool_use block? → 更新"正在执行 <tool_name>"
    text block? → 更新"正在思考..."
  result消息 → 会话结束信号
    → 触发会话归档 + 工作项释放
  control_request消息 → 权限请求
    → 路由到远程端的权限审批UI

7.3 进程终止策略

优雅关机序列:
  ① SIGTERM → 子进程         ← 请求优雅退出
  ② 等待graceful窗口(30s)  ← 给子进程时间清理
  ③ SIGKILL → 顽固子进程     ← 强制终止
  ④ archiveSession()          ← 归档会话
  ⑤ removeAgentWorktree()     ← 清理git worktree(若有)
  ⑥ stopWork()                ← 释放工作项

30秒的优雅窗口是深思熟虑的选择:Claude Code的工具执行(如git commit、npm install)可能需要几秒钟完成,但不太可能超过30秒。Windows平台有特殊情况——child.kill()不支持信号名称参数,SIGTERM和SIGKILL的行为可能相同,Bridge通过"先试后杀"的双阶段策略来应对。


八、控制协议:云端与本地的精确交互

8.1 控制请求类型

方向 Subtype 语义 超时
Server→Client initialize 会话初始化,返回能力列表 ~10-14s
Server→Client set_model 切换当前模型 ~10-14s
Server→Client set_max_thinking_tokens 调整思考Token上限 ~10-14s
Server→Client set_permission_mode 切换权限模式 ~10-14s
Server→Client interrupt 中断当前turn ~10-14s
Client→Server can_use_tool 工具使用权限请求 用户操作

两个不对称性

  1. 方向不对称:大部分控制请求是服务端(远程)发给客户端(本地),只有can_use_tool是反方向的。这反映了Bridge的核心原则——远程端是"控制器",本地端是"执行器+审批者"。

  2. 超时不对称:系统控制请求有固定超时(10-14秒),而权限请求的超时取决于用户操作。系统控制是自动化的(应该很快完成),而权限审批需要等待人类决策。

8.2 超时防御

服务器发送control_request后,如果客户端未在超时窗口内响应,服务器将断开WebSocket连接。handleServerControlRequest对此有一个防御性设计:

function handleServerControlRequest(request) {
  switch (request.subtype) {
    case 'initialize': return handleInitialize(request)
    case 'set_model': return handleSetModel(request)
    // ... 其他已知类型
    default:
      // 未知类型也返回error,防止超时断连
      return { type: 'error', message: `Unknown subtype: ${request.subtype}` }
  }
}

未知请求类型在协议演进中是常见的(服务端升级后可能发送新类型的控制请求),静默忽略会导致超时断连,而返回error至少保持了连接的存活。

8.3 outboundOnly模式

outboundOnly = true时(CCR Mirror模式),除initialize外所有可变请求返回error。使用场景是"展示/演示"——你可能想让别人看到你的编码过程,但不希望他们能中断你的执行或切换模型。


九、横向对比:Bridge/Remote的架构评价

9.1 值得学习的四个模式

模式 解决的问题 核心思想
有界幂等性检查(BoundedUUIDSet) 有界去重,内存不无限增长 环形缓冲区+Set,O(capacity)内存,O(1)操作
冷热路径切换门控(FlushGate) 批量与增量同步的交错排序 先缓冲后排空,保证消息时序
合成消息统一入口 跨上下文复用交互流程 伪装外部事件为内部事件,共享UI和逻辑路径
双策略恢复 快速恢复+安全兜底 乐观尝试+悲观fallback,先快后稳

9.2 值得警惕的四个架构问题

① 巨型闭包风险
initBridgeCore函数体超过1,800行,内部闭包变量20+个。状态散布在transport、currentWorkId、lastTransportSequenceNum、flushGate等变量中,没有显式状态机。对比第3章的QueryEngine——通过AppStateStore将状态外部化,使状态可被观测和测试。Bridge缺少这种外部化,调试困难。

② 缺乏单元测试
bridgeMain.ts、replBridge.ts、remoteBridgeCore.ts、SessionsWebSocket.ts均无直接单元测试文件。对比第8章讨论的安全系统——每个Validator都有独立的可测试边界。Bridge的核心逻辑缺乏这种可测试性设计。

③ v1/v2分叉的债务积累
消息写入、去重、teardown逻辑在两个核心文件中各有一份实现,维护代价随时间线性增长。

④ 重连策略不一致
Bridge侧使用指数退避(2s→4s→60s),Remote侧使用固定延迟(2s×5次),缺乏统一的退避策略抽象。对比第12章讨论的API层统一withRetry引擎——Bridge/Remote系统缺少同等级别的退避策略复用。


十、实战启示:Agent远程化的五条建议

综合整个Bridge/Remote系统的架构分析,以下五条洞察对任何Agent远程化设计者都有参考价值:

洞察1:先问"状态归谁所有",再决定通信架构。
Bridge的所有设计决策都源于一个根本答案:状态属于本地。正是这个决定导出了"语义桥接而非终端转发"、“尽力同步而非可靠队列”、“断连不中断执行"等一系列推论。如果你的Agent选择"状态属于云端”,整个通信架构将完全不同。

洞察2:权限模型决定了远程化的天花板。
如果你的Agent需要用户逐个审批工具调用,那远程化方案必须保留"请求→审批→执行"的三步结构,需要双向控制协议。如果你的Agent是完全自主的(如CI中的自动修复),远程化可以简化为单向的状态通知。

洞察3:消息去重必须分层,因为重复来源不同。
一层去重无法覆盖所有来源。设计远程通信时,先枚举所有可能的重复来源(传输重放、Echo回环、批量/增量交错),再为每个来源设计对应的去重策略。

洞察4:消息协议应从第一天就考虑去重。
Bridge的三层去重部分是后加的补丁。如果协议从一开始就定义全局单调序列号和幂等性语义,可以用一层解决三层的问题。教训是:不要在应用层修补传输层的不可靠性,而是在协议层就定义清楚。

洞察5:远程端应该是"观众",而非"演员"。
将远程端设计为只读观察者+受限的控制通道,而非完全对等的执行端。这简化了一致性问题(只有一个写入者),降低了网络分区的影响(远程端断连不影响执行),也使权限模型更清晰(本地是唯一的信任锚点)。


十一大设计模式速查

模式 核心思想 本章出处
状态所有权决定通信架构 远程化方案的第一个架构决策 §13.1.3
有界幂等性检查(BoundedUUIDSet) 固定内存的O(1)去重 §13.3.3
冷热路径切换门控(FlushGate) 批量与增量同步的交错排序 §13.3.3
合成消息统一入口 跨上下文复用交互流程 §13.5.3
双策略恢复 快速恢复+安全兜底 §13.4.3
分层去重 多来源消息重复的分治防御 §13.3.3
渐进式架构演进的陷阱 版本并存的债务管理 §13.2.3
心跳模式保活 容量满载时的优雅降级 §13.4.2
防御性控制请求处理 未知subtype返回error而非静默 §13.8.2
跨进程退避 共享配置实现跨进程协调 §13.6.3
选择性不清理 持久模式的快速恢复设计 §13.4.4

下期预告

第19篇将进入附录A+C:22个架构模式速查 + System Prompt全链路拆解。

  • 附录A:22个架构模式按场景分类速查
    • 分布式任务类、状态管理类、安全防御类、扩展接入类、上下文管理类、容错弹性类
  • 附录C:System Prompt全链路
    • 三层装配流程(静态数据→动态注入→用户指令)
    • 优先级计算:buildEffectiveSystemPrompt
    • CLAUDE.md编写实战指南

从"远程突破"回到"模式总结"——13章的架构深度探索后,是时候退后一步,把散落各处的设计模式系统化地收拢了。


思考题:如果Claude Code选择"状态属于云端"(类似Codespaces模式),Bridge/Remote的架构会如何变化?哪些当前的设计决策(尽力同步、FlushGate、权限桥接)会被替换?会引入哪些新的挑战?

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐