Claude Code 深度拆解:远程模式 2 — 环境注册与轮询架构

Hi,大家好,欢迎来到维元码簿。

本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,专注于 V1 环境注册与轮询架构------理解完整的 register → poll → ack → heartbeat → stop → deregister 生命周期。

本文聚焦一件事:Claude Code 的 V1 远程架构如何把一个本地 Agent 进程注册为服务端的"执行环境",并通过六步生命周期管理远程会话。

读完全文,你将能回答这几个问题:

  • claude remote-control 启动后到底做了什么? 为什么启动后什么都不干,只是显示一个 QR 码?
  • 从浏览器发消息到本地 Agent 开始执行,中间经历了什么? 答案:一条从 create session → poll → ack → spawn → execute 的完整链路。
  • 服务端怎么知道你的机器还活着? heartbeat 心跳机制------但不是简单的定时 ping,而是带租约(lease)的分布式锁。
  • 为什么 V1 架构需要六步(register → poll → ack → heartbeat → stop → deregister)? 每步解决的是什么分布式系统问题?

阅读提示 :如果你想了解远程模式的完整图景(V1 vs V2 的架构抉择),推荐先读本系列的远程模式专栏完整版。本文聚焦 V1 环境注册与轮询架构,可独立阅读,不依赖其他姊妹篇上下文。


本篇覆盖的源码范围

模块 核心文件 核心代码行 职责
守护进程桥接 src/bridge/bridgeMain.ts L1-3000 独立守护进程、poll 循环、session spawn & manage
REPL 桥接核心 src/bridge/replBridge.ts L1-2407 /remote-control 内嵌桥接、环境注册、传输层
REST API 客户端 src/bridge/bridgeApi.ts L1-540 Environments API 的完整 HTTP 客户端
会话 CRUD src/bridge/createSession.ts L1-385 会话创建、查询、归档、标题更新
子进程孵化 src/bridge/sessionRunner.ts L1-481 spawn 子 Agent 进程、stdin/stdout 管道
类型定义 src/bridge/types.ts L1-263 BridgeConfig、WorkResponse、SessionHandle 等
工作密钥解析 src/bridge/workSecret.ts L1-147 decodeWorkSecret、buildSdkUrl

前情提要:V1 解决的是"远程执行"的什么核心问题

在完整版中,我们介绍了 V1 和 V2 的本质区别:V1 是"环境注册 + 任务分发"模型,V2 是"直连"模型。

问题空间:一台电脑上跑着 Claude Code,你希望从手机浏览器远程给它下命令。但浏览器和你的电脑之间没有直连------它们在互联网的两端,甚至可能在不同的 NAT 后面。你需要一个"中间人"来牵线搭桥。

约束条件 :这台电脑上装的只是一个 npm 全局包------npm install -g @anthropic-ai/claude-code。不能要求用户装 Redis、配消息队列、搭 WebSocket 服务器。所有"如何被发现、如何被派活"的逻辑,必须在这个 npm 包的范围内解决。

方案思路:V1 的答案是------把你的电脑注册为 claude.ai 服务端的一个"执行环境"(environment),然后轮询(poll)等任务。这套方案用六个步骤完成一条完整的远程执行链路,每一步解决一个分布式系统的经典问题。

打个比方:V1 像是你在外卖平台注册了一个厨房。你注册环境(register),平台知道你的厨房在哪、能同时做几个菜。用户下单后,平台把订单放到你的工作队列。你轮询拿单(poll),确认接单(ack),开始做菜(spawn session)。做菜过程中定时报告进度(heartbeat)。菜做好后通知平台(stop),关店时注销厨房(deregister)。


第一章:为什么 V1 需要两种启动形态

在拆解六步生命周期之前,先理解一个设计决策:为什么同一个 V1 架构需要两个入口?

因为远程控制有两种截然不同的使用场景。场景 A:一台闲置的服务器/台式机,你想把它当作"远程 Agent 主机"------不需要在它上面先开一个 REPL 对话,直接开机就跑 claude remote-control,然后从手机连接。场景 B:你正在本地 REPL 里和 Claude 讨论一个 bug,想用手机继续这场对话------你已经有了上下文,不想重开。

