Book 第二十七章:Bridge 与 Remote 怎样把本机会话延展出去
第五部分:会话续航与治理

第二十七章:Bridge 与 Remote 怎样把本机会话延展出去

看 Bridge、remote attach 和 environment host 怎样把本地会话延展成远端 runtime。

1. 为什么这一块必须单独拆

Claude Code 里凡是带 remotebridgesession ingressCCR 这些词的代码,很容易第一眼看花。

因为它不是一个东西。

至少要分清三套彼此相邻、但职责完全不同的子系统:

  1. Standalone Bridge claude remote-control 启起来之后,本机变成一个可被远端调度的环境。
  2. REPL Bridge 当前本地 REPL 会话被“挂出去”,让远端页面或服务可以接入这场会话。
  3. Remote Session Frontend 当前本地 REPL 反过来去连接一个已经存在的远端 session,当 viewer 或控制端。

如果不先把这三层拆开,后面很容易把这些概念混成一团:

  • environment 和 session
  • bridge server 和 remote viewer
  • Environments API 和 Sessions API
  • env-based bridge 和 env-less bridge
  • bridge poll loop 和 session websocket subscribe

而这部分对你以后做 C# 版又特别值钱,因为它已经不是“本地 CLI agent”了,而是在往:

可远程接入、可远程调度、可恢复、可桥接权限流的会话 runtime

这个方向演化。

相关源码锚点:

  • src/bridge/bridgeMain.ts
  • src/bridge/bridgePointer.ts
  • src/bridge/types.ts
  • src/bridge/workSecret.ts
  • src/bridge/sessionRunner.ts
  • src/bridge/createSession.ts
  • src/bridge/initReplBridge.ts
  • src/bridge/replBridge.ts
  • src/bridge/remoteBridgeCore.ts
  • src/remote/RemoteSessionManager.ts
  • src/remote/SessionsWebSocket.ts
  • src/remote/remotePermissionBridge.ts
  • src/cli/remoteIO.ts

2. 先说结论

我对这块的判断是:

Claude Code 的“remote”不是一条协议,而是一整组围绕 session 远程化展开的运行时分层。

更具体一点说:

  1. bridgeMain.ts 代表的是“本机作为远端可调度环境”的 daemon/runtime。
  2. initReplBridge.ts + replBridge.ts + remoteBridgeCore.ts 代表的是“当前 REPL 会话如何被挂出去”的桥接层。
  3. RemoteSessionManager.ts + SessionsWebSocket.ts 代表的是“本地 UI 如何附着到一个远端 session”的前端层。
  4. remoteIO.ts 代表的是“headless / SDK 模式下如何把远端 session ingress 接成流式 IO”的适配层。

这里面最值钱的设计,不是某个 API 调用,而是下面这几个观念:

  • environment 生命周期和 session 生命周期是分开的。
  • bridge child 不是内嵌解释器,而是被父进程监管的普通 Claude headless 子进程。
  • resume 不是只靠 transcript,还专门多了一层 bridge pointer。
  • 远端权限请求不会另起一套 UI 协议,而是会被重新投影回本地已有的 tool permission 抽象。
  • REPL bridge 正在从老的 environment-dispatch 形态,往更直接的 code-session attach 形态演化。

3. 总体结构图

flowchart TD
    A["本地 Claude Code 进程"] --> B{"进入哪种 remote 形态?"}

    B -- "remote-control / rc" --> C["Standalone Bridge<br/>bridgeMain.ts"]
    C --> D["注册 Environment"]
    D --> E["创建或恢复 Session"]
    E --> F["pollForWork / ack / heartbeat"]
    F --> G["spawn child Claude --print"]
    G --> H["session ingress / CCR transport"]

    B -- "当前 REPL 被挂出去" --> I["REPL Bridge<br/>initReplBridge.ts"]
    I --> J{"env-based 还是 env-less"}
    J -- "env-based" --> K["replBridge.ts<br/>environment + poll + transport"]
    J -- "env-less" --> L["remoteBridgeCore.ts<br/>直接绑定 code session"]

    B -- "附着到现有远端 session" --> M["Remote Session Frontend<br/>RemoteSessionManager.ts"]
    M --> N["SessionsWebSocket.ts<br/>接收事件"]
    M --> O["HTTP sendMessage / permission response"]
    N --> P["remotePermissionBridge.ts<br/>投影成本地权限 UI"]

    H --> Q["RemoteIO / SDK streaming"]
    L --> Q
    O --> R["REPL / Headless 宿主"]
    P --> R

