Claude Code设计与实现-第12章 IDE Bridge 通信架构

《Claude Code 设计与实现》完整目录

第12章 IDE Bridge 通信架构

"Any sufficiently advanced CLI is indistinguishable from an IDE." -- 改自 Arthur C. Clarke

:::tip 本章要点

  1. 双模 Bridge 架构 -- Claude Code 通过两条路径实现 CLI 与 IDE 的桥接:独立进程 Bridge(bridgeMain.ts)用于 claude remote-control 命令行场景,REPL Bridge(replBridge.ts)用于 VS Code/JetBrains 等 IDE 内嵌场景
  2. 环境-会话两层模型 -- Environment 注册建立宿主机与服务器的信任通道,Session 在通道内承载具体的对话生命周期,两层解耦使一个 Bridge 可服务多个并发会话
  3. JWT 令牌生命周期管理 -- 从可信设备注册、OAuth 令牌交换到 session_ingress JWT 的主动续期,形成完整的认证链条,支持长达数小时的持续会话
  4. 指数退避与容量感知 -- 轮询循环区分连接错误和通用错误两条退避轨道,结合系统休眠检测和容量唤醒信号,在可靠性与服务器友好之间取得平衡
  5. 权限决策跨进程转发 -- 当子进程 CLI 需要工具执行权限时,control_request 经 Bridge 转发到 IDE/Web UI,决策结果以 control_response 回传,闭环完成跨进程权限对话 :::

引言:从命令行到编辑器的桥梁

Claude Code 诞生于终端,但它的战场不仅限于终端。当一位开发者在 VS Code 中选中一段代码并请求 Claude 帮忙重构时,当另一位开发者在 JetBrains IDE 中让 Claude 分析整个项目结构时,他们所依赖的,是一套在 CLI 进程与 IDE 扩展之间搭建的精密通信管道 -- Bridge。

Bridge 的设计面临一个本质性矛盾:Claude Code 的核心能力(模型调用、工具执行、沙箱隔离、权限管理)全部实现在一个 Node.js/Bun 进程中,而 IDE 扩展运行在另一个完全不同的进程空间。如何让两端高效、安全、可靠地协作,是本章的核心命题。

更具挑战性的是,Bridge 不是简单的请求-响应管道。它需要处理长时间运行的会话(一个编码任务可能持续数小时)、支持多窗口并发(开发者可能在多个 IDE 标签页中同时使用 Claude)、在网络断开时优雅恢复、在 IDE 重启时无缝重连。这些需求交织在一起,催生了 Claude Code 中最复杂的子系统之一。

本章将从底层通信协议到上层会话管理,从认证安全到容错恢复,全面剖析 Bridge 的设计与实现。

12.1 为什么需要 Bridge

12.1.1 CLI 与 IDE 的鸿沟

Claude Code 作为一个 CLI 工具,天然运行在终端进程中。它拥有完整的工具系统、权限模型、流式对话引擎。但 IDE 扩展运行在另一个进程 -- VS Code 扩展运行在 Electron 的扩展宿主进程中,JetBrains 插件运行在 JVM 进程中。两者之间没有共享内存,不能直接调用函数。

最朴素的集成方式是让 IDE 扩展直接嵌入一个 Claude Code 运行时,但这不现实。Claude Code 的依赖链庞大(Bun 运行时、数百个 npm 模块、系统级沙箱组件),硬塞进 IDE 扩展会严重膨胀包体积,还会引发版本同步噩梦 -- 每次 Claude Code 更新,所有 IDE 扩展都要同步发布新版。

Bridge 模式的核心洞见是:让 Claude Code 继续运行在独立进程中,IDE 扩展只需要一个轻量的通信客户端。这样,Claude Code 的全部能力(包括沙箱、权限、MCP 协议等)对 IDE 透明可用,版本升级只需更新 CLI 本身。IDE 扩展的职责被简化为"消息的搬运工" -- 从用户界面收集输入,转交给 Bridge,然后把 Bridge 返回的结果渲染到编辑器中。

这种架构还带来了一个额外的好处:可测试性。Bridge 的所有行为都可以通过模拟的 API 客户端和 session spawner 进行单元测试,不依赖真实的 IDE 进程。类型定义中 BridgeApiClientSessionSpawnerBridgeLogger 等接口的设计,正是为了这种可注入的测试模式。

12.1.2 两种集成场景

Claude Code 的 Bridge 架构服务于两种截然不同的场景:

独立进程 Bridge(Standalone Bridge) :开发者在终端运行 claude remote-control,启动一个持久运行的 Bridge 进程。这个进程向服务器注册为一个"环境"(Environment),然后持续轮询等待来自 claude.ai Web 界面或 IDE 扩展的任务。每个任务到达时,Bridge 会 spawn 一个子 Claude Code 进程来执行。这是 bridgeMain.ts 的职责。

REPL 内嵌 Bridge(REPL Bridge) :当开发者在 IDE 中使用 Claude Code 扩展时,扩展在后台启动一个 Claude Code REPL 进程。这个 REPL 进程内部初始化一个 Bridge 连接,将对话状态同步到 claude.ai Web 界面,同时接收来自 Web 端的输入和控制指令。这是 replBridge.tsinitReplBridge.ts 的职责。

两种模式共享大量基础设施 -- API 客户端、JWT 管理、消息协议、重试逻辑 -- 但在会话生命周期管理上有本质区别。独立 Bridge 是"一对多"的(一个 Bridge 进程服务多个会话),REPL Bridge 是"一对一"的(一个 REPL 进程对应一个 Bridge 会话)。

除了这两种模式之外,源码中还存在第三种变体:remoteBridgeCore.ts 实现的"无环境层 Bridge"。这是一种更新的 v2 直连模式,它完全绕过 Environments API 的工作派发层,直接通过 POST /v1/code/sessions/{id}/bridge 端点获取 worker JWT 和 epoch,建立 SSE+CCRClient 传输。这种模式消除了注册/轮询/确认/停止/心跳/注销的环境生命周期管理开销,使连接建立更加轻量。三种模式的存在反映了 Bridge 子系统正处于从轮询式架构向直连式架构的渐进迁移过程中。

12.1.3 VS Code 与 JetBrains 的统一抽象

无论是 VS Code 还是 JetBrains IDE,从 Bridge 的视角看都是"远端客户端"。Bridge 不直接与任何 IDE 通信 -- 它通过 claude.ai 的服务器作为中介:

rust 复制代码
IDE 扩展 <---> claude.ai 服务器 <---> Bridge 进程 <---> Claude Code CLI

这种间接通信的设计意味着 Bridge 不需要为每种 IDE 编写适配层。IDE 扩展只需实现 claude.ai 的 Web API 客户端,就能与 Bridge 交互。新增对 Neovim 或 Emacs 的支持,不需要修改 Bridge 的任何代码。

从特性门控的角度来看,Bridge 功能需要满足多项前置条件才能激活。bridgeEnabled.ts 中的 isBridgeEnabledBlocking 函数展示了这些条件的层叠结构:首先检查编译期特性标志(feature('BRIDGE_MODE'),通过 Bun bundler 的死代码消除确保外部构建不包含 Bridge 字符串字面量);然后检查用户是否为 claude.ai 订阅者(排除 Bedrock/Vertex/API key 用户);最后检查 GrowthBook 开关(tengu_ccr_bridge)是否对当前组织启用。任何一项不满足,Bridge 功能对用户完全不可见。这种多层门控保证了功能的灰度发布可以按组织维度精确控制。

12.2 Bridge 架构总览

下图展示了 Bridge 的两种运行模式及其与 IDE 和 Claude Code CLI 之间的通信路径:

flowchart TB subgraph IDE["IDE 端"] VSCode["VS Code 扩展"] JetBrains["JetBrains 插件"] WebUI["Web UI"] end subgraph Server["claude.ai 服务器"] EnvAPI["Environments API\n环境注册"] SessionAPI["Session-Ingress\nWS / SSE"] end subgraph BridgeMode1["独立 Bridge 模式\n(bridgeMain.ts)"] Poll["轮询工作队列"] Spawn["spawn 子进程"] CLI1["Claude Code CLI\n子进程"] Poll -->|"获取任务"| Spawn Spawn -->|"stdin/stdout\nNDJSON"| CLI1 end subgraph BridgeMode2["REPL Bridge 模式\n(replBridge.ts)"] Transport["传输层"] REPL["REPL 执行引擎\n进程内"] Transport <-->|"消息转发"| REPL end IDE <-->|"用户交互"| Server Server <-->|"HTTP 长轮询"| BridgeMode1 Server <-->|"WS / SSE"| BridgeMode2 subgraph Auth["认证基础设施"] JWT["令牌刷新调度"] Trusted["设备认证"] end BridgeMode1 & BridgeMode2 --> Auth