这两种场景对底层系统的要求完全不同:场景 A 需要独立的守护进程 ,能并发处理多个远程会话,每个会话跑在子进程里隔离;场景 B 需要注入现有 REPL,把远程消息当作额外的输入源混入当前对话。

Anthropic 用同一套 Environments API + 不同的入口参数(perpetualoutboundOnly),让两个产品形态共享了 register/poll/ack 等核心逻辑。

1.1 守护进程模式:claude remote-control

bridgeMain.ts(3000 行)是独立守护进程的完整实现。它是一个自包含的程序------有自己的 CLI 参数解析、日志系统、状态显示。

启动命令:

bash 复制代码
claude remote-control                    # 单会话模式
claude remote-control --max-sessions 4   # 最多 4 个并发会话
claude remote-control --spawn-mode worktree  # 每个会话独立工作树

启动后的行为:

  1. 解析 CLI 参数,构建 BridgeConfig
  2. 通过 createBridgeApiClient() 创建 API 客户端
  3. 调用 registerBridgeEnvironment() 注册环境
  4. 进入 pollLoop() ------无限循环轮询工作
  5. 显示终端 UI(QR 码 + 状态行)

守护进程模式的核心循环(bridgeMain.ts 简化):

typescript 复制代码
async function pollLoop() {
  while (!shuttingDown) {
    const workItem = await api.pollForWork(envId, envSecret, signal)
    if (workItem === null) continue  // 没任务,继续等
    
    // 有任务!确认领取
    await api.acknowledgeWork(envId, workItem.id, sessionToken)
    
    // 解析工作密钥
    const secret = decodeWorkSecret(workItem.secret)
    
    // 创建服务端会话
    const sessionId = await createBridgeSession({...})
    
    // fork 子进程执行
    const handle = spawner.spawn({ sessionId, sdkUrl, accessToken }, cwd)
    
    // 监控子进程状态,处理心跳
    await monitorSession(handle, workItem)
    
    // 会话完成,清理
    await api.stopWork(envId, workItem.id, false)
  }
}

1.2 REPL 内嵌模式:/remote-control

initReplBridge.tsreplBridge.ts 是 REPL 内嵌的桥接。当用户在正在进行的对话中输入 /remote-control

  1. 检查准入(OAuth、GrowthBook、组织策略)
  2. 注册环境
  3. 将当前会话历史同步到服务端
  4. 建立双向消息通道------远程输入作为 user 消息注入到当前 REPL 会话

与守护进程的关键差异:REPL 模式不需要 fork 子进程。Agent 就在当前进程中运行------远程消息作为额外的输入源注入到现有 REPL 的消息循环中。

两种模式对应了两种截然不同的产品体验 :守护进程模式是"无头机控制"------服务器的终端能力暴露给远程;REPL 内嵌模式是"会话共享"------正在进行的对话无缝扩展到手机。同一套 V1 架构,入口参数不同(perpetualoutboundOnly),产品形态完全不同。完整的调用全景见完整版第一章。


第二章:六步生命周期------为什么是六步

在深入每一步之前,先回答一个读者自然会问的问题:为什么是六步,不是五步,不是一步?

因为这六个步骤解决的是六个不同的分布式系统经典问题,每个都有独立的存在理由:

  • register 解决的是服务发现问题------服务端怎么知道你的电脑是可用的执行环境?
  • poll 解决的是任务分发问题------如何让请求者和执行者解耦?
  • ack 解决的是去重问题------网络重传可能导致同一任务被派发两次
  • spawn session 解决的是故障隔离问题------一个会话的 OOM 不能影响其他会话
  • heartbeat 解决的是租约问题------服务端需要知道"这台机器还活着"才能继续派活
  • stop + deregister 解决的是资源回收问题------关店了就得把环境注销,不能留死连接

这些不是"可以合并"的冗余步骤------砍掉任何一步,要么引入分布式系统经典陷阱(重复执行、资源泄漏),要么失去关键能力(故障隔离、并发控制)。下面逐一拆解。

