Claude Code 源码深度解析
第12章 Remote Control 桥接架构
"Any sufficiently advanced CLI is indistinguishable from a remote service." -- 改自 Arthur C. Clarke
本章要点
- 双模 Bridge 架构 --
src/bridge/实现的是 Remote Control:让 claude.ai/code 网页或 Claude App 远程接管本机会话。独立进程 Bridge(bridgeMain.ts)对应claude remote-control子命令,REPL Bridge(replBridge.ts)对应交互会话里的/remote-control斜杠命令(或启动参数--remote-control)。VS Code/JetBrains 的 IDE 直连是另一条路径,不经过 Bridge(见 §12.9.7) - 环境-会话两层模型 -- Environment 注册建立宿主机与服务器的信任通道,Session 在通道内承载具体的对话生命周期,两层解耦使一个 Bridge 可服务多个并发会话
- JWT 令牌生命周期管理 -- 从 OAuth 登录、可信设备注册到 session_ingress JWT 的主动续期,形成完整的认证链条,支持长达数小时的持续会话
- 指数退避与容量感知 -- 轮询循环区分连接错误和通用错误两条退避轨道,结合系统休眠检测和容量唤醒信号,在可靠性与服务器友好之间取得平衡
- 权限决策跨网络转发 -- 当 CLI 需要工具执行权限时,control_request 经会话连接送到 claude.ai 网页或 App,决策结果以 control_response 回传,闭环完成跨网络权限对话
引言:从终端到任意设备的桥梁
Claude Code 诞生于终端,但会话不必困在终端前。开发者在工位上启动一个任务,然后在手机上的 Claude App 或另一台电脑的 claude.ai/code 网页里接着看进度、审批权限、发下一条指令——支撑这件事的,是 src/bridge/ 模块实现的 Remote Control,本章沿用源码的叫法称它为 Bridge。2.1.89 的 cli.js 里,隐藏子命令 claude remote-control(别名 rc)的描述是 "Connect your local environment for remote-control sessions via claude.ai/code",交互会话里还有同名斜杠命令 /remote-control(别名 /rc)。
Bridge 的设计面临一个本质性矛盾:Claude Code 的核心能力(模型调用、工具执行、沙箱隔离、权限管理)全部实现在本机的一个 Node.js/Bun 进程中,而操作它的界面却在另一台设备的浏览器或 App 里。如何让两端高效、安全、可靠地协作,是本章的核心命题。
更具挑战性的是,Bridge 不是简单的请求-响应管道。它需要处理长时间运行的会话(一个编码任务可能持续数小时)、支持多会话并发(一个 Bridge 进程可同时服务多个远程会话)、在网络断开或笔记本休眠后优雅恢复、在本机进程重启后重新接上已有会话。这些需求交织在一起,催生了 Claude Code 中最复杂的子系统之一。
本章将从底层通信协议到上层会话管理,从认证安全到容错恢复,全面剖析 Bridge 的设计与实现。
12.1 为什么需要 Bridge
12.1.1 本机会话与远端界面的鸿沟
Claude Code 作为一个 CLI 工具,天然运行在终端进程中。它拥有完整的工具系统、权限模型、流式对话引擎,而且要读写的文件、要调用的 MCP 服务器、要执行的命令都在这台机器上。但用户想操作它的地方可能是手机上的 Claude App,或者另一台电脑上的 claude.ai/code 网页——两端不在同一台机器,通常还隔着 NAT 和防火墙。
一种做法是把会话整个搬到云端运行(这是 claude.ai/code 的云端会话),但那样就失去了本机的文件系统、MCP 配置和工具环境。Remote Control 走的是相反的路:按官方文档(核对于 2026-09-25),会话始终在本机运行,代码执行和文件访问都留在本机,网页和 App 只是"通往这个本地会话的一扇窗"。
Bridge 模式的核心洞见是:让 Claude Code 继续在本机进程中运行,由 Anthropic 的服务器在本机会话和远端界面之间中继消息。这样,Claude Code 的全部能力(包括沙箱、权限、MCP 协议等)对远端透明可用,远端只负责收集输入、渲染输出和展示权限请求。
这种架构还带来了一个额外的好处:可测试性。Bridge 的所有行为都可以通过模拟的 API 客户端和 session spawner 进行单元测试,不依赖真实的服务器和远端客户端。类型定义中 BridgeApiClient、SessionSpawner、BridgeLogger 等接口的设计,正是为了这种可注入的测试模式。
12.1.2 两种运行形态
Claude Code 的 Bridge 架构服务于两种截然不同的场景:
独立进程 Bridge(Standalone Bridge):开发者在终端运行 claude remote-control,启动一个持久运行的 Bridge 进程(官方文档称之为 server mode)。这个进程向服务器注册为一个"环境"(Environment),然后持续轮询,等待用户从 claude.ai/code 网页或 Claude App 发起的会话。每个会话到达时,Bridge 会 spawn 一个子 Claude Code 进程来执行。这是 bridgeMain.ts 的职责。
REPL 内嵌 Bridge(REPL Bridge):开发者在已经打开的交互会话里执行 /remote-control(或启动时就带上 --remote-control / --rc 参数),当前 REPL 进程内部就会初始化一个 Bridge 连接,把已有的对话同步到 claude.ai,同时接收来自网页或 App 的输入和控制指令;终端里照样可以继续输入。这是 replBridge.ts 和 initReplBridge.ts 的职责。现行官方文档(核对于 2026-09-25)还写明 VS Code 扩展的输入框里也能执行 /remote-control——此时被远程接管的是扩展背后的 Claude Code 会话,远端仍是 claude.ai,而不是说 Bridge 负责 VS Code 与 CLI 之间的通信。
两种模式共享大量基础设施 -- API 客户端、JWT 管理、消息协议、重试逻辑 -- 但在会话生命周期管理上有本质区别。独立 Bridge 是"一对多"的(一个 Bridge 进程服务多个会话),REPL Bridge 是"一对一"的(一个 REPL 进程对应一个 Bridge 会话)。
除了这两种模式之外,源码中还存在第三种变体:remoteBridgeCore.ts 实现的"无环境层 Bridge"。它只用于 REPL 一侧,由 GrowthBook 开关 tengu_bridge_repl_v2(2.1.89 的 cli.js 中默认值为 false)决定是否启用。这是一种更新的 v2 直连模式,它完全绕过 Environments API 的工作派发层,直接通过 POST /v1/code/sessions/{id}/bridge 端点获取 worker JWT 和 epoch,建立 SSE+CCRClient 传输。这种模式消除了注册/轮询/确认/停止/心跳/注销的环境生命周期管理开销,使连接建立更加轻量。三种模式的存在反映了 Bridge 子系统正处于从轮询式架构向直连式架构的渐进迁移过程中。
12.1.3 远端客户端的统一抽象
无论远端是 claude.ai/code 网页还是 iOS/Android 上的 Claude App,从 Bridge 的视角看都是"远端客户端"。Bridge 不直接与任何远端设备通信 -- 它通过 Anthropic 的服务器作为中介:
claude.ai 网页 / Claude App <---> Anthropic 服务器 <---> Bridge 进程 <---> Claude Code CLI
这种间接通信的设计意味着 Bridge 不需要为每种远端界面编写适配层:只要客户端会说 claude.ai 的会话协议,就能接管本机会话,Bridge 的代码无需改动。
注意这与 IDE 集成是两回事。VS Code / JetBrains 与本机 CLI 的直连走的是 --ide 参数、/ide 命令和 sse-ide / ws-ide 两种 MCP 传输:IDE 扩展在本机回环地址上起一个 MCP 服务器,CLI 通过 ~/.claude/ide/ 下的锁文件发现它并直接连过去,全程不经过 claude.ai,也不在 src/bridge/ 里(§12.9.7 会回到这条路径)。
从特性门控的角度来看,Bridge 功能需要满足多项前置条件才能激活。bridgeEnabled.ts 中的 isBridgeEnabledBlocking 函数展示了这些条件的层叠结构:首先检查编译期特性标志 feature('BRIDGE_MODE')(2.1.89 的公开构建里这个标志是打开的——remote-control 子命令和 Bridge 相关字符串都在 cli.js 中);然后检查用户是否以 claude.ai 订阅账号登录(Bedrock/Vertex/API key 用户不可用);最后检查 GrowthBook 开关(tengu_ccr_bridge)是否对当前账号启用。任何一项不满足,/remote-control 命令就不可用,也不在命令列表中显示。多会话能力另有一道开关 tengu_ccr_bridge_multi_session,未开启时使用 --spawn、--capacity 会报 "Multi-session Remote Control is not enabled for your account yet."。这种多层门控让功能可以按账号和组织灰度发布。
12.2 Bridge 架构总览
下图展示了 Bridge 的两种运行模式及其与远端界面和 Claude Code CLI 之间的通信路径:
flowchart TB
subgraph Remote["远端"]
WebUI["claude.ai/code 网页"]
App["Claude App"]
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
Remote <-->|"用户交互"| Server
Server <-->|"HTTP 长轮询"| BridgeMode1
Server <-->|"WS / SSE"| BridgeMode2
subgraph Auth["认证基础设施"]
JWT["令牌刷新调度"]
Trusted["设备认证"]
end
BridgeMode1 & BridgeMode2 --> Auth
12.2.1 目录结构与职责划分
src/bridge/ 目录包含 30 多个 TypeScript 文件,构成了 Bridge 子系统的完整实现。按职责可划分为以下几个层次:
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 子系统中最大的单一文件。它的宏观结构如下:
// 文件: 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 负责:
- 向服务器注册一个 Bridge 环境
- 创建一个会话(或复用崩溃恢复指针中的已有会话)
- 建立 WebSocket/SSE 传输连接
- 将 REPL 的对话消息实时同步到服务器
- 接收来自 Web 端的用户输入和控制指令
initBridgeCore 返回一个 ReplBridgeHandle,REPL 的其他模块通过这个句柄与 Bridge 交互:
// 文件: 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 启用的多层条件:
// 文件: 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.ts 的 createSessionSpawner 工厂函数负责创建这些子进程并管理其生命周期。
子进程 spawn 时携带的关键参数揭示了 Bridge 的通信机制:
// 文件: 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
}
注意 --sdk-url:子进程的对话输入输出是自己通过这个地址直连服务器收发的,并不经过父进程中转。Bridge 与子进程之间的三个标准 IO 管道只承担"旁路"职责:
- stdin:Bridge 向子进程写入控制消息(如令牌刷新、环境变量更新)
- stdout:子进程回显的 NDJSON 消息,Bridge 据此提取活动(正在读哪个文件、跑哪条命令)、识别权限请求和首条用户消息(用来生成会话标题);带
--debug-file时还会原样追加到bridge-transcript-<会话>.jsonl - stderr:子进程的错误输出,Bridge 维护一个环形缓冲区(最近 10 行)用于故障诊断
SessionHandle 是 Bridge 对子进程的控制句柄,它暴露了精确的生命周期操作:
// 文件: 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 不需要重启子进程来刷新认证:
// 文件: 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 将两种协议统一到同一个接口背后:
// 文件: 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 提供了完整的消息类型守卫和路由逻辑:
// 文件: 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),其中包含会话连接所需的全部凭据:
// 文件: 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:
// 文件: 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 是解决这个问题的状态机:
// 文件: 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 的认证体系从用户登录到会话令牌形成了完整的信任链条。下图以独立 Bridge 为例,展示各层令牌的获取顺序和刷新机制:
sequenceDiagram
participant Bridge as Claude Code / Bridge 进程
participant Auth as OAuth 服务
participant Server as Anthropic API
Note over Bridge,Server: 阶段一: /login (OAuth + PKCE)
Bridge->>Auth: 授权码 + code_verifier
Auth-->>Bridge: access_token + refresh_token
Note over Bridge,Server: 阶段二: 可信设备注册 (登录后不久, 受开关控制)
Bridge->>Server: POST /api/auth/trusted_devices\n(Bearer access_token)
Server-->>Bridge: device_token (存入 secureStorage)
Note over Bridge,Server: 阶段三: 环境注册
Bridge->>Server: POST /v1/environments/bridge\n(access_token + X-Trusted-Device-Token)
Server-->>Bridge: environment_id + environment_secret
Note over Bridge,Server: 阶段四: 轮询工作项
Bridge->>Server: GET .../work/poll\n(environment_secret)
Server-->>Bridge: 工作项 secret 内含 session_ingress JWT
Note over Bridge,Server: 阶段五: 主动续期
loop JWT 过期前
Bridge->>Bridge: jwtUtils.ts 调度器\n按 exp 时间触发, 取最新 OAuth 令牌
alt v1 会话
Bridge->>Bridge: 经 stdin 下发给子进程
else v2 会话
Bridge->>Server: reconnectSession
Server-->>Bridge: 下次轮询得到携带新 JWT 的工作项
end
end
12.4.1 认证链条的全景
Bridge 的认证涉及多层令牌的协作,形成一条完整的信任链:
用户登录 claude.ai
|
v
OAuth Access Token(用户级凭据,由 /login 获得)
|-- 用于 Bridge 环境注册
|-- 用于会话创建
|-- 用于权限响应转发
|
v
Session Ingress JWT(会话级凭据,随工作项签发)
|-- 由服务器在工作派发时签发
|-- 包含 session_id 声明
|-- 用于子进程的 API 调用
|-- 用于 WebSocket/SSE 连接认证
|
v
Trusted Device Token(持久,由服务器控制有效期)
|-- 存入 secureStorage(macOS 为钥匙串,Linux 为 ~/.claude/.credentials.json)
|-- 作为 X-Trusted-Device-Token 头发送
|-- 服务器端安全层级提升的依据
12.4.2 jwtUtils.ts:令牌刷新调度器
长时间运行的 Bridge 会话(可能持续数小时甚至一整天)不能依赖一次性签发的 JWT。jwtUtils.ts 实现了一个主动令牌刷新调度器,在 JWT 过期前提前续期:
// 文件: 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() 在完成后会检查代际号是否匹配 -- 如果不匹配,说明此次刷新已被更新的调度取代,应该静默退出。这避免了竞态条件下的重复刷新和孤立定时器。
// 文件: 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 中,令牌刷新的行为因协议版本而异:
// 文件: 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 管理可信设备令牌的完整生命周期:
// 文件: 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. 持久化到 secureStorage(macOS 钥匙串 / Linux 凭据文件)
const token = response.data?.device_token
storageData.trustedDeviceToken = token
secureStorage.update(storageData)
}
注册时机有严格约束:按源码注释,服务器端限制 account_session.created_at < 10分钟,意味着设备注册必须在用户 /login 后立即完成。延迟注册(如在 Bridge 首次使用时懒注册)会因会话过期而被拒绝。现行官方文档(核对于 2026-09-25)也写明"只在完整登录后不久才提供注册",并把 Trusted Devices 描述为一个 beta 功能:由组织 Owner(或 Pro/Max 用户本人)打开 "Require trusted devices" 后,查看或操作 Remote Control 会话的浏览器、手机和桌面 App 都需各自注册,且登录不得超过 18 小时。
令牌读取使用了 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.ts 的 createBridgeSession 函数负责,它构建包含 Git 来源信息、模型选择和初始事件的请求体,通过 POST /v1/sessions 创建会话。会话创建是独立于环境注册的操作 -- 一个环境可以在其生命周期内创建多个会话。
claude.ai 服务器
|
环境(Environment)
/ | \
Session A Session B Session C
| | |
子进程 A 子进程 B 子进程 C
环境代表一个 Bridge 进程实例与服务器之间的连接。注册环境时,Bridge 提交机器名、工作目录、Git 分支、最大并发会话数等元数据。服务器返回 environment_id 和 environment_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 提供了双向转换:
// 文件: 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 函数则提供了无视前缀的比较:
// 文件: 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 类型定义:
// 文件: src/bridge/types.ts
export type SpawnMode = 'single-session' | 'worktree' | 'same-dir'
- single-session:一次只运行一个会话,会话结束后 Bridge 退出。这是最简单的模式。
- worktree:每个会话创建独立的 Git worktree,确保并发的文件修改操作互不干扰。worktree 在会话结束后自动清理。这是多会话场景下最安全的模式,代价是每个会话启动时都要多做一次创建 worktree 的 Git 操作。
- same-dir:所有会话共享同一工作目录。适用于只读分析场景,并发编辑同一文件时可能互相冲突。这种模式的存在是因为某些场景不需要文件隔离(如几个会话都只是查询代码库而不修改文件),此时避免 worktree 开销可以加快会话启动。
命令行上用 --spawn session|same-dir|worktree 选择(session 即 single-session),--capacity 设并发上限。按 2.1.89 的 cli.js:既没传 --spawn、配置里也没有保存过选择(remoteControlSpawnMode)时,多会话开关 tengu_ccr_bridge_multi_session 打开则用 same-dir,否则用 single-session,并发上限默认 32;运行中按 w 可在 same-dir 与 worktree 之间切换。现行官方文档(核对于 2026-09-25)同样以 same-dir 为默认、上限默认 32。
多会话场景下,Bridge 维护一组并行的跟踪映射表:
// 文件: 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 信号实现精确唤醒:
// 文件: 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 小时:
// 文件: 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 场景下,"用户" 在 claude.ai 网页或 Claude App 上,而非终端面前。因此,权限请求需要跨网络传递:子进程把请求写进自己经 --sdk-url 建立的会话连接,服务器推送给网页或 App,用户做出决策后,决策沿同一条会话连接回到子进程。整个往返的耗时取决于网络状况和用户响应速度;按现行官方文档(核对于 2026-09-25),权限请求会一直保持打开直到用户作答。
请求本身是一条 control_request 消息;bridge 模式下子进程还会把它回显到 stdout,让父进程也能看到:
// 文件: 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 时检测到此消息,通知上层:
// 文件: 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,
)
}
}
在 2.1.89 的 cli.js 中,独立 Bridge 传入的 onPermissionRequest 只记一条调试日志("not auto-approving"),并不代为转发或裁决——请求早已由子进程自己送到服务器。bridgeApi.ts 里虽然有 sendPermissionResponseEvent(向 /v1/sessions/{id}/events 发送 control_response 事件),但该版本 cli.js 中没有调用点。用户在网页或 App 上做出 allow/deny 决策后,决策以 control_response 的形式经会话连接回到子进程,其结构如下:
// 文件: 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 转发。当 claude.ai 网页或 App 上的用户做出权限决策时,control_response 通过 WebSocket/SSE 入站消息到达 Bridge,handleIngressMessage 路由到 onPermissionResponse 回调:
// 文件: src/bridge/bridgeMessaging.ts
if (isSDKControlResponse(parsed)) {
onPermissionResponse?.(parsed)
return
}
bridgePermissionCallbacks.ts 定义了权限回调的完整类型,包括发送请求、接收响应、取消请求和订阅响应的接口:
// 文件: 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
}
响应验证使用类型守卫而非不安全的类型断言:
// 文件: 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.ts 的 handleServerControlRequest 函数负责分发处理:
用户在 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(连接类错误码/5xx)
ConnError --> ConnBackoff: 连接退避
Polling --> GenError: 通用错误\n(其余非致命错误)
GenError --> GenBackoff: 通用退避
ConnBackoff --> SleepCheck: 检测系统休眠
GenBackoff --> SleepCheck
SleepCheck --> WakeReset: 休眠检测到
WakeReset --> Polling: 重置所有退避
SleepCheck --> WaitPoll: 未检测到休眠
WaitPoll --> Polling: 退避时间到期
Success --> AtCapacity: 会话槽位已满
AtCapacity --> CapacityWake: 有会话结束
CapacityWake --> Polling: 立即轮询
AtCapacity --> Polling: 满载轮询间隔到期
12.7.1 双轨指数退避
bridgeMain.ts 的轮询循环实现了双轨退避策略,将错误分为两类独立跟踪:
// 文件: 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重试基础延迟
}
连接错误轨道处理 ECONNREFUSED、ECONNRESET、ETIMEDOUT、ENETUNREACH、EHOSTUNREACH 等网络层故障,以及 HTTP 5xx 服务器错误。这些错误的退避起步较高(2 秒),因为网络恢复通常需要较长时间。
通用错误轨道处理其他非致命错误。退避起步较低(500 毫秒),增长上限也较小(30 秒),因为这类错误往往是暂时性的。
两条轨道互斥切换:当错误从一种类型转为另一种时,旧轨道被重置。这防止了例如"先连接错误退避到 2 分钟,然后出现通用错误,因为已经在高退避所以响应迟钝"的问题。
所有退避值都附加 +-25% 的随机抖动:
// 文件: 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 以为已经持续失败了很长时间,实际上只是睡了一觉。
检测机制非常简洁:记录上一次轮询错误的时间戳,如果两次错误之间的间隔远超预期的退避延迟上限,则判定为系统刚从休眠中恢复:
// 文件: 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 秒),与 WebSocketTransport 和 replBridge 中的休眠检测使用相同的模式。如果阈值设得太低(比如等于退避上限),正常退避延迟本身就会触发误检测,导致错误预算被无限重置,永远不会放弃。
12.7.3 stopWork 的带退避重试
当会话结束时,Bridge 需要调用 stopWork 通知服务器释放工作项。如果这个调用失败,服务器端的工作项会变成"僵尸" -- 已被确认但永远不会完成。stopWorkWithRetry 提供了带指数退避的重试:
// 文件: 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
}
}
}
}
注意 BridgeFatalError(如 401/403)被判定为不可重试 -- 认证失败不会因为重试而好转。三次尝试之间只等待两次(1 秒、2 秒);2.1.89 的 cli.js 中这两次等待还叠加了与轮询相同的 ±25% 抖动。
12.7.4 优雅关闭的多阶段流程
Bridge 的关闭过程是一个精心编排的多阶段协议:
用户按 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: 清除崩溃恢复指针
这条正常关闭路径在 2.1.89 的 cli.js 中不区分模式:预创建的初始会话(initialSessionId)同样会被归档,环境被注销,指针被清除。独立 Bridge 只在 single-session 模式下写崩溃恢复指针(每小时刷新一次),因此只有进程被强杀、崩溃等没走完这条路径的情况,才会留下指针供 claude remote-control --continue 恢复;服务器端的 4 小时 TTL(源码注释中的 BRIDGE_LAST_POLL_TTL)会在超时后自动清理残留的环境。现行官方文档(核对于 2026-09-25)描述的行为已经不同:用 Ctrl+C 停止 claude remote-control 时默认不归档会话,约 4 小时内在同一目录再次运行 claude remote-control、--continue 或 --session-id <id> 即可找回。
12.7.5 动态轮询间隔调参
Bridge 的轮询间隔不是硬编码的常量,而是通过 GrowthBook 特性标志(tengu_bridge_poll_interval_config)动态可调。运维团队可以在不发布新版本的情况下,调整全球所有 Bridge 的轮询频率:
// 文件: 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,带有精心设计的约束条件:
// 文件: src/bridge/pollConfig.ts
// 满载轮询间隔使用"0或>=100"的验证规则:
// 0 表示禁用轮询(仅心跳模式),>=100 防止运维误配导致的高频轮询
// 1-99 被拒绝 -- 防止单位混淆(运维以为是秒,实际是毫秒)
对象级别还有一个关键的不变量约束:心跳间隔和满载轮询间隔至少有一个必须大于 0。如果两者都被设为 0,Bridge 在满载状态下将没有任何活性信号,导致 HTTP 请求以网络往返速度紧密循环。
12.7.6 心跳与轮询的协作
当 Bridge 满载(所有会话槽位都被占用)时,它不需要频繁轮询新任务,但仍需向服务器证明自己还活着。心跳机制(heartbeatWork)使用会话的 JWT 令牌(而非环境密钥)进行认证,避免了数据库查询开销:
满载状态下的循环行为:
while (at_capacity && !aborted) {
heartbeat所有活跃工作项 // 使用JWT, 无DB开销
sleep(heartbeat_interval) // 由 non_exclusive_heartbeat_interval_ms 配置,默认 0(关闭)
if (轮询截止时间到达) {
break → 执行一次pollForWork // 使用环境密钥, 有DB开销
}
}
心跳响应包含 lease_extended 和 state 字段。如果心跳返回 401/403(JWT 过期),Bridge 调用 reconnectSession 触发服务器重新派发,下一次轮询会获取带有新 JWT 的工作项。
12.8 架构图
下图展示了 Bridge 子系统中两种模式的完整数据流:
┌─────────────────────────────────────────┐
│ 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 (容量唤醒信号) │ │
└────────────────────────────────────────────┘ │
│
│
┌───────────────────────────────────────────────────────────────────┘
│ 权限流转
│
│ 独立模式: 子进程 → control_request → 会话连接(--sdk-url) → 服务器 → 网页/App
│ 网页/App → control_response → 服务器 → 会话连接 → 子进程
│ (stdout 回显 control_request, 父进程 Bridge 只旁观)
│ REPL 模式: REPL → Bridge 传输层 → 服务器 → 网页/App, 响应原路返回
└───────────────────────────────────────────────────────────────────
12.9 设计决策
12.9.1 为什么选择服务器中继而非直接通信
Bridge 不直接与远端的网页或 App 通信,而是通过 Anthropic 的服务器中继。这个决策有几层考量:
-
NAT 穿透:开发者的机器通常在 NAT 或防火墙后面,手机或另一台电脑无法直接发起到本机 CLI 的连接。服务器充当公共端点,双方都以出站连接接入——官方文档(核对于 2026-09-25)明确写着本机会话只发出站 HTTPS 请求、从不在本机打开入站端口。
-
身份验证:服务器拥有用户的 OAuth 凭据,可以验证连接双方的身份。直接通信需要在本地实现完整的认证协议。
-
跨设备能力:Bridge 和操作它的界面本来就不在同一台设备上。开发者可以在远程服务器上(例如放在
tmux里)运行claude remote-control,然后从手机或任意一台电脑的浏览器接入。 -
功能聚合:服务器端可以做消息持久化、多客户端扇出、权限策略强制等工作,这些如果放在 Bridge 端会大幅增加复杂度。
12.9.2 为什么子进程使用 NDJSON 而非 IPC
Bridge 与子进程之间使用 stdio 的 NDJSON(换行分隔 JSON)格式通信,而非 Node.js 的 IPC channel 或 Unix domain socket。原因在于:
- 可调试性:NDJSON 是人类可读的文本;Bridge 带
--debug-file运行时会把子进程 stdout 原样追加到bridge-transcript-<会话>.jsonl,可以直接tail -f查看。Node 的 IPC 通道(默认同样用 JSON 序列化)则走专用的文件描述符,外部不便旁听。 - 语言无关:如果将来子进程换成非 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 成功创建会话后,会将 environmentId、sessionId 和 source(repl 或 standalone)写入磁盘文件。如果 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.9.6 关键默认值账本
Bridge 子系统散落在多个文件中的默认值经常成为线上调参的争论点。下表把本章已出现的硬编码常量和 GrowthBook 可调项汇总在一张表里,读者在阅读源码或定位线上问题时可以直接比对。所有数值均来自前文引用的源文件,没有二手转述。
| 参数 | 值 | 来源文件 | 可调性 |
|---|---|---|---|
| 连接错误初始退避 | 2000 ms | bridgeMain.ts DEFAULT_BACKOFF |
运行时不可调 |
| 连接错误退避上限 | 120 000 ms | bridgeMain.ts DEFAULT_BACKOFF |
运行时不可调 |
| 连接错误放弃阈值 | 600 000 ms | bridgeMain.ts DEFAULT_BACKOFF |
运行时不可调 |
| 通用错误初始退避 | 500 ms | bridgeMain.ts DEFAULT_BACKOFF |
运行时不可调 |
| 通用错误退避上限 | 30 000 ms | bridgeMain.ts DEFAULT_BACKOFF |
运行时不可调 |
| 休眠检测阈值 | connCapMs × 2 = 240 000 ms |
bridgeMain.ts pollSleepDetectionThresholdMs |
随 connCapMs 联动 |
| 会话超时看门狗 | 24 × 3600 × 1000 ms | types.ts DEFAULT_SESSION_TIMEOUT_MS |
编译常量 |
| JWT 刷新缓冲窗口 | 5 × 60 × 1000 ms | jwtUtils.ts TOKEN_REFRESH_BUFFER_MS |
构造函数参数 |
| JWT 备用刷新间隔 | 30 × 60 × 1000 ms | jwtUtils.ts FALLBACK_REFRESH_INTERVAL_MS |
编译常量 |
| JWT 连续失败上限 | 3 次 | jwtUtils.ts MAX_REFRESH_FAILURES |
编译常量 |
| stopWork 重试次数 | 3 次 | bridgeMain.ts stopWorkWithRetry |
编译常量 |
| stopWork 基础延迟 | 1000 ms(翻倍:1→2 秒) | bridgeMain.ts stopWorkWithRetry |
BackoffConfig 可覆盖 |
| 空闲轮询间隔 | 2000 ms | pollConfigDefaults.ts |
GrowthBook tengu_bridge_poll_interval_config |
| 满载轮询间隔 | 600 000 ms | pollConfigDefaults.ts |
GrowthBook |
回收阈值 reclaim_older_than_ms |
5000 ms | pollConfigDefaults.ts |
GrowthBook |
| Keep-alive v2 | 120 000 ms | pollConfigDefaults.ts session_keepalive_interval_v2_ms |
GrowthBook |
| 退避抖动范围 | ±25 % | bridgeMain.ts addJitter |
编译常量 |
| 关闭宽限期 | 30 000 ms | BackoffConfig.shutdownGraceMs |
可覆盖 |
| stderr 环形缓冲 | 最近 10 行 | sessionRunner.ts |
编译常量 |
| activities 环形缓冲 | 最近 10 条 | types.ts SessionHandle |
编译常量 |
| 可信设备注册时效 | account_session.created_at < 10 分钟 |
服务端硬约束 | 不可调 |
| BRIDGE_LAST_POLL_TTL | 4 小时 | 服务端常量 | 不可调 |
从这张表可以看出两个设计偏好:一是"数量级分层",同一个维度的值通常相差一个数量级以防止配置错位(比如 2 秒的空闲轮询 vs 600 秒的满载轮询,1 秒的 stopWork 基础延迟 vs 30 秒的关闭宽限期);二是"GrowthBook 走运行时,BackoffConfig 走测试注入"——凡是与流量成本挂钩的参数都交给运维远程调参,凡是与行为正确性挂钩的参数都作为函数入参供测试构造极端场景。
此外还有一个隐式的不变量需要手工维护:心跳间隔与满载轮询间隔"不能同时为 0"。如果这两个值都被配成 0,Bridge 在满载状态下既不发心跳也不触发新轮询,上层只剩业务消息可以证明自己活着,一旦当前会话静默超过服务器 TTL,链接会被清理。pollConfig.ts 在对象级 refine 中用 Zod 把这个组合拒掉在加载期。
12.9.7 对照:IDE 直连走的是另一条路
VS Code / JetBrains 的 IDE 集成常被与 Bridge 混为一谈,但它走的是本机直连,代码也不在 src/bridge/ 里。把两者落到用户电脑上的痕迹对照一看就很清楚。本节以 macOS 上的 claude 2.1.117 为例,给出一份磁盘端的实测地图(路径位于 ~/.claude/)。注意这个运行时版本晚于本专栏锚定的 2.1.88 源码快照,具体字段可能与你机器上的不同;下文凡说到 2.1.89 的行为,都在该版本的 cli.js 中核对过。
~/.claude/
|-- ide/ # IDE 扩展发现入口
| |-- <port>.lock # 每个活跃 IDE 扩展写一份锁文件,文件名是端口号
|-- sessions/ # 本机并发会话登记
| |-- <pid>.json # 每个 CLI 进程一份
|-- projects/ # 按工作目录聚合的对话历史
IDE 发现协议:IDE 扩展(VS Code / Cursor / JetBrains 等)启动时会在本机回环地址上起一个 MCP 服务器,并在 ~/.claude/ide/ 下写一个锁文件,文件名就是这个服务器监听的端口号(2.1.89 的 cli.js 正是把文件名去掉 .lock 后解析成端口)。CLI 扫描这个目录,把每个锁文件当作一个可连接的 IDE 候选。实测本机某个锁文件的内容是:
{
"pid": 92752,
"workspaceFolders": ["/Users/yangyitao/yyt_repository/code/yyt-jt"],
"ideName": "Cursor",
"transport": "ws",
"authToken": "4e03d66a-60a9-420d-8e1c-5af1e3145368"
}
这几个字段回答了 CLI 需要知道的问题:对端进程号(用于存活检测)、工作区列表(与当前 cwd 匹配,判断这个 IDE 是否"有效")、IDE 名称(用于 UI 显示)、传输类型("ws" 时 CLI 以 ws://<主机>:<端口> 建立 ws-ide 连接,否则以 http://<主机>:<端口>/sse 建立 sse-ide 连接)、authToken(建立 ws-ide 连接时作为 X-Claude-Code-Ide-Authorization 请求头出示,防止同机其他进程冒充 CLI 连进 IDE)。按官方文档(核对于 2026-09-25),每次 IDE 启动都会生成新的随机令牌;VS Code 扩展的服务绑定 127.0.0.1 上的随机端口,锁文件权限为 0600、所在目录为 0700,传输是未加密的 ws://。
连上之后,这个 IDE 在 CLI 眼里就是一个名为 ide 的 MCP 服务器(工具名前缀 mcp__ide__,不在 /mcp 列表中显示):CLI 借它在 IDE 里打开 diff、读取当前选区、拿诊断信息。在 IDE 内置终端里启动的 claude 会自动连接;在外部终端里用 /ide 命令手动连接,或者启动时带 --ide 参数(2.1.89 的帮助文本:"Automatically connect to IDE on startup if exactly one valid IDE is available")。这条链路全程在本机回环上,不经过 claude.ai,也不使用 Bridge 的环境、会话、JWT 那一套。
本机会话登记:另一处痕迹是 ~/.claude/sessions/<pid>.json,每个 CLI 进程写一份。本机实测某进程的内容是:
{
"pid": 5435,
"sessionId": "23690c81-b080-4851-80b1-9472bbc3fae1",
"cwd": "/Users/yangyitao/yyt_repository/code/yyt-jt/tmp-chat",
"startedAt": 1776855683127,
"procStart": "Wed Apr 22 11:01:21 2026",
"version": "2.1.117",
"peerProtocol": 1,
"kind": "interactive",
"entrypoint": "cli"
}
注意上面这份快照是在 macOS 上取的(cwd 里的 /Users/ 已经出卖了它),而 procStart 是平台相关的:macOS 上是 ps -o lstart 那种人类可读日期串,Linux 上则是一串纯数字——本机实测某进程写的是 "procStart": "2706508",正好等于 /proc/<pid>/stat 的第 22 个字段(进程启动时刻,单位是自开机起的时钟滴答,getconf CLK_TCK 为 100)。两边都只是拿来做"pid 复用检测"的指纹:光比 pid 不够,pid 会被回收,配上启动时刻才能断定"还是原来那个进程"。另外 peerProtocol / procStart 这两个字段在本专栏锚定的 2.1.89 里还不存在(把该版本 npm 包解开搜这两个名字是零命中),它们是之后才加的——上面这份快照自己也标了 "version": "2.1.117"。
2.1.89 的 cli.js 里,写这个文件的模块在日志中自称 concurrentSessions:它写入 pid、sessionId、cwd、startedAt、kind、entrypoint(取自环境变量 CLAUDE_CODE_ENTRYPOINT),之后还会补写会话名 name,以及开启 Remote Control 时的 bridgeSessionId;进程退出时删除自己的文件,统计并发会话数时顺手清掉已不存在的进程留下的文件。kind 在 2.1.89 的公开构建里恒为 "interactive"(决定其他取值的函数是空实现)。peerProtocol 的用途在锚定版本里无从核对,本专栏不作推断。
特性开关的本地缓存:Bridge 的多层门控(§12.1.3 的订阅检查 + GrowthBook tengu_ccr_bridge)读的开关值,在 2.1.89 中缓存在全局配置文件(通常是 ~/.claude.json)的 cachedGrowthBookFeatures 字段里(也兼容读取旧的 cachedStatsigGates),网络拉取失败时沿用上次的结果。2.1.89 的 cli.js 里 statsig 零命中,如果你的 ~/.claude/ 下还有 statsig/ 目录,那是更早版本留下的。官方文档(核对于 2026-09-25)还提醒:设置 DISABLE_TELEMETRY、DO_NOT_TRACK、CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC 或 DISABLE_GROWTHBOOK 都会关闭开关评估,Remote Control 随之不可用。
两条路径对照起来:Remote Control 是"本机出站连接 → Anthropic 服务器 → 远端网页或 App",靠环境注册、轮询和 JWT 维系;IDE 直连是"本机回环 + 锁文件发现 + MCP 协议",靠锁文件里的令牌维系。排查问题时也据此分流:"IDE 里看不到 diff"先看 ~/.claude/ide/ 下有没有锁文件、/ide 能否连上;"手机上看不到会话"才去看 Remote Control 的状态提示和登录、开关条件。
12.10 小结
Bridge(Remote Control)是 Claude Code 让本机会话可以从任意设备接管的关键基础设施。本章深入剖析了它的多层架构:
传输层提供了 v1(WebSocket + POST)和 v2(SSE + CCRClient)两种协议,统一抽象在 ReplBridgeTransport 接口后面,使上层代码对传输细节无感知。
会话层管理环境注册、会话创建、多会话并发、worktree 隔离和超时看门狗,将"网页或 App 上的一个会话 = 本机的一个执行单元"映射到可靠的进程管理。
认证层构建了由 OAuth 令牌、可信设备令牌和 session-ingress JWT 组成的信任链,通过主动刷新调度器保证长时间运行的会话不会因令牌过期而中断。
容错层实现了双轨指数退避、系统休眠检测、容量唤醒信号和多阶段优雅关闭,使 Bridge 在网络波动、系统休眠、进程崩溃等异常场景下都能可靠恢复。
权限层通过 control_request/control_response 协议打通了子进程与远端 UI 之间的权限决策通道,确保即使在远程执行场景下,用户仍然对工具操作拥有完整的控制权。
Bridge 的设计哲学可以用一句话概括:让 CLI 的能力完整地穿透到任何远端界面,同时不损失安全性、可靠性和可观测性。这种设计使得 Claude Code 不需要为每种远端界面编写适配层,只需维护一个高质量的 Bridge 核心,就能把终端中的全部能力延伸到浏览器和手机上。IDE 集成则是另一条本机直连的路径(§12.9.7),两者互不依赖。
"一个核心 + 多个入口"的架构模式在 GitHub Actions(一个 runner 核 + 多平台 workflow)、Docker(一个 OCI 标准 + 多种客户端)、Language Server Protocol(一个 LSP + 多种编辑器)等基础设施里广泛出现。Claude Code 的 Bridge 把这种思路搬到 AI Agent 领域——网页和手机 App 已经是它的两个入口,其他客户端只要会说同一套会话协议,也能接入。
下一章我们从"把 Claude Code 接到远端界面"转向"把 Claude Code 接到语言智能"——第 13 章讲 LSP 与语言服务:Bridge 让用户在任意设备上接管本机会话,LSP 则让模型看见代码背后的类型系统与调用图。