本文是《Claude Code 代码全景解析》系列第 50 篇,聚焦 Claude Code 的远程协作体系。从本地 CLI 到云端 CCR(Claude Code Remote)容器,从
--teleport跨设备迁移到claude ssh远程终端,我们将完整梳理这套让 AI Agent 突破单机边界、实现多端无缝协作的远程架构。
一、远程会话架构概览
Claude Code 的远程能力并非单一功能点,而是一个覆盖「连接管理—状态传输—环境恢复—跨端同步」的完整体系。它的设计目标是:让用户可以在任何设备上继续同一个 AI 编程会话,无论是在办公室的 MacBook 上开始,还是在通勤路上用手机端的 claude.ai 查看进度,抑或回到家中的 Linux 工作站上通过 --teleport 恢复会话。
1.1 remote/ 目录的职责划分
src/remote/ 目录是远程会话的核心控制层,包含三个关键模块:
| 文件 | 职责 | 规模 |
|---|---|---|
RemoteSessionManager.ts | 远程会话的生命周期管理:连接、消息收发、权限请求协调 | ~200 行 |
SessionsWebSocket.ts | WebSocket 传输层:连接建立、重连策略、心跳保活 | ~280 行 |
sdkMessageAdapter.ts | SDK 消息格式与内部 Message 类型的双向转换 | — |
remotePermissionBridge.ts | 远程权限请求的合成消息构造 | ~100 行 |
这三个模块的分层非常清晰:SessionsWebSocket 负责纯网络传输,RemoteSessionManager 负责业务逻辑编排,而 Adapter 和 Bridge 则处理协议转换。这种分层让代码既容易测试,也方便后续扩展新的传输协议。
1.2 整体架构图
flowchart TB
subgraph Local["本地 CLI"]
A[useDirectConnect Hook] --> B[RemoteSessionManager]
C[useSSHSession Hook] --> D[SSHSessionManager]
B --> E[SessionsWebSocket]
D --> F[SSH Child Process]
end
subgraph Network["网络传输层"]
E -->|WSS| G[api.anthropic.com]
F -->|SSH| H[Remote Host]
end
subgraph Remote["远程执行端"]
G --> I[CCR Container]
H --> I
I --> J[Bridge Poll Loop]
J --> K[replBridge.ts]
end
subgraph Cloud["云端服务"]
G --> L[Sessions API]
L --> M[Session Ingress]
L --> N[Environment Providers]
end
style Local fill:#e3f2fd
style Remote fill:#e8f5e9
style Cloud fill:#fff3e0从上图可以看出,Claude Code 的远程体系实际上支持两条并行的连接路径:
- WebSocket 路径:本地 CLI 作为客户端,通过
SessionsWebSocket连接到 Anthropic 的云端 Session 服务,与运行在云端的 CCR 容器交互。这是--remote模式的核心通路。 - SSH 路径:本地 CLI 通过 SSH 子进程连接到远程机器,在远程机器上启动另一个 Claude Code 实例,本地仅作为显示终端。这是
claude ssh模式的核心通路。
两条路径的上层抽象保持一致(isRemoteMode / sendMessage / cancelRequest / disconnect),因此 UI 层无需关心底层走的是哪条链路。
二、RemoteSessionManager:远程会话的业务大脑
RemoteSessionManager(src/remote/RemoteSessionManager.ts,第 61-260 行)是远程模式的业务控制中心。它的职责可以概括为四个字:收、发、权、断。
2.1 构造函数与配置模型
// src/remote/RemoteSessionManager.ts:33-56
export type RemoteSessionConfig = {
sessionId: string
getAccessToken: () => string
orgUuid: string
/** True if session was created with an initial prompt that's being processed */
hasInitialPrompt?: boolean
/**
* When true, this client is a pure viewer. Ctrl+C/Escape do NOT send
* interrupt to the remote agent; 60s reconnect timeout is disabled;
* session title is never updated. Used by `claude assistant`.
*/
viewerOnly?: boolean
}配置模型中值得关注的是 viewerOnly 标志。当该标志为 true 时,客户端退化为纯观察者:用户的 Ctrl+C 不会发送给远程 Agent,会话标题不会被更新,重连超时也被禁用。这是为 claude assistant 这类旁观工具设计的模式,确保观察者不会意外干扰正在运行的远程会话。
2.2 消息路由与权限处理
RemoteSessionManager 通过 handleMessage 方法实现消息路由,将 WebSocket 收到的消息分发到三个不同的处理路径:
// src/remote/RemoteSessionManager.ts:163-195
private handleMessage(
message:
| SDKMessage
| SDKControlRequest
| SDKControlResponse
| SDKControlCancelRequest,
): void {
// Handle control requests (permission prompts from CCR)
if (message.type === 'control_request') {
this.handleControlRequest(message)
return
}
// Handle control cancel requests
if (message.type === 'control_cancel_request') {
const { request_id } = message
const pendingRequest = this.pendingPermissionRequests.get(request_id)
this.pendingPermissionRequests.delete(request_id)
this.callbacks.onPermissionCancelled?.(request_id, pendingRequest?.tool_use_id)
return
}
// Handle control responses (acknowledgments)
if (message.type === 'control_response') {
logForDebugging('[RemoteSessionManager] Received control response')
return
}
// Forward SDK messages to callback
if (isSDKMessage(message)) {
this.callbacks.onMessage(message)
}
}这里体现了 Claude Code 远程协议的三类消息:
- SDKMessage:正常的会话消息流(用户输入、助手回复、工具结果等)。
- ControlRequest / ControlResponse:控制平面消息,用于工具权限请求和响应。
- ControlCancelRequest:服务器端取消正在等待的权限请求。
特别值得注意的是权限请求的处理。当远程 CCR 容器中的 Agent 需要执行敏感工具时,权限请求通过 WebSocket 发送到本地 CLI,由用户在本地的 TUI 中做出允许或拒绝的决策。这个决策结果再通过 sendControlResponse 返回给服务器。
// src/remote/RemoteSessionManager.ts:227-258
respondToPermissionRequest(
requestId: string,
result: RemotePermissionResponse,
): void {
const pendingRequest = this.pendingPermissionRequests.get(requestId)
if (!pendingRequest) {
logError(new Error(`[RemoteSessionManager] No pending permission request with ID: ${requestId}`))
return
}
this.pendingPermissionRequests.delete(requestId)
const response: SDKControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
behavior: result.behavior,
...(result.behavior === 'allow'
? { updatedInput: result.updatedInput }
: { message: result.message }),
},
},
}
this.websocket?.sendControlResponse(response)
}这种设计保证了即使 Agent 运行在云端容器中,敏感操作的授权仍然由本地用户掌控——这是安全模型的关键红线。
三、SessionsWebSocket:高可用的传输层
SessionsWebSocket(src/remote/SessionsWebSocket.ts,第 47-280 行)是远程会话的网络传输底座。它实现了完整的 WebSocket 客户端,包括 Bun 和 Node.js(ws 库)双运行时支持、自动重连、心跳保活,以及对各种异常关闭码的精细化处理。
3.1 双运行时适配
Claude Code 需要同时支持 Bun 和 Node.js 两个运行时。SessionsWebSocket 通过运行时检测实现了无缝切换:
// src/remote/SessionsWebSocket.ts:89-130
if (typeof Bun !== 'undefined') {
// Bun's WebSocket supports headers/proxy options
const ws = new globalThis.WebSocket(url, {
headers,
proxy: getWebSocketProxyUrl(url),
tls: getWebSocketTLSOptions() || undefined,
} as unknown as string[])
this.ws = ws
ws.addEventListener('open', () => {
this.state = 'connected'
this.reconnectAttempts = 0
this.startPingInterval()
this.callbacks.onConnected?.()
})
// ... message / error / close / pong handlers
} else {
const { default: WS } = await import('ws')
const ws = new WS(url, {
headers,
agent: getWebSocketProxyAgent(url),
...getWebSocketTLSOptions(),
})
this.ws = ws
ws.on('open', () => { /* ... */ })
// ... message / error / close / pong handlers
}Bun 的 WebSocket 原生支持 headers 和 proxy 选项,可以直接在构造时传入认证信息;而 Node.js 的 ws 库则需要通过 agent 选项配置代理。Claude Code 统一使用 OAuth Bearer Token 进行认证,通过 Authorization Header 发送,无需额外的 WebSocket 子协议握手。
3.2 精细化重连策略
重连是 WebSocket 客户端最关键也最复杂的部分。Claude Code 设计了一套分级的重连策略:
flowchart TD
A[WebSocket 断开] --> B{关闭码?}
B -->|4003 unauthorized| C[永久拒绝
不再重连]
B -->|4001 session not found| D{重试次数 < 3?}
D -->|是| E[延迟重连
逐次增加间隔]
D -->|否| F[放弃重连]
B -->|其他| G{已连接过?}
G -->|是| H{重试次数 < 5?}
H -->|是| I[固定 2s 延迟重连]
H -->|否| J[放弃重连]
G -->|否| F代码实现位于 handleClose 方法(第 187-240 行):
// src/remote/SessionsWebSocket.ts:187-240
private handleClose(closeCode: number): void {
this.stopPingInterval()
if (this.state === 'closed') return
const previousState = this.state
this.state = 'closed'
// Permanent codes: stop reconnecting
if (PERMANENT_CLOSE_CODES.has(closeCode)) {
this.callbacks.onClose?.()
return
}
// 4001 can be transient during compaction
if (closeCode === 4001) {
this.sessionNotFoundRetries++
if (this.sessionNotFoundRetries > MAX_SESSION_NOT_FOUND_RETRIES) {
this.callbacks.onClose?.()
return
}
this.scheduleReconnect(
RECONNECT_DELAY_MS * this.sessionNotFoundRetries,
`4001 attempt ${this.sessionNotFoundRetries}/${MAX_SESSION_NOT_FOUND_RETRIES}`,
)
return
}
// Attempt reconnection if we were connected
if (previousState === 'connected' && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts++
this.scheduleReconnect(RECONNECT_DELAY_MS, `attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`)
} else {
this.callbacks.onClose?.()
}
}这套策略的细节非常值得品味:
- 4003(Unauthorized):永久拒绝,立即放弃。这意味着用户的 OAuth Token 已失效或被吊销,重连毫无意义。
- 4001(Session Not Found):特殊处理。服务器在进行 compaction(会话压缩/归档)时,可能短暂地将会话标记为失效。因此客户端给予 3 次重试机会,且延迟逐次递增(2s → 4s → 6s),给服务器足够的恢复时间。
- 普通断开:如果之前已成功连接过,则最多重试 5 次,每次固定 2 秒延迟;如果从未成功连接,则直接放弃。
这种分级策略在用户体验和系统负载之间取得了良好平衡:既不会因为短暂的网络抖动就放弃会话,也不会在明确不可能恢复的情况下持续浪费资源。
3.3 心跳保活
WebSocket 连接长时间空闲时可能被中间代理(如 Nginx、企业防火墙)静默断开。SessionsWebSocket 通过 30 秒间隔的 ping/pong 心跳来保持连接活性:
// src/remote/SessionsWebSocket.ts:250-262
private startPingInterval(): void {
this.stopPingInterval()
this.pingInterval = setInterval(() => {
if (this.ws && this.state === 'connected') {
try {
this.ws.ping?.()
} catch {
// Ignore ping errors, close handler will deal with connection issues
}
}
}, PING_INTERVAL_MS) // 30000ms
}注意这里对 ping() 调用失败采取了静默忽略的策略。设计者的判断是:ping 失败本身不足以说明连接已断,可能只是瞬时的网络抖动。真正权威的连接状态判断应该交给 close 事件处理程序,避免过早触发不必要的重连。
四、Teleport 机制:跨设备会话迁移
Teleport(src/utils/teleport.tsx,~175KB)是 Claude Code 远程体系中最复杂、最核心的模块。它实现了完整的跨设备会话迁移能力:从本地 CLI 将当前会话「传送」到云端 CCR 容器,或者从另一台机器通过 --teleport <session-id> 恢复一个正在运行的远程会话。
4.1 Teleport 的核心类型定义
// src/utils/teleport.tsx:52-60
export type TeleportResult = {
messages: Message[]
branchName: string
}
export type TeleportProgressStep = 'validating' | 'fetching_logs' | 'fetching_branch' | 'checking_out' | 'done'
export type TeleportProgressCallback = (step: TeleportProgressStep) => voidTeleportProgressStep 定义了整个迁移流程的五个阶段,UI 层可以据此向用户展示进度条或状态提示。
4.2 本地 → 远程:teleportToRemote
teleportToRemote 函数(第 480-820 行)负责将本地会话推送到云端。它的流程如下:
- 身份验证:检查 OAuth Token 有效性,获取 Organization UUID。
- 环境选择:如果用户指定了
environmentId(例如 code review 场景),直接使用;否则通过fetchEnvironments获取可用的 Environment Provider。 - 源码来源选择:这是 Teleport 最精妙的设计之一。Claude Code 支持两种将代码送到云端容器的方式:
方式一:GitHub 克隆(默认)
如果当前仓库有 GitHub 远程,且用户已安装 GitHub App,CCR 后端会直接从 GitHub 克隆仓库。这是最高效的方式,但依赖 GitHub 集成。
// src/utils/teleport.tsx:650-660
gitSource = {
type: 'git_repository',
url: `https://${host}/${owner}/${name}`,
revision: options.branchName ?? (await getDefaultBranch()) ?? undefined,
...(options.reuseOutcomeBranch && { allow_unrestricted_git_push: true })
}方式二:Git Bundle 上传(Fallback)
如果 GitHub 不可用(例如本地仓库没有 GitHub 远程、或 GitHub App 未安装),Claude Code 会创建一个 git bundle --all,通过 Files API 上传,然后将 file_id 作为 seed_bundle_file_id 传给 Session API。云端容器从 bundle 克隆,完全绕过 GitHub。
// src/utils/teleport.tsx:530-540
const bundle = await createAndUploadGitBundle({
oauthToken: accessToken,
sessionId: getSessionId(),
baseUrl: getOauthConfig().BASE_API_URL
}, { signal })这种「GitHub 优先、Bundle 兜底」的阶梯式策略确保了几乎所有仓库都能成功 Teleport。源码统计显示,43% 的 CLI 会话有 origin 远程,54% 的会话可以通过 Bundle 方式迁移。
- 生成标题和分支:使用 Claude Haiku 模型根据会话描述自动生成会话标题和
claude/前缀的功能分支名。
// src/utils/teleport.tsx:95-105
async function generateTitleAndBranch(description: string, signal: AbortSignal): Promise<TitleAndBranch> {
const userPrompt = SESSION_TITLE_AND_BRANCH_PROMPT.replace('{description}', description)
const response = await queryHaiku({
systemPrompt: asSystemPrompt([]),
userPrompt,
outputFormat: {
type: 'json_schema',
schema: {
type: 'object',
properties: { title: { type: 'string' }, branch: { type: 'string' } },
required: ['title', 'branch'],
}
},
signal,
// ...
})
// ...
}- 创建远程 Session:通过 Sessions API 创建一个新的云端会话,将 git source、环境变量、标题等信息写入
session_context。
4.3 远程 → 本地:teleportResumeCodeSession
teleportResumeCodeSession(第 310-410 行)实现了相反方向的迁移:用户在新机器上运行 claude --teleport <session-id>,将云端正在运行的会话拉取到本地继续。
// src/utils/teleport.tsx:316-330
export async function teleportResumeCodeSession(sessionId: string, onProgress?: TeleportProgressCallback): Promise<TelemetryRemoteResponse> {
if (!isPolicyAllowed('allow_remote_sessions')) {
throw new Error("Remote sessions are disabled by your organization's policy.")
}
// ...
// Fetch and validate repository matches before resuming
onProgress?.('validating')
const sessionData = await fetchSession(sessionId)
const repoValidation = await validateSessionRepository(sessionData)
// ...
}恢复流程包含以下关键步骤:
- 仓库验证:
validateSessionRepository(第 260-310 行)比较当前本地仓库与 Session 关联的仓库是否匹配。如果不匹配,会给出清晰的错误提示,甚至区分 GitHub.com 和 GitHub Enterprise Server(GHES)的跨实例场景:
// src/utils/teleport.tsx:290-305
const hostsDiffer = repoValidation.sessionHost && repoValidation.currentHost &&
repoValidation.sessionHost.replace(/:\d+$/, '').toLowerCase() !==
repoValidation.currentHost.replace(/:\d+$/, '').toLowerCase()
const sessionDisplay = hostsDiffer ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` : repoValidation.sessionRepo注意这里对 host 进行了端口剥离(replace(/:/d+$/, '')),避免因为 SSH 和 HTTPS 远程 URL 的端口差异导致误判。
- 获取会话日志:优先尝试 CCR v2 的
GetTeleportEvents端点,失败则回退到 legacy 的 session-ingress API。
// src/utils/teleport.tsx:370-380
let logs = await getTeleportEvents(sessionId, accessToken, orgUUID)
if (logs === null) {
logForDebugging('[teleport] v2 endpoint returned null, trying session-ingress')
logs = await getSessionLogsViaOAuth(sessionId, accessToken, orgUUID)
}- 检出分支:如果 Session 关联了特定分支,本地仓库会尝试
git checkout到该分支。Claude Code 处理了多种边缘情况:分支可能只在远程存在、upstream 可能未设置、或者分支检出可能完全失败(此时仍继续恢复,只是通知用户)。
// src/utils/teleport.tsx:215-225
export async function checkOutTeleportedSessionBranch(branch?: string): Promise<{
branchName: string
branchError: Error | null
}> {
try {
// ... fetch from origin, checkout branch, ensure upstream
} catch (error) {
const branchName = await getCurrentBranch()
const branchError = toError(error)
return { branchName, branchError }
}
}- 消息恢复与注入:恢复后的消息会经过
processMessagesForTeleportResume处理,添加两条特殊消息:
// src/utils/teleport.tsx:62-76
function createTeleportResumeSystemMessage(branchError: Error | null): SystemMessage {
if (branchError === null) {
return createSystemMessage('Session resumed', 'suggestion')
}
return createSystemMessage(`Session resumed without branch: ${formattedError}`, 'warning')
}
function createTeleportResumeUserMessage() {
return createUserMessage({
content: `This session is being continued from another machine. Application state may have changed. The updated working directory is ${getOriginalCwd()}`,
isMeta: true
})
}这两条消息的设计意图非常明确:一条向用户说明会话已迁移(系统提示,带 warning 级别),另一条向模型说明「这是从另一台机器继续的会话,应用状态可能已经改变」。后者确保了模型不会因为假设环境一致而产生幻觉。
五、SSH 集成:claude ssh 的实现
除了 WebSocket 远程模式,Claude Code 还支持通过 SSH 连接到远程机器执行命令。useSSHSession.ts(src/hooks/useSSHSession.ts,第 1-241 行)是这一功能的 React Hook 封装。
5.1 与 WebSocket 远程模式的对比
文件头部的注释非常清晰地说明了设计决策:
// src/hooks/useSSHSession.ts:1-11
/**
* REPL integration hook for `claude ssh` sessions.
*
* Sibling to useDirectConnect — same shape (isRemoteMode/sendMessage/
* cancelRequest/disconnect), same REPL wiring, but drives an SSH child
* process instead of a WebSocket. Kept separate rather than generalizing
* useDirectConnect because the lifecycle differs: the ssh process and auth
* proxy are created BEFORE this hook runs (during startup, in main.tsx)
* and handed in; useDirectConnect creates its WebSocket inside the effect.
*/SSH 模式与 WebSocket 远程模式保持相同的接口形状(isRemoteMode / sendMessage / cancelRequest / disconnect),但生命周期完全不同:SSH 子进程和认证代理在 main.tsx 启动阶段就已创建,然后通过 props 传入 Hook;而 WebSocket 模式是在 Hook 的 useEffect 内部动态创建连接。
5.2 SSH 会话的消息处理
SSH 会话的消息处理流程与 WebSocket 模式高度一致,都通过 convertSDKMessage 将 SDKMessage 转换为内部 Message 类型,都支持权限请求的人机交互。关键区别在于重连逻辑:
// src/hooks/useSSHSession.ts:145-165
onReconnecting: (attempt, max) => {
logForDebugging(`[useSSHSession] ssh dropped, reconnecting (${attempt}/${max})`)
isConnectedRef.current = false
setIsLoading(false)
const msg: MessageType = {
type: 'system',
subtype: 'informational',
content: `SSH connection dropped — reconnecting (attempt ${attempt}/${max})...`,
timestamp: new Date().toISOString(),
uuid: randomUUID(),
level: 'warning',
}
setMessages(prev => [...prev, msg])
}当 SSH 连接断开时,Hook 会在消息流中插入一条系统消息,告知用户当前正在重连。这比静默重连更友好——用户知道发生了什么,不会误以为系统卡死。
5.3 连接失败的优雅降级
// src/hooks/useSSHSession.ts:166-185
onDisconnected: () => {
logForDebugging('[useSSHSession] ssh process exited (giving up)')
const stderr = session.getStderrTail().trim()
const connected = isConnectedRef.current
const exitCode = session.proc.exitCode
let msg = connected
? 'Remote session ended.'
: 'SSH session failed before connecting.'
if (stderr && (!connected || exitCode !== 0)) {
msg += `\nRemote stderr (exit ${exitCode ?? 'signal ' + session.proc.signalCode}):\n${stderr}`
}
void gracefulShutdown(1, 'other', { finalMessage: msg })
}这段代码展现了极佳的错误处理设计:
- 区分「连接后断开」和「从未连接成功」两种情况,给出不同的提示语。
- 智能判断是否展示远程 stderr:连接前总是展示(帮助诊断连接问题),连接后仅在非零退出码时展示(避免
--verbose的正常输出干扰用户)。 - 最终通过
gracefulShutdown优雅退出,确保终端状态被正确恢复。
六、Bridge 传输:云端容器的神经中枢
如果说 SessionsWebSocket 是本地 CLI 的网络底座,那么 replBridge.ts(src/bridge/replBridge.ts,~100KB)就是云端容器(以及 claude remote-control 模式)的神经中枢。它实现了一个完整的 Bridge 协议客户端,负责与 Anthropic 的 Bridge API 通信,管理会话生命周期,并处理消息的入站和出站。
6.1 Bridge 架构的核心抽象
replBridge.ts 的核心是 initBridgeCore 函数(第 252 行起),它实现了完整的 Bridge 初始化流程:
sequenceDiagram
participant CLI as 本地 CLI
participant API as Bridge API
participant SI as Session Ingress
participant WS as WebSocket
CLI->>API: registerBridgeEnvironment
API-->>CLI: environment_id + secret
CLI->>API: createSession
API-->>CLI: session_id
loop Poll Loop
CLI->>API: pollWork
API-->>CLI: work item / null
end
CLI->>SI: connect ingress WebSocket
SI-->>CLI: ingress token
CLI->>WS: establish read/write transport
WS-->>CLI: bidirectional message flow6.2 环境注册与永续模式
Bridge 的第一步是向服务器注册环境。BridgeConfig 中包含了机器名称、当前分支、Git 仓库 URL 等元数据:
// src/bridge/replBridge.ts:330-345
const bridgeConfig: BridgeConfig = {
dir,
machineName,
branch,
gitRepoUrl,
maxSessions: 1,
spawnMode: 'single-session',
bridgeId: randomUUID(),
workerType,
environmentId: randomUUID(),
reuseEnvironmentId: prior?.environmentId,
apiBaseUrl: baseUrl,
sessionIngressUrl,
}reuseEnvironmentId 是永续模式(perpetual mode)的关键。当用户启用永续模式时,Bridge 指针会被持久化到磁盘。下次启动时,如果环境仍然存活,Claude Code 可以直接重连到同一个环境,无需重新创建 Session。这在 CI/CD 场景或长时间运行的远程 Agent 中特别有用。
6.3 双策略重连机制
当 Bridge 检测到环境丢失(poll 返回 404)或 WebSocket 异常断开时,doReconnect 函数(第 620-750 行)会启动双策略重连:
策略一:原地重连(Reconnect-in-Place)
尝试用相同的 environmentId 重新注册环境。如果服务器返回了相同的 ID,说明环境只是暂时失联(例如网络抖动),可以直接将现有 Session 重新排队到该环境。这种方式的优势是:Session URL 保持不变,历史消息无需重新发送。
// src/bridge/replBridge.ts:380-410
async function tryReconnectInPlace(requestedEnvId: string, sessionId: string): Promise<boolean> {
if (environmentId !== requestedEnvId) {
return false
}
const infraId = toInfraSessionId(sessionId)
const candidates = infraId === sessionId ? [sessionId] : [sessionId, infraId]
for (const id of candidates) {
try {
await api.reconnectSession(environmentId, id)
return true
} catch (err) {
// log and try next candidate
}
}
return false
}注意这里对 Session ID 的兼容性处理:代码同时尝试了原始 ID 和 Infra 层的内部 ID(toInfraSessionId),因为服务器端的 ID 映射可能因功能开关状态而不同。
策略二:全新会话(Fresh Session Fallback)
如果策略一失败(环境已被服务器回收,例如笔记本休眠超过 4 小时),则归档旧会话,在新注册的环境上创建全新会话。此时历史消息会通过 initialMessages 重新 flush 到服务器。
6.4 Transport 抽象与 v1/v2 双协议
replBridgeTransport.ts 实现了对两套传输协议的统一抽象:
- v1(HybridTransport):WebSocket 读 + POST 写,通过 Session Ingress 服务通信。
- v2(SSETransport + CCRClient):Server-Sent Events 读 + POST 写到 CCR v2 的
/worker/*端点。
// src/bridge/replBridgeTransport.ts:25-55
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
readonly droppedBatchCount: number
reportState(state: SessionState): void
reportMetadata(metadata: Record<string, unknown>): void
reportDelivery(eventId: string, status: 'processing' | 'processed'): void
flush(): Promise<void>
}这个抽象层的存在让 replBridge.ts 的上层逻辑无需关心底层是 v1 还是 v2。服务器通过 secret.use_code_sessions 字段驱动协议选择,Ant 开发者还可以通过 CLAUDE_BRIDGE_USE_CCR_V2 环境变量强制覆盖。
6.5 SSE 序列号与消息去重
v2 协议引入了 SSE 序列号机制,解决了 Transport 切换时的消息重复问题:
// src/bridge/replBridge.ts:555-570
let lastTransportSequenceNum = reusedPriorSession ? initialSSESequenceNum : 0
// ...
if (transport) {
const seq = transport.getLastSequenceNum()
if (seq > lastTransportSequenceNum) {
lastTransportSequenceNum = seq
}
transport.close()
transport = null
}如果没有这个序列号 carryover 机制,每次 Transport 重建都会导致服务器从 seq 0 开始重播全部历史,意味着每条历史消息都会作为「新消息」重新投递。通过在 Transport 切换前捕获 lastSequenceNum 并在新 Transport 建立时传入,客户端确保只接收增量事件。
此外,BoundedUUIDSet(容量 2000)作为防御性去重层,捕获那些因序列号协商失败或服务器边缘情况导致的重复投递。
七、Resume 与恢复:跨设备状态同步
远程会话的最终挑战是如何在跨设备迁移时保持状态一致性。Claude Code 通过多层机制来解决这个问题。
7.1 消息级状态同步
会话的核心状态就是消息历史。无论是 Teleport 还是 Bridge 重连,Claude Code 都以消息历史作为「真相源」:
- Teleport 恢复:从 Sessions API 或 Session Ingress 获取完整的会话日志,过滤出 transcript 消息(排除 sidechain 消息),然后在本地重新构建消息列表。
- Bridge 重连:通过
initialMessages将本地历史 flush 到新的远程会话,或通过 SSE 序列号从断点续传。
// src/utils/teleport.tsx:390-400
const messages = logs.filter(
entry => isTranscriptMessage(entry) && !entry.isSidechain
) as Message[]7.2 仓库状态同步
消息历史无法捕获工作目录中的未提交变更。Claude Code 通过 Git 来解决这个问题:
- Teleport 到远程时,如果用户有未暂存的变更,Git Bundle 机制会自动将这些变更打包上传(通过
createAndUploadGitBundle中的refs/seed/stash技巧)。 - 从远程恢复时,如果关联分支存在,本地会自动
git checkout到该分支;如果不存在,给出 warning 但继续恢复。
// src/utils/teleport.tsx:115-125
export async function validateGitState(): Promise<void> {
const isClean = await getIsClean({ ignoreUntracked: true })
if (!isClean) {
throw new TeleportOperationError(
'Git working directory is not clean. Please commit or stash your changes before using --teleport.',
chalk.red('Error: Git working directory is not clean...')
)
}
}注意 validateGitState 仅在启动 Teleport 时检查本地状态,而 ignoreUntracked: true 的设计非常实用——未跟踪的文件不会被 bundle 丢失(它们不影响分支切换),因此不需要强制用户清理。
7.3 冲突处理与边缘情况
Claude Code 在恢复流程中处理了多种冲突和边缘情况:
- 分支检出失败:不会阻塞恢复流程,而是记录错误并在系统消息中提示用户。
- 仓库不匹配:清晰区分「当前不在任何仓库」和「仓库名称/Host 不匹配」两种情况,给出针对性的错误提示。
- Session 不存在(404):给出简洁的错误信息,并提示用户运行
/status检查账户状态。 - 网络瞬断:Teleport API 请求实现了指数退避重试(2s → 4s → 8s → 16s,共 4 次重试)。
// src/utils/teleport/api.ts:30-35
const TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000]
const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length- Poll Loop 的容量控制:Bridge 的 poll loop 在有 active transport 时进入 heartbeat-only 模式,避免无意义的频繁轮询;当 transport 丢失时,通过
capacityWake信号立即唤醒,切换回快速轮询以尽快获取重新分派的 work item。
7.4 权限状态的跨端一致性
远程模式中最微妙的问题是权限状态。当用户在手机上允许了一个工具调用,而本地 CLI 正在等待同一个权限请求的响应时,必须确保状态不会混乱。
RemoteSessionManager 通过 pendingPermissionRequests Map 追踪所有待处理的权限请求。当收到 control_cancel_request 时,它会从 Map 中移除对应的请求,并触发 onPermissionCancelled 回调,确保 UI 层可以正确清理相关的提示框。
// src/remote/RemoteSessionManager.ts:175-185
if (message.type === 'control_cancel_request') {
const { request_id } = message
const pendingRequest = this.pendingPermissionRequests.get(request_id)
this.pendingPermissionRequests.delete(request_id)
this.callbacks.onPermissionCancelled?.(request_id, pendingRequest?.tool_use_id)
return
}八、总结
Claude Code 的远程体系是一套工程化程度极高的分布式协作方案。从本地到云端,从 WebSocket 到 SSH,从即时连接到异步恢复,每个环节都经过了精心的设计和打磨。
回顾全文,我们可以提炼出几个关键的设计哲学:
- 协议分层:
SessionsWebSocket管传输、RemoteSessionManager管业务、teleport.tsx管迁移、replBridge.ts管云端生命周期。每一层都有清晰的边界和职责。 - 优雅降级:GitHub 不可用时自动 fallback 到 Git Bundle;v2 协议不可用时 fallback 到 v1;重连失败时 graceful teardown。系统在任何异常路径上都力求给用户最有价值的反馈。
- 状态以消息为源:跨设备同步的核心不是复杂的 CRDT 或状态机复制,而是简单的消息历史 + Git 分支。这种选择降低了复杂度,同时满足了 AI Agent 场景的实际需求。
- 安全不妥协:敏感工具权限永远由本地用户决定,即使 Agent 运行在云端容器中也不例外。
RemotePermissionResponse的设计确保了这一点。
Teleport 和远程会话功能的存在,让 Claude Code 不再是一个单机工具,而是一个真正的分布式 AI 编程伙伴。无论你身在何处、使用何种设备,你的 AI Agent 始终在线,随时准备继续未竟的编程之旅。