2.1 第一步:环境注册(register)

注册的本质是向服务端宣告"我这里有一个可用的执行环境"。

typescript 复制代码
// bridgeApi.ts --- registerBridgeEnvironment()
const response = await axios.post('/v1/environments/bridge', {
  machine_name: hostname(),       // 机器名
  directory: getOriginalCwd(),    // 工作目录
  branch: await getBranch(),      // Git 分支
  git_repo_url: await getRemoteUrl(), // Git 仓库 URL
  max_sessions: maxSessions,      // 最大并发会话数
  metadata: { worker_type: workerType }, // 工作类型:claude_code / claude_code_assistant
})
// Response: { environment_id: "env_abc123", environment_secret: "secret_xyz" }

注册时发送的信息决定了 claude.ai 上环境列表的样子------用户看到机器名、目录、分支、仓库名。

worker_type 的作用claude_codeclaude_code_assistant 是两个不同的 worker 类型。claude.ai 前端根据这个字段过滤------助理模式的 picker 只显示 claude_code_assistant 类型的环境。

幂等注册 :如果传入 reuseEnvironmentId--session-id 恢复场景),服务端会复用已有环境而不是创建新的。这样 Bridge 重启后可以无缝恢复。

2.2 第二步:轮询工作(poll)

注册后,Bridge 进入轮询循环。这是 V1 架构最核心的循环:

复制代码
GET /v1/environments/{env_id}/work/poll
Headers: Authorization: Bearer {environment_secret}

→ 有工作: { id: "work_123", data: { type: "session", id: "session_456" }, secret: "base64url..." }
→ 无工作: null

轮询不是定时短轮询,而是长轮询(long poll)。服务端会 hold 请求最多 25 秒------有新工作立即返回,没有就超时返回 null。

轮询间隔由 GrowthBook 动态配置(pollConfig.ts / pollConfigDefaults.ts):

场景 间隔 原因
空闲等待 30 秒 没人用就省资源
有活跃会话 2-5 秒 快速响应新请求
初始启动 5 秒 基线

轮询容错不是简单重试。 replBridge.ts 中定义了指数退避策略:

typescript 复制代码
// poll error recovery
const POLL_ERROR_INITIAL_DELAY_MS = 2_000   // 从 2 秒开始
const POLL_ERROR_MAX_DELAY_MS = 60_000      // 上限 60 秒
const POLL_ERROR_GIVE_UP_MS = 15 * 60 * 1000 // 坚持 15 分钟

如果轮询出错(网络波动、服务端 500),Bridge 不会立即放弃------它会指数退避重试,最长坚持 15 分钟。即使所有会话都结束了,只要服务端还接受轮询,Bridge 就继续等待------因为可能有新的远程请求进来。

2.3 第三步:工作确认(acknowledge)

收到工作项后的第一件事是确认领取

复制代码
POST /v1/environments/{env_id}/work/{work_id}/ack
Headers: Authorization: Bearer {session_token}

确认用的是 session_token,不是 environment_secret。这是有意为之------确认是会话级别的操作,一个环境可能有多个活跃会话,每个会话用自己的 token。

确认的设计是为了防止重复执行。 如果工作因为网络问题被重复派发(re-dispatch),已经 ack 过的 Bridge 会再次看到这个工作------但此时它可以检查 sameSessionId() 判断是否是重派,避免重复创建会话。

2.4 第四步:会话创建与子进程孵化

确认工作后,Bridge 解析 work.secret(一个 Base64URL 编码的 JSON):

typescript 复制代码
// workSecret.ts --- decodeWorkSecret()
type WorkSecret = {
  version: number
  session_ingress_token: string    // 会话 JWT
  api_base_url: string             // API 地址
  sources: Array<{ type, git_info }> // Git 仓库信息
  auth: Array<{ type, token }>     // 认证令牌
  claude_code_args?: Record<string, string>  // Claude Code 启动参数
  mcp_config?: unknown             // MCP 配置
  environment_variables?: Record<string, string>
}

这个 Secret 本质上是服务端给子进程的"启动包"------包含了子进程需要的所有认证信息和配置。