这张图最关键的一点是:

Bridge 是“把本地运行时暴露出去”,Remote Frontend 是“把远端运行时接进来”。

两边看起来都叫 remote,但方向是反的。

4. Standalone Bridge:本机变成可远程调度环境

源码锚点:

  • src/bridge/bridgeMain.ts
  • src/bridge/types.ts
  • src/bridge/createSession.ts
  • src/bridge/sessionRunner.ts
  • src/bridge/workSecret.ts

claude remote-control 这条线的本质不是“启动一个远端会话客户端”,而是:

启动一个本地环境守护进程,让服务端能把 work item 派发到这台机器上执行。

4.1 它管理的是两层对象

第一层是 environment

它代表“这台机器上的一个可调度执行环境”,通过 registerBridgeEnvironment(...) 注册,拿到:

  • environment_id
  • environment_secret

第二层是 session

它代表“在这个环境里跑起来的一场 Claude 会话”,通过 createBridgeSession(...) 建出来,走的是 Sessions API。

这个区分很重要,因为后面很多行为都建立在这两个生命周期分离之上:

  • environment 可以长活
  • session 可以一个个创建、结束、归档
  • resume 有时候要复用 session,有时候只复用 environment

4.2 它有三种 spawn mode

src/bridge/types.ts 里把 SpawnMode 说得很清楚:

  • single-session 一个 cwd 里只跑一个 session,session 结束后 bridge 也会跟着停。
  • same-dir bridge 常驻,多个 session 共用当前目录。
  • worktree bridge 常驻,但每个 session 都在独立 git worktree 里执行。

这背后的设计思路很实在:

  • 有的人就是想把当前项目临时挂成一个可继续的远端 session
  • 有的人要把它当多任务环境池
  • 有的人要多 session,但不能互相污染工作区

所以它没有硬写死成一种 remote 模式,而是把“隔离策略”上升成 bridge runtime 自己的配置维度。

4.3 它的主循环不是消息循环,而是 work dispatch loop

bridgeMain.ts 的核心不是 websocket 聊天,而是:

  1. 注册环境
  2. 创建或恢复 session
  3. 进入 pollForWork(...)
  4. 拿到 work item 后 ack
  5. 解出 work.secret
  6. 决定 transport 和 spawn 参数
  7. 启动子 Claude 进程
  8. 对活跃 work 做 heartbeat
  9. 在会话结束后回收 slot、继续等待下一批 work

也就是说,这一层更像 job runner / environment host,而不是会话 UI。

4.4 work.secret 是桥接协议交接点

decodeWorkSecret(...) 做的事情很关键。

它把服务端派发下来的 secret 解码成真正的运行参数,包括:

  • session_ingress_token
  • api_base_url
  • claude_code_args
  • mcp_config
  • environment_variables
  • use_code_sessions

这说明 bridge poll loop 和真正的 child runtime 之间,不是靠全局状态硬连,而是靠一个显式协议对象交接。

它还顺手暴露了一个很重要的事实:

transport 选择不是纯本地决定的,服务端也能通过 use_code_sessions 把 child 引到另一套 transport 上。

4.5 child 不是特殊 runtime,而是普通 headless Claude 子进程

sessionRunner.ts 非常值钱,因为它把一个常见误解彻底澄清了。

Bridge 并不是把 query.ts 嵌进 bridgeMain.ts 自己跑。

它做的是:

  • spawn(...) 一个正常的 Claude CLI 子进程
  • 参数里带上:
    • --print
    • --sdk-url
    • --session-id
    • --input-format stream-json
    • --output-format stream-json
    • --replay-user-messages
  • 再配上一组 bridge 专用环境变量