12.2.1 目录结构与职责划分

src/bridge/ 目录包含 30 多个 TypeScript 文件,构成了 Bridge 子系统的完整实现。按职责可划分为以下几个层次:

lua 复制代码
src/bridge/
  |-- 核心流程
  |     |-- bridgeMain.ts        # 独立Bridge的主循环:注册→轮询→分发→清理
  |     |-- replBridge.ts        # REPL Bridge核心:注册→连接→消息转发→重连
  |     |-- initReplBridge.ts    # REPL Bridge初始化:鉴权门控、参数准备、动态导入
  |     |-- remoteBridgeCore.ts  # 无环境层Bridge核心(v2直连模式)
  |
  |-- 会话管理
  |     |-- sessionRunner.ts     # 子进程生命周期:spawn→监控→清理
  |     |-- createSession.ts     # 会话创建:POST /v1/sessions API
  |     |-- sessionIdCompat.ts   # 会话ID标签转换(cse_* <-> session_*)
  |
  |-- 通信传输
  |     |-- replBridgeTransport.ts  # 传输层抽象:v1(WebSocket) / v2(SSE+CCR)
  |     |-- bridgeMessaging.ts      # 消息路由:入站解析、类型守卫、去重
  |     |-- inboundMessages.ts      # 入站消息处理:附件解析
  |     |-- inboundAttachments.ts   # 附件下载与转换
  |     |-- flushGate.ts            # 初始消息刷新状态机
  |
  |-- API 客户端
  |     |-- bridgeApi.ts         # 环境API封装:注册/轮询/确认/停止/心跳
  |     |-- codeSessionApi.ts    # 会话API封装(v2直连模式)
  |     |-- workSecret.ts        # 工作密钥解码、SDK URL构建、Worker注册
  |
  |-- 认证安全
  |     |-- jwtUtils.ts          # JWT解码与令牌刷新调度器
  |     |-- trustedDevice.ts     # 可信设备注册与令牌管理
  |     |-- bridgeConfig.ts      # Bridge配置读取(OAuth令牌、API地址)
  |
  |-- 容错与配置
  |     |-- pollConfig.ts        # 轮询间隔配置(GrowthBook动态调参)
  |     |-- pollConfigDefaults.ts # 轮询间隔默认值
  |     |-- capacityWake.ts      # 容量唤醒信号(会话结束→立即轮询)
  |     |-- bridgeEnabled.ts     # 特性门控(订阅检查、GrowthBook开关)
  |
  |-- 类型定义
  |     |-- types.ts             # 核心类型:BridgeConfig, SessionHandle, WorkResponse...
  |     |-- bridgePermissionCallbacks.ts  # 权限回调类型定义
  |
  |-- 辅助工具
        |-- bridgeUI.ts          # 状态显示:Banner、会话进度、QR码
        |-- bridgeStatusUtil.ts  # 状态格式化工具
        |-- bridgePointer.ts     # 崩溃恢复指针(持久化环境/会话ID)
        |-- bridgeDebug.ts       # 调试故障注入(ant用户专用)
        |-- debugUtils.ts        # 调试日志工具
        |-- envLessBridgeConfig.ts  # 无环境层配置(v2模式)

12.2.2 bridgeMain.ts:独立 Bridge 的主循环

bridgeMain.ts 是独立 Bridge 模式的核心,其主函数 runBridgeLoop 实现了一个完整的服务器长轮询循环。这个文件超过 1600 行,是整个 Bridge 子系统中最大的单一文件。它的宏观结构如下:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

export async function runBridgeLoop(
  config: BridgeConfig,
  environmentId: string,
  environmentSecret: string,
  api: BridgeApiClient,
  spawner: SessionSpawner,
  logger: BridgeLogger,
  signal: AbortSignal,
  backoffConfig: BackoffConfig = DEFAULT_BACKOFF,
  initialSessionId?: string,
  getAccessToken?: () => string | undefined | Promise<string | undefined>,
): Promise<void> {
  // 1. 初始化活跃会话跟踪表
  const activeSessions = new Map<string, SessionHandle>()
  const sessionWorkIds = new Map<string, string>()
  const sessionIngressTokens = new Map<string, string>()
  // ...

  // 2. 创建令牌刷新调度器
  const tokenRefresh = getAccessToken
    ? createTokenRefreshScheduler({ getAccessToken, onRefresh, label: 'bridge' })
    : null

  // 3. 进入主轮询循环
  while (!loopSignal.aborted) {
    const pollConfig = getPollIntervalConfig()
    try {
      const work = await api.pollForWork(environmentId, environmentSecret, ...)
      // 4. 处理工作项(session / healthcheck)
      // 5. 管理容量和休眠策略
    } catch (err) {
      // 6. 错误恢复(指数退避)
    }
  }

  // 7. 优雅关闭:SIGTERM→等待→SIGKILL→archive→deregister
}

这个循环的关键设计决策在于**先确认后生产(ack-after-commit)**模式。当 Bridge 从服务器拉取到一个工作项时,它不会立即确认(ack),而是先解码工作密钥、验证会话 ID、检查容量,确认能够处理后才调用 acknowledgeWork。如果确认后子进程 spawn 失败,Bridge 会调用 stopWork 通知服务器,防止工作项永久丢失。

12.2.3 replBridge.ts:REPL 会话桥接

与独立 Bridge 不同,REPL Bridge 运行在 Claude Code 的交互式 REPL 进程内部。它不需要 spawn 子进程 -- 因为 REPL 本身就是执行引擎。REPL Bridge 的核心函数 initBridgeCore 负责:

  1. 向服务器注册一个 Bridge 环境
  2. 创建一个会话(或复用崩溃恢复指针中的已有会话)
  3. 建立 WebSocket/SSE 传输连接
  4. 将 REPL 的对话消息实时同步到服务器
  5. 接收来自 Web 端的用户输入和控制指令

initBridgeCore 返回一个 ReplBridgeHandle,REPL 的其他模块通过这个句柄与 Bridge 交互:

typescript 复制代码
// 文件: src/bridge/replBridge.ts

export type ReplBridgeHandle = {
  bridgeSessionId: string
  environmentId: string
  sessionIngressUrl: string
  writeMessages(messages: Message[]): void        // 同步对话消息到服务器
  writeSdkMessages(messages: SDKMessage[]): void   // 直接写SDK消息
  sendControlRequest(request: SDKControlRequest): void   // 发送控制请求
  sendControlResponse(response: SDKControlResponse): void // 发送控制响应
  sendControlCancelRequest(requestId: string): void       // 取消权限请求
  sendResult(): void                               // 通知会话结束
  teardown(): Promise<void>                        // 拆除Bridge连接
}

12.2.4 initReplBridge.ts:REPL Bridge 的门控与初始化

REPL Bridge 的初始化不是直接调用 initBridgeCore,而是通过 initReplBridge.ts 中间层完成。这个中间层承担了所有与 REPL 状态相关的工作,使 initBridgeCore 保持"无引导状态依赖"(bootstrap-free),便于非 REPL 场景(如 Agent SDK daemon)复用。

initReplBridge 的门控链展示了 Bridge 启用的多层条件:

typescript 复制代码
// 文件: src/bridge/initReplBridge.ts

export async function initReplBridge(
  options?: InitBridgeOptions,
): Promise<ReplBridgeHandle | null> {
  // 1. 运行时特性门控(GrowthBook 开关)
  if (!(await isBridgeEnabledBlocking())) {
    return null
  }

  // 2. OAuth 认证检查(必须登录 claude.ai)
  if (!getBridgeAccessToken()) {
    onStateChange?.('failed', '/login')
    return null
  }

  // 3. 策略合规检查(组织策略是否允许 Remote Control)
  // 4. 最低版本检查(防止过旧客户端连接)
  // 5. 收集 Git 上下文(仓库 URL、分支名)
  // 6. 委托给 initBridgeCore 执行实际注册和连接
}