然后创建服务端会话:

复制代码
POST /v1/sessions
Body: {
  environment_id: "env_abc123",
  title: "Bug fix for auth module",
  events: [...],           // 会话历史(可选)
  session_context: {
    sources: [{ type: "git_repository", url: "...", revision: "main" }],
    outcomes: [{ type: "git_repository", git_info: {...} }],
    model: "claude-sonnet-4-20250514"
  },
  source: "remote-control"
}

session_context 中包含了 Git 上下文------这让 claude.ai 的会话卡片上能显示仓库信息和分支。

最后 fork 子进程(守护进程模式):

typescript 复制代码
// sessionRunner.ts --- createSessionSpawner()
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
  const child = spawn(claudeBinary, [
    '--sdk-url', sdkUrl,
    '--session-id', sessionId,
    '--access-token', accessToken,
    '--replay-user-messages',  // 让子进程在 stdout 输出用户消息文本
  ], {
    cwd: dir,
    env: { ...process.env, ...ccrEnvVars },
  })
  
  return {
    sessionId,
    done: new Promise((resolve) => child.on('exit', resolve)),
    kill: () => child.kill(),
    activities: [],         // 最近操作(环缓冲,last ~10)
    currentActivity: null,
    writeStdin: (data) => child.stdin.write(data),
  }
}

为什么要分离父进程和子进程? 两个原因:

  1. 故障隔离:子进程 OOM 或 crash 不影响守护进程。守护进程可以继续服务其他会话。
  2. 并发会话 :守护进程模式下,maxSessions 控制最大并发数。每个会话跑在独立进程中。

2.5 第五步:心跳保活(heartbeat)

工作项有租约(lease)机制。默认 TTL 是 300 秒(5 分钟)。Bridge 需要定期发送心跳续约:

复制代码
POST /v1/environments/{env_id}/work/{work_id}/heartbeat
Headers: Authorization: Bearer {session_token} (SessionIngressAuth, JWT, no DB hit)
→ Response: { lease_extended: true, state: "running", ttl_seconds: 300 }

为什么用 JWT 而不是查数据库? 注释中说得很清楚:Uses SessionIngressAuth (JWT, no DB hit) instead of EnvironmentSecretAuth。心跳是高频操作,每个活跃会话每 60 秒一次------用 JWT 验证避免数据库查询,降低服务端压力。

心跳同时汇报会话状态:

  • running:模型正在推理或工具正在执行
  • idle:等待用户输入
  • requires_action:等待权限确认

claude.ai 上的会话状态指示器就是从这里来的。

如果心跳丢失 :服务端会在 TTL 过期后将工作标记为失效,可能重新分配给其他环境(如果配置了 reclaim_older_than_ms)。

2.6 第六步:停止与注销(stop + deregister)

会话完成后:

  1. stopWorkPOST /v1/environments/{env_id}/work/{work_id}/stop

    • 如果是正常完成,force = false
    • 如果用户中断(Ctrl+C),force = true,服务端会强制停止运行中的 Agent
  2. archiveSessionPOST /v1/sessions/{sessionId}/archive

    • 将会话从 claude.ai 的活跃列表移除
    • 幂等操作------已归档的会话返回 409(不是错误)
  3. deregisterEnvironmentDELETE /v1/environments/bridge/{env_id}

    • Bridge 关闭时注销环境
    • 之后服务端不会再向这个环境派发工作

关闭顺序经过精心编排。 bridgeMain.ts 的 shutdown 逻辑:

typescript 复制代码
async function gracefulShutdown() {
  // ① 停止接受新工作
  shuttingDown = true
  
  // ② 停止所有运行中的会话(force kill)
  for (const handle of activeSessions) {
    await api.stopWork(envId, handle.workId, true)
  }
  
  // ③ 等待子进程退出(最多 30 秒,然后 SIGKILL)
  await Promise.race([
    Promise.all([...activeSessions].map(h => h.done)),
    sleep(30_000)
  ])
  
  // ④ 注销环境
  await api.deregisterEnvironment(envId)
}

第三章:REPL 桥接的特殊路径