也就是说:

Bridge 是“父进程监管 + 子进程执行”的组合,不是“一个大进程里塞两套运行模式”。

这个设计很成熟,因为它天然获得了这些好处:

  • child session 崩了不会直接把 bridge 守护进程拖死
  • 父进程更容易做 session timeout 和 slot 回收
  • transport 细节能靠参数和 env 注入,不用把 bridge 和 query loop 强绑定

4.6 它会主动做 heartbeat 和 token 恢复

Standalone Bridge 不是“拉到任务就完事”,它还会持续维持活跃 work 的租约。

heartbeatActiveWorkItems() 会拿 session ingress token 去续命。

如果服务器回了 401/403,它不会立刻把任务判死,而是会:

  • reconnectSession(...)
  • 让服务端重新派发 work
  • 拿新的 token 继续接

这就是典型的“长活远端 runtime”思维,而不是一次性 CLI 思维。

4.7 shutdown 也有专门的 resumable 语义

bridge 退出时并不是无脑清理一切。

正常情况下它会:

  • 停掉活跃 session
  • 等清理完成
  • 归档 session
  • 注销 environment

但在 single-session 模式下,如果它认为当前 session 还适合继续恢复,而且这次退出不是 fatal 的,它会故意:

  • 跳过 archive
  • 跳过 deregister environment

目的就是让后面的 claude remote-control --continue 能回来接着跑。

这个选择说明:

single-session bridge 本质上是“可暂停/可恢复的远端附着会话”,不是单纯 daemon。

5. bridge-pointer.json:resume 元数据,不是 transcript

源码锚点:

  • src/bridge/bridgePointer.ts

这一小块很容易被忽略,但其实特别值钱。

Claude Code 对 bridge resume 没有只靠 transcript 或 session 列表查找,而是单独维护了一个:

  • bridge-pointer.json

里面记的是:

  • sessionId
  • environmentId
  • source

而且它不是长期真相,只是一个短时恢复指针:

  • 用文件 mtime 作为 freshness 基准
  • TTL 默认 4 小时
  • 能跨 worktree 扫描,找最近那一份

这说明它的定位非常明确:

pointer 不是历史记录,而是“下一次 resume 最值得先试的恢复入口”。

对 C# 版很有参考价值,因为这其实是在说:

  • session journal 适合存事实
  • resume hint 适合存捷径

两者不要混在一起。

6. REPL Bridge:把当前本地会话挂出去

源码锚点:

  • src/bridge/initReplBridge.ts
  • src/bridge/replBridge.ts
  • src/bridge/remoteBridgeCore.ts

REPL bridge 和 standalone bridge 很像,但不是一回事。

它不是守护进程式的“环境池”,而是:

围绕当前交互会话做桥接,让远端能接入这场正在发生的会话。

6.1 initReplBridge.ts 是桥接 bootstrap 包装层

initReplBridge.ts 先做的不是 transport,而是大量上层准备:

  • 运行时 gate 检查
  • OAuth 检查和刷新
  • 组织策略 allow_remote_control 检查
  • dead token 退避
  • 标题推导
  • git 上下文收集
  • worker type 推导

这个文件很值钱,因为它把桥接 core 和“把一场普通 REPL 会话转成可远控会话”的上层准备明确分开了。

换句话说:

bridge core 不读 UI 启动状态,bootstrap wrapper 才读。

6.2 它有两条 REPL bridge 路线

这一块最容易看混。

REPL bridge 现在至少有两条路:

  1. env-based bridgereplBridge.ts,仍然有 environment 注册、poll、pointer、teardown 这一整套。
  2. env-less bridgeremoteBridgeCore.ts,直接围绕 code session 建桥,不再经过 Environments API。

这里要特别强调一件事:

env-less bridge 不等于 CCR v2 transport。

这两个维度不是一回事。

  • env-less 说的是“要不要经过 Environments API 这一层”
  • CCR v2 说的是“session ingress / streaming transport 用哪套协议”