之所以拆分为两个文件,原因在源码注释中有明确说明:sessionStorage 的导入会传递性地拉入 src/commands.ts(整个斜杠命令注册表和 React 组件树,约 1300 个模块)。将 initBridgeCore 放在不触碰 sessionStorage 的文件中,让 Agent SDK daemon 可以导入核心逻辑而不膨胀构建产物。

12.2.5 sessionRunner.ts:子进程生命周期管理

在独立 Bridge 模式下,每个从 claude.ai 派发的任务都会被 spawn 为一个独立的 Claude Code 子进程。sessionRunner.tscreateSessionSpawner 工厂函数负责创建这些子进程并管理其生命周期。

子进程 spawn 时携带的关键参数揭示了 Bridge 的通信机制:

typescript 复制代码
// 文件: src/bridge/sessionRunner.ts

const args = [
  ...deps.scriptArgs,
  '--print',                         // 非交互模式
  '--sdk-url', opts.sdkUrl,          // WebSocket/HTTP 接入点
  '--session-id', opts.sessionId,    // 会话标识
  '--input-format', 'stream-json',   // NDJSON 输入
  '--output-format', 'stream-json',  // NDJSON 输出
  '--replay-user-messages',          // 回放历史用户消息
]

const env: NodeJS.ProcessEnv = {
  ...deps.env,
  CLAUDE_CODE_OAUTH_TOKEN: undefined,           // 剥离Bridge的OAuth令牌
  CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',       // 标识运行环境
  CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken, // 会话专用JWT
}

Bridge 与子进程之间通过三个标准 IO 管道通信:

  • stdin:Bridge 向子进程写入控制消息(如令牌刷新、环境变量更新)
  • stdout:子进程输出 NDJSON 格式的事件流(助手回复、工具调用、权限请求)
  • stderr:子进程的错误输出,Bridge 维护一个环形缓冲区(最近 10 行)用于故障诊断

SessionHandle 是 Bridge 对子进程的控制句柄,它暴露了精确的生命周期操作:

typescript 复制代码
// 文件: src/bridge/types.ts

export type SessionHandle = {
  sessionId: string
  done: Promise<SessionDoneStatus>        // 进程退出的 Promise
  kill(): void                            // 发送 SIGTERM
  forceKill(): void                       // 发送 SIGKILL
  activities: SessionActivity[]           // 活动环形缓冲区(最近10条)
  currentActivity: SessionActivity | null // 最新活动
  accessToken: string                     // 会话令牌
  lastStderr: string[]                    // stderr 环形缓冲区
  writeStdin(data: string): void          // 写入 stdin
  updateAccessToken(token: string): void  // 刷新令牌(通过stdin下发)
}

令牌更新的实现尤其巧妙 -- Bridge 不需要重启子进程来刷新认证:

typescript 复制代码
// 文件: src/bridge/sessionRunner.ts

updateAccessToken(token: string): void {
  handle.accessToken = token
  handle.writeStdin(
    jsonStringify({
      type: 'update_environment_variables',
      variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
    }) + '\n',
  )
}

子进程的 StructuredIO 模块会处理 update_environment_variables 消息,直接更新 process.env,使后续的 API 调用自动使用新令牌。整个过程对子进程的应用层完全透明。

12.3 通信协议

12.3.1 双版本传输协议

Bridge 的传输层经历了从 v1 到 v2 的演进,两套协议并行存在并由服务器端特性开关控制:

v1 协议(HybridTransport):基于 WebSocket 的混合传输。读取方向使用 WebSocket 接收服务器推送的事件,写入方向使用 HTTP POST 发送消息。这种"读 WS + 写 POST"的混合设计是因为 WebSocket 的写入在高并发下不够可靠(消息可能乱序或丢失),而 HTTP POST 天然保证请求级别的原子性。

