Hi,大家好,欢迎来到维元码簿。
本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,专注于 V2 无环境直连架构------理解 Anthropic 如何用"减法重构"砍掉 V1 的六步环境生命周期,实现从 Web 端到本地 Agent 的零轮询延迟直连。
本文聚焦一件事:Claude Code 的 V2 架构如何在不去掉传输协议的前提下,砍掉 Environments API 这一整层------register、poll、ack、heartbeat、stop、deregister 全部消失,替换为三步直连:create session → fetch JWT → connect transport。
读完全文,你将能回答这几个问题:
- V2 到底"减"掉了什么? 答案:Environments API 的整个 poll/dispatch 层------六步生命周期变成三步。但不是所有的 V1 都"慢"------V1 也可以用 V2 的传输协议。
- FlushGate 是什么?为什么 transport 重建需要它? 一个不到 70 行的状态机,却在 JWT 刷新时保护了消息不丢失、不乱序。
- Epoch(纪元)为什么是 V2 的核心发明? 每个
/bridge调用递增 epoch,旧 epoch 的心跳自动失效------不需要显式的"取消旧连接"操作。 - 笔记本电脑合盖几小时后打开,JWT 过期了怎么办? 答案:
authRecoveryInFlight布尔标志防止双重刷新------scheduler 和 SSE 401 几乎同时触发,但只有一条路径执行。
阅读提示 :如果你想了解远程模式的完整图景,推荐先读本系列的远程模式专栏完整版。本文聚焦 V2 无环境直连架构,可独立阅读,不依赖其他姊妹篇上下文。
本篇覆盖的源码范围
| 模块 | 核心文件 | 核心代码行 | 职责 |
|---|---|---|---|
| V2 无环境核心 | src/bridge/remoteBridgeCore.ts |
L1-1009 | 跳过 Environments API 的直连架构主入口 |
| 会话 API 客户端 | src/bridge/codeSessionApi.ts |
L1-169 | createCodeSession() + fetchRemoteCredentials() |
| 传输层适配 | src/bridge/replBridgeTransport.ts |
L1-371 | createV2ReplTransport():SSETransport + CCRClient 组合 |
| 写栅栏 | src/bridge/flushGate.ts |
L1-72 | transport 重建期间的消息排队状态机 |
| JWT 刷新 | src/bridge/jwtUtils.ts |
~L1-200 | createTokenRefreshScheduler() 提前 5 分钟调度 |
| 环境变量配置 | src/bridge/envLessBridgeConfig.ts |
~L1-150 | V2 专属配置:超时、重试、心跳参数 |
| 准入控制 | src/bridge/bridgeEnabled.ts |
L1-203 | isEnvLessBridgeEnabled() 特性开关 |
前情提要:V1 的代价------轮询延迟是分布式系统的固有代价
在 V1 篇中,我们拆解了六步生命周期------register → poll → ack → heartbeat → stop → deregister。这套架构的优点是成熟稳定------每一步解决一个分布式系统经典问题。但代价是轮询延迟。
问题空间:V1 的核心瓶颈不在代码,而在架构模型本身。用户发消息 → 服务端存到队列 → Bridge 轮询拿到 → 确认 → 孵化子进程------这中间有多少个网络往返?即使长轮询最快返回,"最快"不等于"瞬时"。用户点完发送按钮后看着聊天界面"正在连接..."的那几秒,就是 V1 的产品天花板。
约束条件:不能推翻重来。V1 已经部署在生产环境,有活跃用户;CCR v2 传输协议(SSE + CCRClient)已经设计好,工作稳定;OAuth 认证体系不能改------改 OAuth 会影响所有非远程控制功能。
方案思路 :不做重写,做减法重构。砍掉 Environments API 这一整层------register、poll、ack、heartbeat、stop、deregister 全部消失,替换为三步直连:create session → fetch JWT → connect transport。同时保留 CCR v2 传输协议------V1 也可以启用 CCR v2 传输,只是还得走 Environments API 那一层。V2 额外做了"去环境化"。
但在进入深度拆解之前,有一个关键澄清必须前置。remoteBridgeCore.ts 顶部有一段注释精确总结了 V2 的边界:
"Env-less" = no Environments API layer. Distinct from "CCR v2" (the /worker/* transport protocol) --- the env-based path can also use CCR v2 transport. This file is about removing the poll/dispatch layer.
V2 砍掉的是 poll/dispatch 层,不是传输协议。 这就是本文要拆解的。
第一章:三步替代六步------V2 的连接建立
V2 的主入口是 remoteBridgeCore.ts 的 initEnvLessBridgeCore()(1009 行)。源码用编号注释(── 1. 到 ── 10.)标注了完整流程。我们先看前三步------连接建立的核心。
1.1 Step 1:创建会话(createCodeSession)
typescript
// codeSessionApi.ts
POST /v1/code/sessions
Headers: Authorization: Bearer {OAuth token}
Body: { title: "Bug fix for auth", bridge: {} }
→ Response: { session: { id: "cse_abc123" } }
注意这是 /v1/code/sessions ,不是 V1 的 /v1/sessions。不需要 environment_id------因为根本没有环境注册。也不需要 session_context(Git 仓库、分支信息)------V2 的服务端从 OAuth 令牌推导这些元数据。
bridge: {} 是一个正向信号 ,告诉服务端这个会话走 Bridge 路径。如果不传(或传 environment_id: ""),服务端现在会返回 400。BridgeRunner 目前是空消息,作为未来 bridge-specific 选项的占位符。
返回的 session ID 是 cse_ 前缀(Code Session),区别于 V1 的 session_ 前缀。客户端有一个兼容 shim(sessionIdCompat.ts)将 cse_* 转换为 session_*,以便 claude.ai 前端路由正常工作。
1.2 Step 2:获取 Bridge 凭证(fetchRemoteCredentials)
typescript
// codeSessionApi.ts
POST /v1/code/sessions/{sessionId}/bridge
Headers: Authorization: Bearer {OAuth token}
→ Response: {
worker_jwt: "eyJ...", // Worker JWT(不透明,不解码)
api_base_url: "https://...", // API 基础地址
expires_in: 3600, // JWT 有效期(秒)
worker_epoch: 42 // 工作纪元(整数,可能 int64 字符串)
}
/bridge 端点用 OAuth 令牌换一个 worker JWT。 这个 JWT 的声明中包含 session_id 和 worker 角色------OAuth 令牌没有这两个声明,所以不能直接用于 worker 端点。
每个 /bridge 调用递增 epoch(服务端计数器)------这是 V2 架构的核心设计。Epoch 使得 JWT 刷新、transport 重建、心跳保活三件事在服务端形成了统一的因果链。
关于 worker_epoch 的类型处理:protojson 序列化 int64 为字符串(避免 JS 精度丢失),但 Go 也可能返回数字。源码做了兼容处理:
typescript
const rawEpoch = data.worker_epoch
const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
if (!Number.isFinite(epoch) || !Number.isSafeInteger(epoch)) { return null }
1.3 Step 3:建立传输层(createV2ReplTransport)
typescript
// replBridgeTransport.ts
transport = await createV2ReplTransport({
sessionUrl: buildCCRv2SdkUrl(credentials.api_base_url, sessionId),
ingressToken: credentials.worker_jwt,
sessionId,
epoch: credentials.worker_epoch, // ← 从 /bridge 响应传入
heartbeatIntervalMs: cfg.heartbeat_interval_ms,
heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
getAuthToken: () => credentials.worker_jwt, // 闭包捕获,不写 env var
})
V2 传输层由两部分组成:
- SSETransport(读):服务端 → 客户端的 SSE 流,接收入站消息和控制指令
- CCRClient(写):客户端 → 服务端的 HTTP POST,发送出站消息 + 心跳 + 状态汇报
一个重要的安全设计:getAuthToken 闭包。 源码注释解释了为什么不用环境变量:
Per-instance closure --- keeps the worker JWT out of
process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN, whichmcp/client.tsreads ungatedly and would otherwise send to user-configured ws/http MCP servers.
如果 JWT 写入 CLAUDE_CODE_SESSION_ACCESS_TOKEN 环境变量,用户配置的 MCP 服务器可能通过 WebSocket/HTTP 读取到它------这是一个安全泄漏。闭包捕获确保 JWT 只在 V2 transport 内部使用。
connect() 的执行顺序 也经过精心编排。sse.connect() 是 fire-and-forget(打开读流),ccr.initialize(epoch) 注册 worker。onConnect 回调在 ccr.initialize() resolve 后触发------此时写路径就绪,可以开始发送消息。SSE 流在 ~30ms 内打开并开始推送入站事件。
第二章:10 步完整流程------为什么是 10 步
前三步(创建会话、获取凭证、建立传输)是 V2 的"连接建立三步曲"------替代了 V1 的 register → poll → ack。但连接建立只是开始。一个可靠的远程会话还需要 JWT 刷新、消息去重、传输重建、auth 恢复、优雅关闭------这些构成了剩余七步。
10 步不是"多",而是 V1 把很多事交给了服务端(心跳、租约、重派),V2 把它们拿回了客户端。 客户端的复杂度增加了,但换来的是零轮询延迟。下面是源码注释中标注的完整 10 步拆解:

2.1 步骤 4:状态初始化
typescript
// Echo dedup sets
const recentPostedUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size) // 容量 2000
const initialMessageUUIDs = new Set<string>() // 初始消息 UUID(无界回退)
const recentInboundUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)
// FlushGate:在历史 flush 期间排队新消息
const flushGate = new FlushGate<Message>()
// 控制标志
let initialFlushDone = false
let tornDown = false
let authRecoveryInFlight = false // 防止双重 JWT 刷新
双重去重 是防御深度设计。recentPostedUUIDs 是 2000 容量环形缓冲区------可能因大量实时写入而淘汰初始消息 UUID。initialMessageUUIDs 是无界 Set,作为回退------即使环形缓冲区淘汰了初始 UUID,去重依然有效。
2.2 步骤 5:JWT 刷新调度器
typescript
const refresh = createTokenRefreshScheduler({
refreshBufferMs: cfg.token_refresh_buffer_ms, // 默认 300000(5 分钟)
getAccessToken: async () => {
const stale = getAccessToken()
if (onAuth401) await onAuth401(stale ?? '')
return getAccessToken() ?? stale
},
onRefresh: (sid, oauthToken) => { /* 见下文 */ },
label: 'remote',
})
refresh.scheduleFromExpiresIn(sessionId, credentials.expires_in)
JWT 刷新不是在过期时触发,而是提前 5 分钟。 这给了足够窗口完成刷新------如果拖到过期那一刻,SSE 流会收到 401 并触发恢复路径(两个路径可能竞争,后面讲)。
getAccessToken 闭包无条件刷新 OAuth (不是检查过期)------因为 getAccessToken() 返回过期令牌作为非空字符串(不检查 expiresAt),truthiness 不代表有效性。传递旧令牌给 onAuth401 是为了让 handleOAuth401Error 做 keychain 比较(检测并行刷新)。
2.3 步骤 6:绑定回调(wireTransportCallbacks)
这是最长的步骤,包含三个核心回调:
onConnect :连接建立后,flush 历史消息到服务端,drain FlushGate 中的排队消息,通知状态变为 connected。
onData :收到入站消息时,调用 handleIngressMessage() 路由分发(control_response → 权限确认、control_request → 控制指令、SDKMessage → 用户输入)。
onClose :传输关闭时,区分可恢复(401 → recoverFromAuthFailure)和不可恢复(4090 epoch 冲突、4091 init 失败、4092 重连预算耗尽)两种关闭。
2.4 步骤 7:Transport 重建
rebuildTransport() 是 V2 架构最精妙的设计。在拆解它之前,必须先理解 FlushGate。
FlushGate:不到 70 行的状态机
typescript
// 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 {
if (!this._active) return false // 不活跃 → 调用者应直接发送
this._pending.push(...items)
return true // 活跃 → 已排队
}
drop(): number { this._active = false; const c = this._pending.length; this._pending.length = 0; return c }
deactivate(): void { this._active = false } // 保留排队项,不清空
}
FlushGate 只有两个状态:active (消息排队)和 inactive(消息直接发送)。它的生命周期:
start() → 开始排队
enqueue() 返回 true(消息被排队)
enqueue() 返回 true
end() → 停止排队,返回排队消息(调用者负责发送)
enqueue() 返回 false(消息直接发送)
为什么 transport 重建需要 FlushGate? 因为 /bridge 调用后旧 transport 的 epoch 已失效------旧 epoch 的 CCRClient 心跳会在 20 秒内收到 409。如果没有 FlushGate,writeMessages() 添加 UUID 到 recentPostedUUIDs(标记已发送),然后 writeBatch 静默失败(uploader 已关闭)------消息永久丢失。
有了 FlushGate,重建期间的写入被排队。重建完成后 drainFlushGate() 一次性通过新 transport 发送所有排队消息。
Transport 重建的完整流程

