远程会话与 Teleport

📑 目录

本文是《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.tsWebSocket 传输层:连接建立、重连策略、心跳保活~280 行
sdkMessageAdapter.tsSDK 消息格式与内部 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 的远程体系实际上支持两条并行的连接路径:

  1. WebSocket 路径:本地 CLI 作为客户端,通过 SessionsWebSocket 连接到 Anthropic 的云端 Session 服务,与运行在云端的 CCR 容器交互。这是 --remote 模式的核心通路。
  2. SSH 路径:本地 CLI 通过 SSH 子进程连接到远程机器,在远程机器上启动另一个 Claude Code 实例,本地仅作为显示终端。这是 claude ssh 模式的核心通路。

两条路径的上层抽象保持一致(isRemoteMode / sendMessage / cancelRequest / disconnect),因此 UI 层无需关心底层走的是哪条链路。

二、RemoteSessionManager:远程会话的业务大脑

RemoteSessionManagersrc/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:高可用的传输层

SessionsWebSocketsrc/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 原生支持 headersproxy 选项,可以直接在构造时传入认证信息;而 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) => void

TeleportProgressStep 定义了整个迁移流程的五个阶段,UI 层可以据此向用户展示进度条或状态提示。

4.2 本地 → 远程:teleportToRemote

teleportToRemote 函数(第 480-820 行)负责将本地会话推送到云端。它的流程如下:

  1. 身份验证:检查 OAuth Token 有效性,获取 Organization UUID。
  2. 环境选择:如果用户指定了 environmentId(例如 code review 场景),直接使用;否则通过 fetchEnvironments 获取可用的 Environment Provider。
  3. 源码来源选择:这是 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 方式迁移。

  1. 生成标题和分支:使用 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,
    // ...
  })
  // ...
}
  1. 创建远程 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)
  // ...
}

恢复流程包含以下关键步骤:

  1. 仓库验证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 的端口差异导致误判。

  1. 获取会话日志:优先尝试 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)
}
  1. 检出分支:如果 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 }
  }
}
  1. 消息恢复与注入:恢复后的消息会经过 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.tssrc/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.tssrc/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 flow

6.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 在恢复流程中处理了多种冲突和边缘情况:

  1. 分支检出失败:不会阻塞恢复流程,而是记录错误并在系统消息中提示用户。
  2. 仓库不匹配:清晰区分「当前不在任何仓库」和「仓库名称/Host 不匹配」两种情况,给出针对性的错误提示。
  3. Session 不存在(404):给出简洁的错误信息,并提示用户运行 /status 检查账户状态。
  4. 网络瞬断: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
  1. 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,从即时连接到异步恢复,每个环节都经过了精心的设计和打磨。

回顾全文,我们可以提炼出几个关键的设计哲学:

  1. 协议分层SessionsWebSocket 管传输、RemoteSessionManager 管业务、teleport.tsx 管迁移、replBridge.ts 管云端生命周期。每一层都有清晰的边界和职责。
  2. 优雅降级:GitHub 不可用时自动 fallback 到 Git Bundle;v2 协议不可用时 fallback 到 v1;重连失败时 graceful teardown。系统在任何异常路径上都力求给用户最有价值的反馈。
  3. 状态以消息为源:跨设备同步的核心不是复杂的 CRDT 或状态机复制,而是简单的消息历史 + Git 分支。这种选择降低了复杂度,同时满足了 AI Agent 场景的实际需求。
  4. 安全不妥协:敏感工具权限永远由本地用户决定,即使 Agent 运行在云端容器中也不例外。RemotePermissionResponse 的设计确保了这一点。

Teleport 和远程会话功能的存在,让 Claude Code 不再是一个单机工具,而是一个真正的分布式 AI 编程伙伴。无论你身在何处、使用何种设备,你的 AI Agent 始终在线,随时准备继续未竟的编程之旅。