v2 协议(SSE + CCRClient) :读取方向使用 Server-Sent Events(SSE)流,写入方向通过 CCRClient 调用 CCR v2 的 /worker/* REST 端点。v2 的核心优势在于跳过了 Session-Ingress 层的 WebSocket 代理,直接与 CCR 通信,减少了一层延迟和故障点。

replBridgeTransport.ts 将两种协议统一到同一个接口背后:

typescript 复制代码
// 文件: src/bridge/replBridgeTransport.ts

export type ReplBridgeTransport = {
  write(message: StdoutMessage): Promise<void>
  writeBatch(messages: StdoutMessage[]): Promise<void>
  close(): void
  isConnectedStatus(): boolean
  getStateLabel(): string
  setOnData(callback: (data: string) => void): void
  setOnClose(callback: (closeCode?: number) => void): void
  setOnConnect(callback: () => void): void
  connect(): void
  getLastSequenceNum(): number          // SSE序列号高水位
  readonly droppedBatchCount: number    // 静默丢弃的批次数
  reportState(state: SessionState): void    // 报告工作状态(v2专用)
  reportMetadata(metadata: Record<string, unknown>): void
  reportDelivery(eventId: string, status: 'processing' | 'processed'): void
  flush(): Promise<void>                // 清空写入队列
}

v1 适配器几乎是直接代理 HybridTransport 的方法,而 v2 适配器则编排了 SSE 读取和 CCRClient 写入两个独立组件。关键差异在于认证方式:v1 使用 OAuth 令牌(通过 Authorization: Bearer 头),v2 使用 session-ingress JWT(JWT 中包含 session_id 声明,CCR 端点验证此声明)。

12.3.2 消息格式与类型守卫

Bridge 在传输通道上使用 NDJSON(Newline-Delimited JSON)格式。每一行是一个独立的 JSON 对象,通过 type 字段进行消息类型区分。bridgeMessaging.ts 提供了完整的消息类型守卫和路由逻辑:

typescript 复制代码
// 文件: src/bridge/bridgeMessaging.ts

export function handleIngressMessage(
  data: string,
  recentPostedUUIDs: BoundedUUIDSet,    // 最近发出的消息UUID(去重回声)
  recentInboundUUIDs: BoundedUUIDSet,   // 最近接收的消息UUID(去重重传)
  onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined,
  onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined,
  onControlRequest?: ((request: SDKControlRequest) => void) | undefined,
): void {
  const parsed = normalizeControlMessageKeys(jsonParse(data))

  // 1. 控制响应(权限决策结果)
  if (isSDKControlResponse(parsed)) {
    onPermissionResponse?.(parsed)
    return
  }

  // 2. 控制请求(服务器发起的初始化、模型设置等)
  if (isSDKControlRequest(parsed)) {
    onControlRequest?.(parsed)
    return
  }

  // 3. SDK消息(用户输入、助手回复等)
  if (!isSDKMessage(parsed)) return

  // 4. 回声过滤:忽略自己刚发出的消息
  const uuid = 'uuid' in parsed ? parsed.uuid : undefined
  if (uuid && recentPostedUUIDs.has(uuid)) return

  // 5. 重传过滤:忽略已处理过的入站消息
  if (uuid && recentInboundUUIDs.has(uuid)) return

  // 6. 只处理用户消息(type === 'user')
  if (parsed.type === 'user') {
    if (uuid) recentInboundUUIDs.add(uuid)
    void onInboundMessage?.(parsed)
  }
}

消息去重使用了两个独立的 BoundedUUIDSet:一个跟踪"我发出的"(用于过滤服务器回声),一个跟踪"我收到的"(用于过滤服务器重传)。这种双向去重在传输层交换(Transport swap)场景中至关重要 -- 当 WebSocket 断开重连时,服务器可能回放部分历史消息,如果不去重会导致重复处理。

12.3.3 工作密钥与 SDK URL 构建

独立 Bridge 通过轮询获取的工作项(WorkResponse)携带一个 Base64url 编码的密钥(secret),其中包含会话连接所需的全部凭据:

typescript 复制代码
// 文件: src/bridge/types.ts

export type WorkSecret = {
  version: number                       // 协议版本(当前为1)
  session_ingress_token: string         // 会话JWT令牌
  api_base_url: string                  // API基础URL
  sources: Array<{                      // 代码来源(Git仓库等)
    type: string
    git_info?: { type: string; repo: string; ref?: string; token?: string }
  }>
  auth: Array<{ type: string; token: string }>
  claude_code_args?: Record<string, string> | null
  mcp_config?: unknown | null
  environment_variables?: Record<string, string> | null
  use_code_sessions?: boolean           // v2标志:使用CCR v2直连
}

根据 use_code_sessions 标志,Bridge 构建不同的 SDK URL:

typescript 复制代码
// 文件: src/bridge/workSecret.ts

// v1: WebSocket URL(Session-Ingress)
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
  const isLocalhost = apiBaseUrl.includes('localhost')
  const protocol = isLocalhost ? 'ws' : 'wss'
  const version = isLocalhost ? 'v2' : 'v1'
  const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
  return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
}

// v2: HTTP URL(CCR v2 直连)
export function buildCCRv2SdkUrl(apiBaseUrl: string, sessionId: string): string {
  const base = apiBaseUrl.replace(/\/+$/, '')
  return `${base}/v1/code/sessions/${sessionId}`
}

v1 的 URL 路径区分了 localhost 和生产环境:本地开发直接访问 session-ingress 的 /v2/ 路径,而生产环境通过 Envoy 代理的 /v1/ 路径(Envoy 内部重写为 /v2/)。这个细节揭示了基础设施层面的路由架构。

v2 模式在使用之前还需要执行 Worker 注册流程。workSecret.ts 中的 registerWorker 函数向 {sessionUrl}/worker/register 端点发送 POST 请求,获取 worker_epoch(一个单调递增的纪元数),子进程在后续的每次心跳和状态上报请求中都必须携带此 epoch。如果 epoch 不匹配(说明有更新的 worker 接管了会话),请求会被拒绝。这是一种类似于乐观并发控制的机制,确保同一时刻只有一个 worker 在服务一个会话。

12.3.4 FlushGate:初始消息刷新的状态机

当 Bridge 连接建立时,需要将 REPL 的历史对话消息批量发送到服务器(初始刷新)。在这个过程中,新产生的消息不能直接发送 -- 否则会导致历史消息和新消息在服务器端交错,破坏对话顺序。FlushGate 是解决这个问题的状态机:

typescript 复制代码
// 文件: src/bridge/flushGate.ts

export class FlushGate<T> {
  private _active = false
  private _pending: T[] = []

  start(): void { this._active = true }        // 刷新开始,进入排队模式
  end(): T[] {                                  // 刷新结束,返回排队项
    this._active = false
    return this._pending.splice(0)
  }
  enqueue(...items: T[]): boolean {             // 尝试排队(active=true则排队,false则拒绝)
    if (!this._active) return false
    this._pending.push(...items)
    return true
  }
  deactivate(): void { this._active = false }  // 传输层替换时,保留排队项
  drop(): number {                              // 永久关闭时,丢弃排队项
    this._active = false
    const count = this._pending.length
    this._pending.length = 0
    return count
  }
}

deactivate()drop() 的区分是精心设计的:当传输层被替换(如 WebSocket 断开后重建)时,排队中的消息不应被丢弃,因为新传输层连接后会执行新一轮刷新,届时排队项会被合并发送。只有在 Bridge 永久关闭时,才调用 drop() 释放内存。

12.4 JWT 认证

Bridge 的认证体系从设备注册到会话令牌形成了完整的信任链条。下图展示了各层令牌之间的派生关系和刷新机制:

sequenceDiagram participant Device as 可信设备 participant Bridge as Bridge 进程 participant Auth as OAuth 服务 participant Server as claude.ai Note over Device,Server: 阶段一: 设备注册 (首次) Device->>Auth: 设备注册请求 Auth-->>Device: device_token (长期) Note over Device,Server: 阶段二: OAuth 令牌交换 Bridge->>Auth: device_token + code_challenge Auth-->>Bridge: access_token + refresh_token Note over Device,Server: 阶段三: 环境注册 Bridge->>Server: POST /environments\n(access_token) Server-->>Bridge: environment_id Note over Device,Server: 阶段四: 会话 JWT 获取 Bridge->>Server: POST /sessions\n(access_token) Server-->>Bridge: session_ingress JWT Note over Device,Server: 阶段五: 主动续期 loop JWT 过期前 Bridge->>Bridge: jwtUtils.ts 调度器\n检测 exp 时间 Bridge->>Server: 刷新 JWT Server-->>Bridge: 新 session_ingress JWT end

12.4.1 认证链条的全景

Bridge 的认证涉及多层令牌的协作,形成一条完整的信任链:

lua 复制代码
用户登录 claude.ai
    |
    v
OAuth Access Token(长期,~4小时有效)
    |-- 用于 Bridge 环境注册
    |-- 用于会话创建
    |-- 用于权限响应转发
    |
    v
Session Ingress JWT(短期,~5-6小时有效)
    |-- 由服务器在工作派发时签发
    |-- 包含 session_id 声明
    |-- 用于子进程的 API 调用
    |-- 用于 WebSocket/SSE 连接认证
    |
    v
Trusted Device Token(持久,90天滚动过期)
    |-- 存储在系统密钥链中
    |-- 作为 X-Trusted-Device-Token 头发送
    |-- 服务器端安全层级提升的依据

12.4.2 jwtUtils.ts:令牌刷新调度器

长时间运行的 Bridge 会话(可能持续数小时甚至一整天)不能依赖一次性签发的 JWT。jwtUtils.ts 实现了一个主动令牌刷新调度器,在 JWT 过期前提前续期:

typescript 复制代码
// 文件: src/bridge/jwtUtils.ts

/** 刷新缓冲区:在过期前5分钟请求新令牌 */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000

/** 当新令牌的过期时间未知时的备用刷新间隔 */
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30分钟

/** 连续失败超过此次数后放弃刷新 */
const MAX_REFRESH_FAILURES = 3

export function createTokenRefreshScheduler({
  getAccessToken,
  onRefresh,
  label,
  refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
}: {
  getAccessToken: () => string | undefined | Promise<string | undefined>
  onRefresh: (sessionId: string, oauthToken: string) => void
  label: string
  refreshBufferMs?: number
}): {
  schedule: (sessionId: string, token: string) => void
  scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
  cancel: (sessionId: string) => void
  cancelAll: () => void
}

调度器的核心设计有几个值得注意的要点:

代际计数器(Generation Counter) :每个 session 维护一个递增的代际号。当 schedule()cancel() 被调用时,代际号递增。正在执行的异步 doRefresh() 在完成后会检查代际号是否匹配 -- 如果不匹配,说明此次刷新已被更新的调度取代,应该静默退出。这避免了竞态条件下的重复刷新和孤立定时器。

typescript 复制代码
// 文件: src/bridge/jwtUtils.ts

async function doRefresh(sessionId: string, gen: number): Promise<void> {
  let oauthToken = await getAccessToken()

  // 如果在 await 期间会话被取消或重新调度,代际号已变 -- 静默退出
  if (generations.get(sessionId) !== gen) {
    return
  }

  if (!oauthToken) {
    // 重试逻辑:最多 MAX_REFRESH_FAILURES 次
    const failures = (failureCounts.get(sessionId) ?? 0) + 1
    failureCounts.set(sessionId, failures)
    if (failures < MAX_REFRESH_FAILURES) {
      const retryTimer = setTimeout(doRefresh, REFRESH_RETRY_DELAY_MS, sessionId, gen)
      timers.set(sessionId, retryTimer)
    }
    return
  }

  failureCounts.delete(sessionId) // 成功获取令牌,重置失败计数
  onRefresh(sessionId, oauthToken)

  // 安排后续刷新,保证长时间运行的会话持续认证
  const timer = setTimeout(doRefresh, FALLBACK_REFRESH_INTERVAL_MS, sessionId, gen)
  timers.set(sessionId, timer)
}

v1/v2 的刷新策略差异 :在 bridgeMain.ts 中,令牌刷新的行为因协议版本而异:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

const tokenRefresh = getAccessToken
  ? createTokenRefreshScheduler({
      getAccessToken,
      onRefresh: (sessionId, oauthToken) => {
        const handle = activeSessions.get(sessionId)
        if (!handle) return

        if (v2Sessions.has(sessionId)) {
          // v2: 不能直接下发OAuth令牌(CCR端点验证JWT的session_id声明)
          // 触发服务器重新派发,获取新JWT
          void api.reconnectSession(environmentId, sessionId)
        } else {
          // v1: 直接将OAuth令牌下发给子进程
          handle.updateAccessToken(oauthToken)
        }
      },
      label: 'bridge',
    })
  : null

