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 中,核心逻辑分五个阶段:
- 环境注册:本地进程向 Anthropic 注册一个 Bridge Environment。
- 会话创建:为当前本地会话申请一个唯一的远程会话 ID。
- 后台轮询(Poll Loop):本地进程持续向云端轮询 Work Items。
- 消息接入:一旦云端有指令下发,本地进程利用 Ingress Token 通过 WebSocket 或长轮询将指令拉回。
- 断线续连:即便本地网络瞬时掉线,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)。
别踩这些坑
- 环境依赖性:必须具备 Claude.ai OAuth 认证,且当前组织策略必须允许
allow_remote_control,该功能无法在纯 API Key 模式下开启。 - 本地主导权:所有的实际动作(改代码、跑命令)仍然依赖于本地进程的存活。如果本地 CLI 退出,远程界面将立即变为只读或离线状态。
- 两种实现分流:源码中存在
replBridge.ts(Env-based)和remoteBridgeCore.ts(Env-less)两条技术路径,这是内部为了兼容不同传输后端而做的抽象。 - 标题推导机制:远程会话的标题是由本地进程在
initReplBridge.ts中根据当前会话内容异步生成的,而不是由云端自动抓取。 - 版本一致性: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 逻辑。
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 的接线层。
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 constantclaude-code-opensource/src/bridge/initReplBridge.ts: 启动前的 OAuth 检查、组织策略验证与标题推导逻辑。
📄 src/bridge/initReplBridge.ts — 启动前的 OAuth 检查、组织策略验证与标题推导逻辑。
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` 命令对应的独立服务端实现。
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 机制的核心实现点。
typescript
type InteractivePermissionParams = {
ctx: PermissionContext
description: string
result: PermissionDecision & { behavior: 'ask' }
awaitAutomatedChecksBeforeDialog: boolean | undefined
bridgeCallbacks?: BridgePermissionCallbacks
channelCallbacks?: ChannelPermissionCallbacks
}