REPL 内嵌模式(replBridge.ts)与守护进程共享同一套核心 API,但有自己的特殊处理:

3.1 环境复用与"环境丢失"恢复

REPL 桥接不创建新环境------它复用守护进程创建的环境,或者自己创建一个。如果环境因为服务端过期而"丢失"(env lost),replBridge.ts 有自动恢复机制:

typescript 复制代码
// replBridge.ts --- onEnvLost
// 环境过期 → 标记所有会话为失效 → 重新注册 → 重建会话
if (isExpiredErrorType(errorType)) {
  // 清理旧的 transport
  // 重新调用 initBridgeCore({ reuseEnvironmentId: undefined })
  // 用新的环境 ID 重建所有活跃会话
}

3.2 会话历史同步

当用户在 REPL 中输入 /remote-control 时,当前对话的完整历史会被同步到服务端:

typescript 复制代码
// initReplBridge.ts --- initialHistoryCap
const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH(
  'tengu_bridge_initial_history_cap', 200, 5 * 60 * 1000
)
// 最多同步 200 条历史消息

为什么限制 200 条? 避免在服务端创建巨大的初始会话。200 条消息对于大多数对话来说足够建立上下文。

3.3 权限桥接

当远程会话中的 Agent 需要权限确认时(比如要执行 git push),权限请求不会弹本地对话框,而是通过 Bridge 发送到 claude.ai

复制代码
Agent 需要权限
    │
    ▼
sendControlRequest({ subtype: "can_use_tool", tool_name: "Bash", ... })
    │
    ▼
服务端 → claude.ai 网页弹窗 "Claude 想执行 git push --- 允许/拒绝?"
    │
    ▼
用户在网页上点击"允许"
    │
    ▼
服务端 → Bridge 收到 control_response { behavior: "allow" }
    │
    ▼
Agent 继续执行

整个回路不依赖轮询------权限请求和响应都走 WebSocket/SSE 实时推送。


本章小结

V1 的六步生命周期不是冗余设计,每一步解决的是一个分布式系统经典问题。

  • register:服务发现------让服务端知道哪里有可用的执行环境
  • poll:任务分发------解耦请求者和执行者
  • ack:去重保证------防止同一任务被多次执行
  • spawn session:故障隔离------子进程崩溃不影响守护进程
  • heartbeat:租约管理------分布式锁的"我还活着"信号
  • stop + deregister:优雅关闭------不丢任务、不污染状态

V1 的代价是延迟。 轮询间隔意味着从用户发消息到 Agent 开始执行,最多可能有几秒的等待。这就是 V2 要解决的问题------下一篇见。


系列导航

本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,完整版见本系列远程模式专栏。

本系列其他子篇章覆盖了 V2 无环境直连架构、鉴权链与会话生命周期、消息路由与传输层等命题,可独立阅读。


如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

相关推荐
踏着七彩祥云的小丑2 小时前
AI——Dify创建第一个AI聊天机器人
人工智能·ai·机器人
HIT_Weston2 小时前
76、【Agent】【OpenCode】用户对话提示词(addtionalProperties 属性)
人工智能·agent·opencode
多年小白2 小时前
【行情复盘】2026年5月8日(周五)
大数据·人工智能·科技·gpt·深度学习·ai
.-Smile-.2 小时前
【开源】Yszen AI:一个开箱即用的 Harness 架构 Agent 脚手架(FastAPI + LangGraph + React)
aigc·agent·harness
手打猪大屁2 小时前
使用claude code 接入deepseek-v4pro
linux·windows·ai·deepseek·claude code
香蕉鼠片2 小时前
python框架Numpy、Pandas、Flask、Django、TensorFlow(ai写的
ai
介一安全3 小时前
【Web安全】AI自动化实现前端加密算法逆向分析
测试工具·ai·自动化·逆向·安全性测试
快跑bug来啦3 小时前
RAGFlow部署教程:Ubuntu24.04
ai·大模型·知识图谱·知识库·rag
阿里-于怀3 小时前
Nacos Skill Registry: 面向个人场景的Skill中心实践
阿里云·云原生·nacos·agent·skills