Claude Code 源码里已经明显看得出来,它在把这两层慢慢拆开。

6.3 env-based REPL bridge 还是“环境 + 会话 + poll”模型

replBridge.ts 的核心仍然是:

  • 读 pointer
  • 创建或复用 environment
  • 创建 session
  • 写 pointer
  • 启动 work poll loop
  • 为被派发的 work 建 transport
  • 出错时按错误类型选择 reconnect 还是重建

也就是说,老的 bridge 语义在 REPL 路线里还在。

6.4 env-less bridge 说明服务器能力在变

remoteBridgeCore.ts 的注释已经把意图说得很直接了:

它是 env-less remote control bridge core

这条线的大意是:

  1. 先建 /v1/code/sessions
  2. 再调 /v1/code/sessions/{id}/bridge
  3. 拿到:
    • worker_jwt
    • expires_in
    • api_base_url
    • worker_epoch
  4. 用这些参数建立 createV2ReplTransport(...)
  5. 定时刷新 bridge token
  6. 401 时重建 transport

这说明什么?

说明 Claude Code 的 remote 控制面已经在往更直接的 code-session attach 演化。

以前是:

  • environment register
  • poll
  • ack
  • heartbeat

现在 REPL 这条线上,服务端能力足够时,可以直接变成:

  • 建一个 code session
  • 给它挂一个 bridge
  • 靠 bridge token 和 transport 保持连通

这对你以后做 C# 版特别值钱,因为它提醒了一件事:

不要把“远端控制”死绑在某一代服务器调度协议上,最好把控制面和 transport 面拆成独立抽象。

7. Remote Session Frontend:本地 REPL 去附着远端 session

源码锚点:

  • src/remote/RemoteSessionManager.ts
  • src/remote/SessionsWebSocket.ts
  • src/remote/remotePermissionBridge.ts
  • src/screens/REPL.tsx

这一层和前面两层方向相反。

前面两层是把本地会话暴露出去,这一层是:

让本地 REPL 变成某个远端 session 的 viewer / controller。

7.1 RemoteSessionManager 很薄,但边界很清楚

它主要干三件事:

  1. 建 websocket 订阅,持续接事件
  2. 发 HTTP 请求把用户消息送到远端 session
  3. 处理远端 permission request 和本地响应

这说明 Claude Code 没把 remote attach 客户端做成一个第二套 query runtime。

它更像一个:

session transport adapter + permission bridge

7.2 SessionsWebSocket 是 session subscribe 通道,不是 bridge poll 通道

这也是特别容易混的一点。

SessionsWebSocket.ts 走的是:

  • /v1/sessions/ws/{sessionId}/subscribe

它负责:

  • 收远端事件
  • 自动重连
  • 处理 ping / keepalive
  • 特判 session-not-found 这类可能是临时状态的错误

bridgeMain.ts 那套是:

  • environment register
  • poll for work
  • ack / heartbeat

两边都跟“远端”有关,但协议层完全不是一回事。

一个是环境调度,一个是 session 订阅。

7.3 远端权限请求会被投影成本地 tool permission UI

remotePermissionBridge.ts 很能体现 Claude Code 的工程味。

它没有为了 remote attach 单独再设计一套权限弹窗协议,而是会把远端权限请求重新包装成:

  • synthetic AssistantMessage
  • synthetic tool stub

然后继续复用本地现成的 ToolUseConfirm、工具展示和审批流。

这个做法很值钱,因为它避免了两套权限 UI 分叉:

  • 本地工具调用走一套
  • 远端权限请求走另一套

它选择的是:

把远端事件重新投影回本地已有抽象。

8. RemoteIO:headless / SDK 路径下的流式远端适配器

源码锚点:

  • src/cli/remoteIO.ts

这一层不该漏掉。

因为 Claude Code 的 remote 不只在 REPL 里用,headless / SDK 模式也会走到远端 transport。

RemoteIO 的定位不是“远端 query 引擎”,而是:

一个双向流式 IO 适配器。