v2 协议下,子进程使用的 JWT 包含 session_id 声明,CCR 端点会验证这一声明。OAuth 令牌没有此声明,因此不能直接传递给子进程。Bridge 的解法是调用 reconnectSession 让服务器重新派发工作项 -- 新的工作项携带全新的 JWT,Bridge 的轮询循环(existingHandle 路径)会将其送达运行中的子进程。

12.4.3 可信设备认证

Bridge 会话在服务器端具有 ELEVATED 安全等级 -- 它能执行文件操作、运行 shell 命令,权限远高于普通 Web 聊天。为此,服务器引入了可信设备(Trusted Device)认证层。

trustedDevice.ts 管理可信设备令牌的完整生命周期:

typescript 复制代码
// 文件: src/bridge/trustedDevice.ts

export async function enrollTrustedDevice(): Promise<void> {
  // 1. 检查 GrowthBook 开关
  if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) return

  // 2. 获取当前 OAuth 令牌
  const accessToken = getClaudeAIOAuthTokens()?.accessToken
  if (!accessToken) return

  // 3. 调用注册端点
  const response = await axios.post(
    `${baseUrl}/api/auth/trusted_devices`,
    { display_name: `Claude Code on ${hostname()} · ${process.platform}` },
    { headers: { Authorization: `Bearer ${accessToken}` } },
  )

  // 4. 持久化到系统密钥链
  const token = response.data?.device_token
  storageData.trustedDeviceToken = token
  secureStorage.update(storageData)
}

注册时机有严格约束:服务器端限制 account_session.created_at < 10分钟,意味着设备注册必须在用户 /login 后立即完成。延迟注册(如在 Bridge 首次使用时懒注册)会因会话过期而被拒绝。

令牌读取使用了 memoize 缓存,因为底层的 macOS security 命令行工具调用耗时约 40ms,而 bridgeApi.ts 的每次 HTTP 请求都需要读取此令牌。缓存在注册新令牌和用户注销时清除。

12.5 会话管理

12.5.1 环境注册与会话创建的分层设计

Bridge 的会话管理遵循"环境(Environment)在外、会话(Session)在内"的两层模型。环境注册通过 bridgeApi.ts 中的 registerBridgeEnvironment 方法完成,它向 /v1/environments/bridge 端点 POST 一组元数据,包括机器名、工作目录、Git 分支、最大并发会话数和 worker 类型等。这些元数据使 claude.ai 的 Web UI 能够向用户展示有意义的信息,例如"你的 MacBook 上的 claude-code 仓库 main 分支,当前 2/4 个会话槽位空闲"。

环境注册还支持幂等重连:如果 Bridge 配置中携带 reuseEnvironmentId(来自上一次会话的崩溃恢复指针),服务器会尝试重新绑定到已有的环境实例,而非创建新的。这避免了每次 Bridge 重启都在 Web UI 中出现一个新条目的问题。当然,如果旧环境已过期(超过 4 小时 TTL),服务器会返回一个新的 environment_id,Bridge 需要处理这种"请求复用但得到新建"的情况。

会话创建则由 createSession.tscreateBridgeSession 函数负责,它构建包含 Git 来源信息、模型选择和初始事件的请求体,通过 POST /v1/sessions 创建会话。会话创建是独立于环境注册的操作 -- 一个环境可以在其生命周期内创建多个会话。

css 复制代码
                     claude.ai 服务器
                           |
                   环境(Environment)
                  /        |        \
           Session A   Session B   Session C
              |            |           |
         子进程 A      子进程 B     子进程 C

环境 代表一个 Bridge 进程实例与服务器之间的连接。注册环境时,Bridge 提交机器名、工作目录、Git 分支、最大并发会话数等元数据。服务器返回 environment_idenvironment_secret,后续所有 API 调用以此为凭据。

会话 是环境内的具体工作单元。每个来自 claude.ai 的对话请求会创建一个会话,分配一个 session_id。独立 Bridge 为每个会话 spawn 一个子进程,REPL Bridge 则在当前进程内处理。

这种两层分离使得:

  • 一个 Bridge 可以服务多个并发会话(多窗口场景)
  • 环境元数据(机器名、目录)只需注册一次
  • 会话结束不影响环境 -- Bridge 继续等待新任务
  • 环境注销时自动清理所有关联会话

12.5.2 会话 ID 的双标签体系

CCR v2 compat 层引入了一个有趣的 ID 兼容性问题。同一个会话,在不同层面有不同的标签前缀:

  • 基础设施层 使用 cse_ 前缀(Code Session Entity),如 cse_abc123
  • 兼容 API 层 使用 session_ 前缀,如 session_abc123

两者的底层 UUID 完全相同,只是"外衣"不同。sessionIdCompat.ts 提供了双向转换:

typescript 复制代码
// 文件: src/bridge/sessionIdCompat.ts

// cse_abc123 -> session_abc123(用于 /v1/sessions/ 端点)
export function toCompatSessionId(id: string): string {
  if (!id.startsWith('cse_')) return id
  if (_isCseShimEnabled && !_isCseShimEnabled()) return id
  return 'session_' + id.slice('cse_'.length)
}

// session_abc123 -> cse_abc123(用于 /v1/environments/ 端点)
export function toInfraSessionId(id: string): string {
  if (!id.startsWith('session_')) return id
  return 'cse_' + id.slice('session_'.length)
}

workSecret.ts 中的 sameSessionId 函数则提供了无视前缀的比较:

typescript 复制代码
// 文件: src/bridge/workSecret.ts

export function sameSessionId(a: string, b: string): boolean {
  if (a === b) return true
  const aBody = a.slice(a.lastIndexOf('_') + 1)
  const bBody = b.slice(b.lastIndexOf('_') + 1)
  return aBody.length >= 4 && aBody === bBody
}

这种设计避免了 Bridge 代码在各处判断 "到底用哪个前缀" 的混乱。所有面向用户的 API(如归档会话、更新标题)使用 toCompatSessionId,所有面向基础设施的 API(如重连会话)使用 toInfraSessionId

12.5.3 多会话并发

独立 Bridge 支持三种 spawn 模式,由 SpawnMode 类型定义:

typescript 复制代码
// 文件: src/bridge/types.ts

export type SpawnMode = 'single-session' | 'worktree' | 'same-dir'
  • single-session:一次只运行一个会话,会话结束后 Bridge 退出。这是最简单的模式。
  • worktree:每个会话创建独立的 Git worktree,确保并发的文件修改操作互不干扰。worktree 在会话结束后自动清理。这是多会话场景下最安全的模式,代价是 worktree 创建需要约 1-2 秒的 Git 操作延迟。
  • same-dir:所有会话共享同一工作目录。适用于只读分析场景,但并发写入会互相覆盖。这种模式的存在是因为某些场景不需要文件隔离(如多个用户同时查询代码库而不修改文件),此时避免 worktree 开销可以加快会话启动。

多会话场景下,Bridge 维护一组并行的跟踪映射表:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

const activeSessions = new Map<string, SessionHandle>()    // 活跃会话
const sessionStartTimes = new Map<string, number>()        // 启动时间
const sessionWorkIds = new Map<string, string>()           // 工作项ID
const sessionCompatIds = new Map<string, string>()         // 兼容层ID缓存
const sessionIngressTokens = new Map<string, string>()     // 入口JWT
const sessionTimers = new Map<string, ReturnType<typeof setTimeout>>()  // 超时看门狗
const completedWorkIds = new Set<string>()                 // 已完成的工作项(防重复)
const sessionWorktrees = new Map<string, { ... }>()        // Worktree信息

容量管理通过 capacityWake 信号实现精确唤醒:

typescript 复制代码
// 文件: src/bridge/capacityWake.ts

export function createCapacityWake(outerSignal: AbortSignal): CapacityWake {
  let wakeController = new AbortController()

  function wake(): void {
    wakeController.abort()                // 中断当前的 sleep
    wakeController = new AbortController() // 准备下一次
  }

  function signal(): CapacitySignal {
    const merged = new AbortController()
    const abort = (): void => merged.abort()
    outerSignal.addEventListener('abort', abort, { once: true })
    wakeController.signal.addEventListener('abort', abort, { once: true })
    return { signal: merged.signal, cleanup: () => { /* 移除监听 */ } }
  }

  return { signal, wake }
}

