Skip to content
源码分析手册

Remote Control:将本地 REPL 接入长期双向桥的远程驱动机制

本质

Claude Code 的 Remote Control(远程控制)并不是将你的会话“迁移”到云端运行。它的本质是为本地正在运行的 REPL 进程接入一条长期的、双向的通信桥(REPL Bridge)

这种机制下:

  • 真正的工具执行、文件读写和模型推理触发仍然发生在你本地的计算机上。
  • 云端的 Claude.ai 网页或移动 App 仅充当一个远程的“交互窗口”。
  • 所有的输入(无论来自本地键盘还是远程手机)都会被重新注入本地的 REPL 输入队列中统一处理。

压成一句话:Remote Control 不是在云端克隆一个 Agent,而是给本地 REPL 会话接上了一根可以让远程设备拨动的“遥控连线”。

实现机制

整个远程控制体系由启动分流、Bridge 生命周期管理和消息重注入机制共同构建:

1. 启动路径的分流

系统通过 claude-code-opensource/src/main.tsx 区分三种接入模式:

  • 按需开启(/remote-control--rc:在交互式 REPL 会话中启用,通过 useReplBridge.tsx 挂载 Bridge。
  • 独立服务模式(claude remote-control:不启动本地交互终端,直接进入 claude-code-opensource/src/bridge/bridgeMain.ts,作为一个常驻后台的 Bridge 宿主运行。

2. Bridge 环境的建立与轮询

claude-code-opensource/src/bridge/replBridge.ts 中,核心逻辑分五个阶段:

  1. 环境注册:本地进程向 Anthropic 注册一个 Bridge Environment。
  2. 会话创建:为当前本地会话申请一个唯一的远程会话 ID。
  3. 后台轮询(Poll Loop):本地进程持续向云端轮询 Work Items。
  4. 消息接入:一旦云端有指令下发,本地进程利用 Ingress Token 通过 WebSocket 或长轮询将指令拉回。
  5. 断线续连:即便本地网络瞬时掉线,Bridge 也会利用 Session ID 尝试自动重连。

3. 消息的双向同步与注入

claude-code-opensource/src/hooks/useReplBridge.tsx 是 REPL 与 Bridge 的粘合层:

  • 向外同步:每当本地产生新的 Transcript(对话记录),Bridge 会将其同步给远程界面,并附带模型、技能、Slash Commands 等元数据。
  • 向内注入:远程发送的用户消息会被解析并带上来源标签,随后通过 enqueue() 塞进本地的 REPL 输入队列。后续的工具调用和权限请求流程与本地输入完全一致。

4. 权限决策的“多方竞赛”

远程控制并不意味着绕过权限审批。claude-code-opensource/src/hooks/toolPermission/handlers/interactiveHandler.ts 中实现了一个精妙的权限竞争机制:

  • 本地终端会弹出权限对话框。
  • 同时,通过 bridgePermissionCallbacks.ts,权限请求会被推送到远程 App。
  • 谁先点击“同意”或“拒绝”,谁就赢得了这次权限裁决(Claimed)。

别踩这些坑

  1. 环境依赖性:必须具备 Claude.ai OAuth 认证,且当前组织策略必须允许 allow_remote_control,该功能无法在纯 API Key 模式下开启。
  2. 本地主导权:所有的实际动作(改代码、跑命令)仍然依赖于本地进程的存活。如果本地 CLI 退出,远程界面将立即变为只读或离线状态。
  3. 两种实现分流:源码中存在 replBridge.ts(Env-based)和 remoteBridgeCore.ts(Env-less)两条技术路径,这是内部为了兼容不同传输后端而做的抽象。
  4. 标题推导机制:远程会话的标题是由本地进程在 initReplBridge.ts 中根据当前会话内容异步生成的,而不是由云端自动抓取。
  5. 版本一致性:Bridge 连接时会校验最低版本要求,如果本地 CLI 版本过低,可能导致连接被云端单方面终止。

继续探索

  • 后台服务化运行:查看 claude-code-opensource/src/bridge/bridgeMain.ts,了解独立服务模式如何管理多个并发的远程会话。
  • 权限竞争细节:阅读 claude-code-opensource/src/hooks/toolPermission/handlers/interactiveHandler.ts,深入研究本地与远程是如何在同一个权限队列中竞争裁决权的。
  • 状态同步流:探索 useReplBridge.tsx 里的 replBridgeHandle 是如何通过 React Context 向下层组件传播同步状态的。

源码锚点

  • claude-code-opensource/src/bridge/replBridge.ts: 远程环境注册、Session 创建与核心 Poll Loop 逻辑。
📄 src/bridge/replBridge.ts — 远程环境注册、Session 创建与核心 Poll Loop 逻辑。L29-31 of 2407
typescript
  sameSessionId,
} from './workSecret.js'
import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
  • claude-code-opensource/src/hooks/useReplBridge.tsx: REPL 主循环与 Bridge 消息注入、权限 Callback 的接线层。
📄 src/hooks/useReplBridge.tsx — REPL 主循环与 Bridge 消息注入、权限 Callback 的接线层。L51-80 of 723
tsx
 * Inbound messages from claude.ai are injected into the REPL via queuedCommands.
 */
export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction<Message[]>) => void, abortControllerRef: React.RefObject<AbortController | null>, commands: readonly Command[], mainLoopModel: string): {
  sendBridgeResult: () => void;
} {
  const handleRef = useRef<ReplBridgeHandle | null>(null);
  const teardownPromiseRef = useRef<Promise<void> | undefined>(undefined);
  const lastWrittenIndexRef = useRef(0);
  // Tracks UUIDs already flushed as initial messages. Persists across
  // bridge reconnections so Bridge #2+ only sends new messages — sending
  // duplicate UUIDs causes the server to kill the WebSocket.
  const flushedUUIDsRef = useRef(new Set<string>());
  const failureTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  // Persists across effect re-runs (unlike the effect's local state). Reset
  // only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown
  // for the session, regardless of replBridgeEnabled re-toggling.
  const consecutiveFailuresRef = useRef(0);
  const setAppState = useSetAppState();
  const commandsRef = useRef(commands);
  commandsRef.current = commands;
  const mainLoopModelRef = useRef(mainLoopModel);
  mainLoopModelRef.current = mainLoopModel;
  const messagesRef = useRef(messages);
  messagesRef.current = messages;
  const store = useAppStateStore();
  const {
    addNotification
  } = useNotifications();
  const replBridgeEnabled = feature('BRIDGE_MODE') ?
  // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  • claude-code-opensource/src/bridge/initReplBridge.ts: 启动前的 OAuth 检查、组织策略验证与标题推导逻辑。
📄 src/bridge/initReplBridge.ts — 启动前的 OAuth 检查、组织策略验证与标题推导逻辑。L3-16 of 570
typescript
 * bootstrap state — gates, cwd, session ID, git context, OAuth, title
 * derivation — then delegates to the bootstrap-free core.
 *
 * Split out of replBridge.ts because the sessionStorage import
 * (getCurrentSessionTitle) transitively pulls in src/commands.ts → the
 * entire slash command + React component tree (~1300 modules). Keeping
 * initBridgeCore in a file that doesn't touch sessionStorage lets
 * daemonBridge.ts import the core without bloating the Agent SDK bundle.
 *
 * Called via dynamic import by useReplBridge (auto-start) and print.ts
 * (SDK -p mode via query.enableRemoteControl).
 */

import { feature } from 'bun:bundle'
  • claude-code-opensource/src/bridge/bridgeMain.ts: claude remote-control 命令对应的独立服务端实现。
📄 src/bridge/bridgeMain.ts — `claude remote-control` 命令对应的独立服务端实现。L41-51 of 3000
typescript
  type BridgeApiClient,
  type BridgeConfig,
  type BridgeLogger,
  DEFAULT_SESSION_TIMEOUT_MS,
  type SessionDoneStatus,
  type SessionHandle,
  type SessionSpawner,
  type SessionSpawnOpts,
  type SpawnMode,
} from './types.js'
import {
  • claude-code-opensource/src/hooks/toolPermission/handlers/interactiveHandler.ts: 权限 race 机制的核心实现点。
📄 src/hooks/toolPermission/handlers/interactiveHandler.ts — 权限 race 机制的核心实现点。L34-41 of 537
typescript
type InteractivePermissionParams = {
  ctx: PermissionContext
  description: string
  result: PermissionDecision & { behavior: 'ask' }
  awaitAutomatedChecksBeforeDialog: boolean | undefined
  bridgeCallbacks?: BridgePermissionCallbacks
  channelCallbacks?: ChannelPermissionCallbacks
}

基于 Claude Code v2.1.88 开源快照的深度分析