它会根据 URL 和 token 情况选择 transport:

  • 传统 bridge/session ingress 路线
  • CCR v2 路线

它还会额外处理几件事情:

  • 内部 reader / writer 注册
  • delivery / metadata / state 事件监听
  • bridge-only keep_alive
  • 把 bridge permission request 通过 stdout 吐给父进程

也就是说:

headless remote 不是直接把模型循环搬到网络上,而是把 stdin/stdout 风格的执行面接到了远端 session transport 上。

9. Claude Code 在这块做了哪些“额外处理”

如果只看表面,很容易把这部分理解成“就是加了点 remote API 调用”。

其实源码里额外做了很多成熟 runtime 才会做的处理。

9.1 单独维护 bridge pointer

它没有把 resumability 硬塞进 transcript,而是多维护一层短时恢复指针。

9.2 transport 可以切换、重建、热恢复

不管是 bridgeMain.ts 还是 remoteBridgeCore.ts,都不是一条链断了就彻底失败,而是会:

  • 尝试 reconnect
  • 失效 token 后重取
  • 401 时重建 transport
  • 环境丢失后重建 environment

9.3 权限请求会跨 runtime 重投影

远端权限流不会暴露成完全新的 UI 协议,而是重新映射回本地 tool permission 抽象。

9.4 会按场景选隔离策略

不同 remote 场景下,Claude Code 会明确选择:

  • 单 session
  • 同目录多 session
  • 独立 worktree 多 session

这说明它把“工作区污染风险”当成 remote runtime 的一等公民问题。

9.5 child session 由父进程监管,而不是共享内存态

这样更容易做:

  • watchdog
  • timeout
  • slot 回收
  • 心跳治理
  • 崩溃隔离

9.6 remote attach 复用了本地 REPL 壳

REPL.tsx 不会为了 remote session 另造一套界面,而是把远端收发、历史懒加载、viewer-only 等状态接到同一个会话壳上。

这对后面做 C# 版也很有帮助,因为它说明:

“本地会话”和“远端附着会话”最好共享一套上层会话投影模型。

10. 对 C# 版最有价值的抽象

如果把这一块翻成 C#,我建议不要叫一个大而全的 RemoteManager

更合适的拆法是:

10.1 Bridge 主机侧

  • IRemoteEnvironmentHost
  • EnvironmentRegistrationService
  • WorkDispatchLoop
  • BridgeSessionSpawner
  • BridgeHeartbeatService
  • BridgeResumePointerStore

10.2 REPL 挂出侧

  • IReplBridgeBootstrapper
  • EnvBasedReplBridge
  • EnvLessReplBridge
  • BridgeTitleDeriver
  • BridgePolicyGate

10.3 远端附着侧

  • IRemoteSessionClient
  • SessionSubscribeSocket
  • RemoteMessageSender
  • RemotePermissionProjector
  • RemoteConversationProjection

10.4 transport 层

  • ISessionIngressTransport
  • SessionIngressTransportFactory
  • CcrV2Transport
  • LegacyBridgeTransport
  • TransportRefreshCoordinator

10.5 生命周期与恢复

  • BridgeResumePointer
  • SessionRecoveryHintStore
  • EnvironmentLease
  • RemoteSessionAttachmentState

这里最值得照抄的原则是:

把 host、attach client、transport、resume hint、permission projection 分成不同层,不要揉成一个 RemoteService。

11. 最后一句判断

如果只从“功能列表”看,Bridge/Remote 这一块像是在做远控。

但从运行时设计看,它真正展示出来的是另一件事:

Claude Code 已经不只是本地 agent CLI,它在朝“可远程调度、可附着、可恢复、可跨宿主投影的会话系统”演化。

这块源码对做 C# 版最大的价值,不是教你怎么接某个 API,而是提醒你:

  • session 不一定只在本地活
  • transport 不一定只服务一种宿主
  • resume 不一定只靠 transcript
  • 权限 UI 不一定只能服务本地工具
  • 远端控制和远端附着最好从第一天就分成两套模型