当一个会话结束时,onSessionDone 回调调用 capacityWake.wake(),Bridge 的轮询循环从容量等待中立即唤醒,可以接受新任务。无需等到下一个轮询周期。

12.5.4 会话超时与看门狗

每个会话都有一个超时看门狗,默认 24 小时:

typescript 复制代码
// 文件: src/bridge/types.ts
export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000

超时到达时,Bridge 标记该会话为"已超时"(timedOutSessions.add(sessionId)),然后发送 SIGTERM。当子进程退出时,onSessionDone 检测到超时标记,将 interrupted 状态重新分类为 failed,确保服务器被正确通知会话并非正常中断。

12.6 权限对话转发

12.6.1 control_request/control_response 协议

当子进程 CLI 需要执行一个受保护的工具操作(如写入文件、运行 shell 命令)时,它不能自行决策 -- 权限决策权在用户手中。在 Bridge 场景下,"用户" 在 IDE 或 Web 界面中,而非终端面前。因此,权限请求需要跨进程、跨网络传递。这形成了一个四段跳的通信路径:子进程通过 stdout 发出请求,Bridge 进程解析后转发给服务器,服务器推送给 Web UI 或 IDE 扩展,用户做出决策后原路返回。整个往返延迟可能在数百毫秒到数秒之间,取决于网络状况和用户响应速度。如果用户在约 10-14 秒内未响应,服务器会终止 WebSocket 连接,触发 Bridge 的重连逻辑。

子进程通过 stdout 输出 control_request 消息:

typescript 复制代码
// 文件: src/bridge/sessionRunner.ts

export type PermissionRequest = {
  type: 'control_request'
  request_id: string
  request: {
    subtype: 'can_use_tool'          // 工具执行权限检查
    tool_name: string                // 工具名称
    input: Record<string, unknown>   // 工具输入参数
    tool_use_id: string              // 工具调用ID
  }
}

Bridge 的 sessionRunner.ts 在解析 stdout NDJSON 时检测到此消息,转发给上层:

typescript 复制代码
// 文件: src/bridge/sessionRunner.ts(stdout解析逻辑)

if (msg.type === 'control_request') {
  const request = msg.request as Record<string, unknown> | undefined
  if (request?.subtype === 'can_use_tool' && deps.onPermissionRequest) {
    deps.onPermissionRequest(
      opts.sessionId,
      parsed as PermissionRequest,
      opts.accessToken,
    )
  }
}

在独立 Bridge 中,onPermissionRequest 回调调用 api.sendPermissionResponseEvent 将请求转发到服务器。服务器将其推送到 Web UI,用户在界面上做出 allow/deny 决策后,决策结果作为 PermissionResponseEvent 返回。

权限响应的类型结构如下:

typescript 复制代码
// 文件: src/bridge/types.ts

export type PermissionResponseEvent = {
  type: 'control_response'
  response: {
    subtype: 'success'
    request_id: string            // 与原始请求的request_id匹配
    response: Record<string, unknown>  // 权限决策:{ behavior: 'allow' | 'deny' }
  }
}

12.6.2 REPL Bridge 中的权限流转

在 REPL Bridge 场景下,权限流转的路径稍有不同。REPL 进程本身就是执行引擎,不需要通过 stdin 转发。当 IDE 扩展的 Web 端用户做出权限决策时,control_response 通过 WebSocket/SSE 入站消息到达 Bridge,handleIngressMessage 路由到 onPermissionResponse 回调:

typescript 复制代码
// 文件: src/bridge/bridgeMessaging.ts

if (isSDKControlResponse(parsed)) {
  onPermissionResponse?.(parsed)
  return
}

bridgePermissionCallbacks.ts 定义了权限回调的完整类型,包括发送请求、接收响应、取消请求和订阅响应的接口:

typescript 复制代码
// 文件: src/bridge/bridgePermissionCallbacks.ts

type BridgePermissionCallbacks = {
  sendRequest(requestId: string, toolName: string, input: Record<string, unknown>,
    toolUseId: string, description: string, permissionSuggestions?: PermissionUpdate[],
    blockedPath?: string): void
  sendResponse(requestId: string, response: BridgePermissionResponse): void
  cancelRequest(requestId: string): void
  onResponse(requestId: string,
    handler: (response: BridgePermissionResponse) => void): () => void
}

响应验证使用类型守卫而非不安全的类型断言:

typescript 复制代码
// 文件: src/bridge/bridgePermissionCallbacks.ts

function isBridgePermissionResponse(value: unknown): value is BridgePermissionResponse {
  if (!value || typeof value !== 'object') return false
  return 'behavior' in value && (value.behavior === 'allow' || value.behavior === 'deny')
}

12.6.3 服务器端控制请求

除了子进程发起的权限请求,服务器也会主动发送控制请求。例如 initialize(初始化握手)、set_model(切换模型)、set_permission_mode(更改权限模式)。这些请求通过入站的 control_request 消息到达,bridgeMessaging.tshandleServerControlRequest 函数负责分发处理:

yaml 复制代码
用户在 Web UI 切换模型
    |
    v
服务器发送 control_request (subtype: 'set_model')
    |
    v
Bridge 的 SSE/WS 入站流
    |
    v
handleIngressMessage → isSDKControlRequest → onControlRequest
    |
    v
handleServerControlRequest → onSetModel 回调
    |
    v
REPL 切换到新模型,发送 control_response 确认

12.7 重试与容错

Bridge 的容错机制包含双轨退避、休眠检测和容量唤醒三大子系统,它们协同确保长时间运行的 Bridge 连接的可靠性。下图展示了轮询循环中的重试决策状态机:

stateDiagram-v2 [*] --> Polling: 正常轮询 Polling --> Success: 获取到工作 Success --> Polling: 处理完成\n重置退避 Polling --> ConnError: 连接错误\n(网络/DNS) ConnError --> ConnBackoff: 连接退避 Polling --> GenError: 通用错误\n(4xx/5xx) GenError --> GenBackoff: 通用退避 ConnBackoff --> SleepCheck: 检测系统休眠 GenBackoff --> SleepCheck SleepCheck --> WakeReset: 休眠检测到 WakeReset --> Polling: 重置所有退避 SleepCheck --> WaitPoll: 未检测到休眠 WaitPoll --> CapacityWake: 容量唤醒 CapacityWake --> Polling: 立即轮询 WaitPoll --> Polling: 退避时间到期

12.7.1 双轨指数退避

bridgeMain.ts 的轮询循环实现了双轨退避策略,将错误分为两类独立跟踪:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

export type BackoffConfig = {
  connInitialMs: number      // 连接错误:初始延迟(默认2秒)
  connCapMs: number          // 连接错误:最大延迟(默认2分钟)
  connGiveUpMs: number       // 连接错误:放弃阈值(默认10分钟)
  generalInitialMs: number   // 通用错误:初始延迟(默认500毫秒)
  generalCapMs: number       // 通用错误:最大延迟(默认30秒)
  generalGiveUpMs: number    // 通用错误:放弃阈值(默认10分钟)
  shutdownGraceMs?: number   // 关闭宽限期(默认30秒)
  stopWorkBaseDelayMs?: number // stopWork重试基础延迟
}

连接错误轨道 处理 ECONNREFUSEDECONNRESETETIMEDOUTENETUNREACHEHOSTUNREACH 等网络层故障,以及 HTTP 5xx 服务器错误。这些错误的退避起步较高(2 秒),因为网络恢复通常需要较长时间。

通用错误轨道处理其他非致命错误。退避起步较低(500 毫秒),增长上限也较小(30 秒),因为这类错误往往是暂时性的。

两条轨道互斥切换:当错误从一种类型转为另一种时,旧轨道被重置。这防止了例如"先连接错误退避到 2 分钟,然后出现通用错误,因为已经在高退避所以响应迟钝"的问题。

所有退避值都附加 +-25% 的随机抖动:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

function addJitter(ms: number): number {
  return Math.max(0, ms + ms * 0.25 * (2 * Math.random() - 1))
}

12.7.2 系统休眠检测