typescript
async function rebuildTransport(fresh: RemoteCredentials, cause: ConnectCause) {
connectCause = cause
// ① 开启写栅栏:后续写入排队
flushGate.start()
try {
// ② 记录当前 SSE 序列号(避免历史重放)
const seq = transport.getLastSequenceNum()
// ③ 关闭旧 transport
transport.close()
// ④ 创建新 transport(携带序列号 + 新 JWT + 新 epoch)
transport = await createV2ReplTransport({
sessionUrl: buildCCRv2SdkUrl(fresh.api_base_url, sessionId),
ingressToken: fresh.worker_jwt,
sessionId,
epoch: fresh.worker_epoch,
initialSequenceNum: seq, // ← 关键:从旧序列号继续
heartbeatIntervalMs: cfg.heartbeat_interval_ms,
heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
getAuthToken: () => fresh.worker_jwt,
outboundOnly,
})
// ⑤ 重新绑定回调
wireTransportCallbacks()
// ⑥ 建立连接
transport.connect()
// ⑦ 重新调度下次刷新
refresh.scheduleFromExpiresIn(sessionId, fresh.expires_in)
// ⑧ 排空 FlushGate------所有排队消息通过新 transport 发送
drainFlushGate()
} finally {
flushGate.drop() // 失败路径:丢弃排队消息(transport 仍然不可用)
}
}
第 ② 步传递序列号是整个机制的精华。 如果不传 initialSequenceNum,服务端从 seq 0 开始重放整个会话历史------客户端会收到大量重复消息。传了序列号,服务端从上次断开的地方继续推送。源码中的注释说得精确:
Passed to the new SSETransport so its first connect() sends from_sequence_num / Last-Event-ID and the server resumes from where the old stream left off. Without this, every transport swap asks the server to replay the entire session history from seq 0.
第 ⑧ 步的 drainFlushGate 在 ccr.initialize() resolve 之前运行。 transport.connect() 是 fire-and-forget------SSE 流和 CCR 初始化异步进行。但 SerialBatchEventUploader 内部的队列在初始化完成前会缓冲消息。所以 drain 在初始化之前运行也没问题------消息等待 uploader 就绪后自动发送。
2.5 步骤 8:401 恢复(recoverFromAuthFailure)
当 SSE 流收到 401(JWT 过期)时,setOnClose 触发 recoverFromAuthFailure():
typescript
async function recoverFromAuthFailure() {
// 双重刷新防护------setOnClose 和 onRefresh 可能同时触发
if (authRecoveryInFlight) return
authRecoveryInFlight = true
onStateChange?.('reconnecting', 'JWT expired --- refreshing')
try {
// 无条件刷新 OAuth
const stale = getAccessToken()
if (onAuth401) await onAuth401(stale ?? '')
const oauthToken = getAccessToken() ?? stale
// 获取新凭证(含新 JWT + 新 epoch)
const fresh = await fetchRemoteCredentials(sessionId, baseUrl, oauthToken, ...)
// 401 可能打断了初始 flush------writeBatch 已在关闭的 uploader 上
// 静默 no-op。重置 initialFlushDone,让新 onConnect 重新 flush。
initialFlushDone = false
// 重建 transport
await rebuildTransport(fresh, 'auth_401_recovery')
} finally {
authRecoveryInFlight = false
}
}
笔记本电脑唤醒是典型的边界场景。 笔记本合盖几小时 → 打开 → JWT 已过期。此时两个路径几乎同时触发:
- scheduler 的 proactive refresh (超时,
onRefresh回调) - SSE 的 401 onClose(服务端检测到过期 JWT)
源码用 authRecoveryInFlight 布尔标志 防止双重刷新------先拿到标志的路径执行,另一个看到标志已设置就跳过。更关键的是:如果两者都执行,会产生两个 /bridge 调用 → 两个 epoch → 第一个 rebuild 获得过时的 epoch → 409 错误。
源码注释精确描述了这个竞态:
Laptop wake fires both paths ~simultaneously. Claim the flag BEFORE the /bridge fetch so the other path skips entirely --- prevents double epoch bump (each /bridge call bumps; if both fetch, the first rebuild gets a stale epoch and 409s).
2.6 步骤 9:历史 flush 与 drain
连接建立后,onConnect 中调用 flushHistory(initialMessages):
typescript
async function flushHistory(msgs: Message[]) {
const eligible = msgs.filter(isEligibleBridgeMessage)
const capped = initialHistoryCap > 0 && eligible.length > initialHistoryCap
? eligible.slice(-initialHistoryCap) // 取最后 N 条
: eligible
const events = toSDKMessages(capped).map(m => ({ ...m, session_id: sessionId }))
await transport.writeBatch(events)
}
V2 始终创建新的服务端会话 (不像 V1 可以复用环境),所以不需要担心重复 flush。V1 的 previouslyFlushedUUIDs 机制在 V2 中不适用------那个 Set 跨 REPL enable/disable 周期持久化,会错误地抑制重新启用后的历史 flush。
容量上限 由 GrowthBook 配置 tengu_bridge_initial_history_cap 控制(默认 200 条)。slice(-cap) 取最后 N 条------最新的消息最重要。
2.7 步骤 10:Teardown(优雅关闭)
typescript
async function teardown() {
if (tornDown) return
tornDown = true
// ① 取消所有定时器
refresh.cancelAll()
clearTimeout(connectDeadline)
flushGate.drop()
// ② 在 archive 之前发送结果消息
// archive 是 HTTP POST(~100-500ms),给 uploader drain 留出窗口
transport.reportState('idle')
void transport.write(makeResultMessage(sessionId))
// ③ 归档会话
let status = await archiveSession(sessionId, baseUrl, token, orgUUID, ...)
// ④ archive 401 重试(笔记本电脑唤醒后 token 过期)
if (status === 401 && onAuth401) {
await onAuth401(token ?? '')
status = await archiveSession(...)
}
// ⑤ 关闭传输(必须在 archive 之后)
transport.close()
}
第 ②-⑤ 步的顺序是精心编排的。 源码注释解释了为什么 archiveSession 在 transport.close() 之前:
close() sets closed=true which interrupts drain at the next while-check, so close-before-archive drops the result.
如果先 close,uploader 的 drain 循环在下一次 while-check 时被打断------结果消息可能丢失。先 archive(HTTP POST,异步),在 archive 的 ~100-500ms 内 uploader 完成 drain,然后 close 安全执行。
第三章:Epoch------V2 的核心发明
Epoch 不是 V2 独有的(CCR v1 也有),但 V2 的环境去除让 epoch 承担了更重的协调职责。
Epoch 的三个关键属性:
- 单调递增 :每个
/bridge调用在服务端递增 epoch 计数器。旧 epoch 的心跳自动失效,不需要显式的"取消旧连接"。 - JWT 内嵌:worker JWT 包含当前 epoch。服务端验证 JWT 的同时检查 epoch------过期 JWT = 过期 epoch。
- 409 冲突检测 :旧 epoch 的 CCRClient 心跳在 20 秒内收到 409(epoch mismatch),触发
onEpochMismatch→ 关闭 transport → 通知 replBridge。
为什么 JWT 刷新必须重建整个 transport? 源码注释解释:
Schedule a callback 5min before expiry. On fire, re-fetch /bridge with OAuth → rebuild transport with fresh credentials. Each /bridge call bumps epoch server-side, so a JWT-only swap would leave the old CCRClient heartbeating with a stale epoch → 409 within 20s.
不是"刷新 JWT",而是"重建整个 transport + 新 JWT + 新 epoch"。 三者不可分割------只换 JWT 不换 epoch,心跳会 409;只换 epoch 不换 JWT,API 调用会 401。
第四章:CCR Mirror------只出不进的特殊模式
V2 架构还支持一种特殊模式:CCR Mirror (outboundOnly = true)。
typescript
if (feature('CCR_MIRROR') && outboundOnly) {
// SSE 流不打开,只激活 CCRClient 写路径
// 本地会话事件转发到 claude.ai 显示,但不接受远程控制
}
CCR Mirror 模式下,SSE 读流完全跳过------connect() 只调用 ccr.initialize(),不调用 sse.connect()。本地会话的所有事件通过 CCRClient 转发到 claude.ai 显示(像一面"镜子"),但用户不能从 claude.ai 发消息回来。
Control Request 的处理也不同。 在 outboundOnly 模式下,所有可变请求(interrupt、set_model、set_permission_mode、set_max_thinking_tokens)返回 error 而非假成功。只有 initialize 必须成功------否则服务端会杀掉连接。
本章小结
V2 架构的设计哲学可以归纳为三个关键词:减法、双工、容错。
- 减法:砍掉 Environments API 的六步生命周期,用三步直连替代。不是重写,是去掉不需要的抽象。
- 双工:SSE(读)+ CCRClient(写)分离,各自独立管理。写路径不依赖 SSE 流状态------即使 SSE 断开,CCRClient 也能继续发送。
- 容错 :JWT 提前 5 分钟刷新、FlushGate 保护重建期间的消息、
authRecoveryInFlight防止双重刷新、epoch 机制消除旧连接歧义。
V2 最值得学习的不是"用了什么新技术"------它用的 SSE、HTTP POST、JWT 都是成熟的 Web 技术。值得学习的是"如何把成熟技术组合成一个可靠的分布式系统":连接管理、认证轮换、消息去重、优雅降级------这些才是 1009 行代码真正在做的事。
系列导航:
本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,完整版见本系列远程模式专栏。
本系列其他子篇章覆盖了 V1 环境注册与轮询架构、鉴权链与会话生命周期、消息路由与传输层等命题,可独立阅读。
如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