笔记本电脑合盖休眠是 Bridge 的常见场景。系统休眠期间,所有定时器被冻结,但墙钟时间照常流逝。当系统醒来时,如果 Bridge 不做特殊处理,累积的退避延迟可能导致错误预算被"虚耗" -- Bridge 以为已经持续失败了很长时间,实际上只是睡了一觉。

检测机制非常简洁:记录上一次轮询错误的时间戳,如果两次错误之间的间隔远超预期的退避延迟上限,则判定为系统刚从休眠中恢复:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
  return backoff.connCapMs * 2  // 使用2倍的最大退避延迟作为阈值
}

// 在错误处理中:
if (
  lastPollErrorTime !== null &&
  now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig)
) {
  // 检测到系统休眠,重置错误预算
  connErrorStart = null
  connBackoff = 0
  generalErrorStart = null
  generalBackoff = 0
}

阈值设置为连接退避上限的 2 倍(默认 240 秒),与 WebSocketTransportreplBridge 中的休眠检测使用相同的模式。如果阈值设得太低(比如等于退避上限),正常退避延迟本身就会触发误检测,导致错误预算被无限重置,永远不会放弃。

12.7.3 stopWork 的带退避重试

当会话结束时,Bridge 需要调用 stopWork 通知服务器释放工作项。如果这个调用失败,服务器端的工作项会变成"僵尸" -- 已被确认但永远不会完成。stopWorkWithRetry 提供了带指数退避的重试:

typescript 复制代码
// 文件: src/bridge/bridgeMain.ts

async function stopWorkWithRetry(
  api: BridgeApiClient,
  environmentId: string,
  workId: string,
  logger: BridgeLogger,
  baseDelayMs = 1000,
): Promise<void> {
  const MAX_ATTEMPTS = 3

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    try {
      await api.stopWork(environmentId, workId, false)
      return
    } catch (err) {
      // 认证错误不可重试
      if (err instanceof BridgeFatalError) return

      if (attempt < MAX_ATTEMPTS) {
        await sleep(baseDelayMs * Math.pow(2, attempt - 1)) // 1s, 2s, 4s
      }
    }
  }
}

注意 BridgeFatalError(如 401/403)被判定为不可重试 -- 认证失败不会因为重试而好转。

12.7.4 优雅关闭的多阶段流程

Bridge 的关闭过程是一个精心编排的多阶段协议:

makefile 复制代码
用户按 Ctrl+C / 系统发送 SIGTERM
    |
    v
阶段1: 向所有活跃子进程发送 SIGTERM
    |
    v
阶段2: 等待子进程退出(最多30秒宽限期)
    |
    v
阶段3: 对未退出的子进程发送 SIGKILL
    |
    v
阶段4: 清理所有定时器(超时、令牌刷新)
    |
    v
阶段5: 移除会话的 Git worktree
    |
    v
阶段6: 调用 stopWork 通知服务器所有工作项已结束
    |
    v
阶段7: 等待所有异步清理 Promise 完成
    |
    v
阶段8: 归档会话(使其在 Web UI 显示为已结束)
    |
    v
阶段9: 注销环境(使 Bridge 在 Web UI 显示为离线)
    |
    v
阶段10: 清除崩溃恢复指针

有一种例外情况:当 Bridge 以 single-session 模式运行且包含预创建的会话(initialSessionId)时,关闭过程会跳过阶段 8-10,保留会话和环境。这样用户可以通过 claude remote-control --continue 命令恢复中断的会话。服务器端的 4 小时 TTL(BRIDGE_LAST_POLL_TTL)会在超时后自动清理。

12.7.5 动态轮询间隔调参

Bridge 的轮询间隔不是硬编码的常量,而是通过 GrowthBook 特性标志(tengu_bridge_poll_interval_config)动态可调。运维团队可以在不发布新版本的情况下,调整全球所有 Bridge 的轮询频率:

typescript 复制代码
// 文件: src/bridge/pollConfigDefaults.ts

export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
  poll_interval_ms_not_at_capacity: 2000,      // 空闲时:2秒
  poll_interval_ms_at_capacity: 600_000,        // 满载时:10分钟
  non_exclusive_heartbeat_interval_ms: 0,       // 心跳:默认关闭
  multisession_poll_interval_ms_not_at_capacity: 2000,
  multisession_poll_interval_ms_partial_capacity: 2000,
  multisession_poll_interval_ms_at_capacity: 600_000,
  reclaim_older_than_ms: 5000,                  // 回收阈值:5秒
  session_keepalive_interval_v2_ms: 120_000,    // Keep-alive:2分钟
}

配置验证使用 Zod schema,带有精心设计的约束条件:

typescript 复制代码
// 文件: src/bridge/pollConfig.ts

// 满载轮询间隔使用"0或>=100"的验证规则:
// 0 表示禁用轮询(仅心跳模式),>=100 防止运维误配导致的高频轮询
// 1-99 被拒绝 -- 防止单位混淆(运维以为是秒,实际是毫秒)

对象级别还有一个关键的不变量约束:心跳间隔和满载轮询间隔至少有一个必须大于 0。如果两者都被设为 0,Bridge 在满载状态下将没有任何活性信号,导致 HTTP 请求以网络往返速度紧密循环。

12.7.6 心跳与轮询的协作

当 Bridge 满载(所有会话槽位都被占用)时,它不需要频繁轮询新任务,但仍需向服务器证明自己还活着。心跳机制(heartbeatWork)使用会话的 JWT 令牌(而非环境密钥)进行认证,避免了数据库查询开销:

scss 复制代码
满载状态下的循环行为:

while (at_capacity && !aborted) {
  heartbeat所有活跃工作项          // 使用JWT, 无DB开销
  sleep(heartbeat_interval)        // 默认60秒

  if (轮询截止时间到达) {
    break → 执行一次pollForWork    // 使用环境密钥, 有DB开销
  }
}

心跳响应包含 lease_extendedstate 字段。如果心跳返回 401/403(JWT 过期),Bridge 调用 reconnectSession 触发服务器重新派发,下一次轮询会获取带有新 JWT 的工作项。

12.8 架构图

下图展示了 Bridge 子系统中两种模式的完整数据流:

scss 复制代码
                            ┌─────────────────────────────────────────┐
                            │           claude.ai 服务器               │
                            │                                         │
                            │  ┌──────────┐  ┌───────────────────┐   │
                            │  │Environments│  │ Session-Ingress  │   │
                            │  │   API     │  │  (WS/SSE)        │   │
                            │  └────┬─────┘  └────────┬──────────┘   │
                            │       │                  │              │
                            └───────┼──────────────────┼──────────────┘
                                    │                  │
            ┌───────────────────────┼──────────────────┼───────────────────┐
            │                       │                  │                   │
    ┌───────┴──────────┐   ┌───────┴──────────┐   ┌──┴──────────────┐   │
    │  独立Bridge模式    │   │  REPL Bridge模式  │   │   无环境v2模式   │   │
    │  (bridgeMain.ts)  │   │  (replBridge.ts) │   │(remoteBridgeCore)│   │
    │                   │   │                  │   │                  │   │
    │ ┌──────────────┐ │   │ writeMessages()  │   │  POST /bridge    │   │
    │ │ 轮询工作队列  │ │   │ writeSdkMessages│   │  → JWT + epoch   │   │
    │ │ pollForWork  │ │   │                  │   │                  │   │
    │ └──────┬───────┘ │   │ ┌──────────────┐│   │ ┌──────────────┐ │   │
    │        │         │   │ │ FlushGate    ││   │ │ SSE + CCR    │ │   │
    │ ┌──────┴───────┐ │   │ │ (排队/去重)  ││   │ │ Transport    │ │   │
    │ │ spawn子进程   │ │   │ └──────────────┘│   │ └──────────────┘ │   │
    │ │(sessionRunner)│ │   │                  │   │                  │   │
    │ └──────┬───────┘ │   │ ┌──────────────┐│   └──────────────────┘   │
    │        │         │   │ │ Transport    ││                          │
    │  stdin ↕ stdout  │   │ │v1:Hybrid/WS  ││                          │
    │        │         │   │ │v2:SSE+CCR    ││                          │
    │ ┌──────┴───────┐ │   │ └──────────────┘│                          │
    │ │ Claude Code  │ │   │                  │                          │
    │ │   CLI子进程   │ │   │  REPL 执行引擎   │                          │
    │ └──────────────┘ │   └──────────────────┘                          │
    └──────────────────┘                                                 │
            │                                                            │
            │              共享基础设施                                    │
            │   ┌────────────────────────────────────────────┐          │
            └──>│  jwtUtils    (令牌刷新调度)                  │          │
                │  trustedDevice (可信设备认证)                │          │
                │  bridgeApi     (HTTP客户端)                  │          │
                │  bridgeMessaging (消息路由/去重)             │          │
                │  pollConfig    (动态间隔配置)                │          │
                │  capacityWake  (容量唤醒信号)                │          │
                └────────────────────────────────────────────┘          │
                                                                        │
                                                                        │
    ┌───────────────────────────────────────────────────────────────────┘
    │  权限流转
    │
    │  子进程 stdout → control_request → Bridge → 服务器 → Web UI
    │  Web UI → control_response → 服务器 → Bridge → 子进程 stdin
    └───────────────────────────────────────────────────────────────────

12.9 设计决策

12.9.1 为什么选择服务器中继而非直接通信

Bridge 不直接与 IDE 扩展通信,而是通过 claude.ai 服务器中继。这个决策有几层考量:

  1. NAT 穿透:开发者的机器通常在 NAT 或防火墙后面,IDE 扩展无法直接发起到 CLI 的连接。服务器充当公共端点,双方都以出站连接接入。

  2. 身份验证:服务器拥有用户的 OAuth 凭据,可以验证连接双方的身份。直接通信需要在本地实现完整的认证协议。

  3. 跨设备能力 :Bridge 和 IDE 不一定在同一台机器上。开发者可能在云服务器上运行 claude remote-control,然后从本地 IDE 连接。

  4. 功能聚合:服务器端可以做消息持久化、多客户端扇出、权限策略强制等工作,这些如果放在 Bridge 端会大幅增加复杂度。

12.9.2 为什么子进程使用 NDJSON 而非 IPC

Bridge 与子进程之间使用 stdio 的 NDJSON(换行分隔 JSON)格式通信,而非 Node.js 的 IPC channel 或 Unix domain socket。原因在于:

  • 可调试性 :NDJSON 是人类可读的文本,可以直接 tail -f 查看通信内容。IPC 是二进制序列化的。
  • 语言无关:如果将来子进程换成非 Node.js 实现,NDJSON 通过 stdio 是最普遍的 IPC 方式。
  • 已有基础设施 :Claude Code 的 --output-format stream-json--input-format stream-json 本身就是为 SDK 集成设计的,Bridge 直接复用了这套格式。

12.9.3 为什么需要两层 ID(cse_ 和 session_)

这是一个典型的"渐进式迁移"代价。CCR v2 引入了新的基础设施层,其内部使用 cse_(Code Session Entity)前缀的 ID。但上层的兼容 API 仍然使用 session_ 前缀,且无法一次性全量切换(涉及多个团队的多个服务)。

sessionIdCompat.ts 的存在本身就是一个架构债务的标志。源码注释中提到了 ccr_v2_compat_enabled 门控开关,暗示当 v2 全量上线后,这层转换可能会被简化。但在当前阶段,它是保障 v1/v2 共存所必需的粘合层。

12.9.4 Bridge 的崩溃恢复设计

bridgePointer.ts(本章未详述源码但已在目录结构中列出)实现了崩溃恢复指针机制。每次 Bridge 成功创建会话后,会将 environmentIdsessionIdsourcereplstandalone)写入磁盘文件。如果 Bridge 进程意外崩溃,下次启动时可以从指针文件中恢复,通过 reconnectSession 重新接管已有的会话,避免用户的对话历史丢失。

恢复过程并非简单地读取指针然后直连。initBridgeCore 中的 tryReconnectInPlace 函数展示了恢复的完整逻辑:首先尝试用指针中的 environmentId 重新注册环境,如果服务器返回的 environment_id 与请求的一致(说明旧环境仍存活),则调用 reconnectSession 强制停止旧的 worker 实例并重新排队会话。由于服务器端可能已开启 CCR v2 兼容层,reconnectSession 需要同时尝试 session_*cse_* 两种 ID 格式 -- 因为指针中存储的是 createBridgeSession 返回的兼容层 ID,而基础设施层可能只认识另一种格式。

perpetual(持久)模式更进一步 -- 即使是正常退出也不清除指针文件,使得 Bridge 能够在每次启动时自动恢复上次的会话状态。这对 Agent SDK daemon 场景尤为重要:daemon 进程可能因为系统更新而被重启,但它管理的会话应该无缝延续。replBridge.ts 在 perpetual 模式下,还会将上一次运行中已刷新到服务器的消息 UUID 标记为"已发送",避免重连后的初始刷新阶段重复发送相同的消息导致服务器端出现重复内容。

12.9.5 API 客户端的安全设计

bridgeApi.ts 中的 API 客户端实现体现了多层安全防护思维。首先,所有从服务器获取的 ID(如 environment_id、session_id)在插入 URL 路径之前都经过 validateBridgeId 校验 -- 一个正则白名单过滤器,只允许字母、数字、下划线和连字符。这防止了路径穿越攻击(例如恶意的 session_id 值 ../../admin 被拼接到 URL 中)。

其次,OAuth 401 错误的处理采用了单次重试模式:收到 401 后,调用注入的 onAuth401 回调尝试刷新 OAuth 令牌;刷新成功则用新令牌重试请求一次;如果重试仍返回 401,则抛出 BridgeFatalError 让上层决定是否放弃。这种"最多一次重试"的策略防止了无限重试循环,同时给令牌刷新机制足够的机会修复暂时性的认证问题。

BridgeFatalError 类携带 HTTP 状态码和服务器提供的错误类型(如 environment_expired),使上层能够做出差异化的错误处理 -- 环境过期显示清洁的状态消息而非报错,认证失败则建议用户重新登录。

12.10 小结

Bridge 是 Claude Code 从终端工具进化为 IDE 集成平台的关键基础设施。本章深入剖析了它的多层架构:

传输层 提供了 v1(WebSocket + POST)和 v2(SSE + CCRClient)两种协议,统一抽象在 ReplBridgeTransport 接口后面,使上层代码对传输细节无感知。

会话层管理环境注册、会话创建、多会话并发、worktree 隔离和超时看门狗,将"一个 IDE 标签页 = 一个会话"的用户心智模型映射到可靠的进程管理。

认证层构建了从 OAuth 令牌到 session-ingress JWT 再到可信设备令牌的完整信任链,通过主动刷新调度器保证长时间运行的会话不会因令牌过期而中断。

容错层实现了双轨指数退避、系统休眠检测、容量唤醒信号和多阶段优雅关闭,使 Bridge 在网络波动、系统休眠、进程崩溃等异常场景下都能可靠恢复。

权限层 通过 control_request/control_response 协议打通了子进程与远端 UI 之间的权限决策通道,确保即使在远程执行场景下,用户仍然对工具操作拥有完整的控制权。

Bridge 的设计哲学可以用一句话概括:让 CLI 的能力完整地穿透到任何远端界面,同时不损失安全性、可靠性和可观测性。这种设计使得 Claude Code 不需要为每种 IDE 编写适配层,只需维护一个高质量的 Bridge 核心,就能将终端中的全部能力延伸到 VS Code、JetBrains 乃至浏览器中。

相关推荐
杨艺韬9 小时前
Claude Code设计与实现-第10章 Bash 安全与沙箱
agent
杨艺韬9 小时前
Claude Code设计与实现-第14章 多 Agent 协调与 Swarm
agent
杨艺韬9 小时前
Claude Code设计与实现-第9章 多模式权限模型
agent
杨艺韬9 小时前
Claude Code设计与实现-第16章 上下文管理与自动压缩
agent
杨艺韬9 小时前
Claude Code设计与实现-第11章 MCP 协议集成
agent
杨艺韬9 小时前
Claude Code设计与实现-第18章 设计模式与架构决策
agent
杨艺韬9 小时前
Claude Code设计与实现-第6章 工具类型系统设计
agent
杨艺韬9 小时前
Claude Code设计与实现-第1章 为什么需要理解 Claude Code
agent
杨艺韬9 小时前
Claude Code设计与实现-前言
agent