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 + 不同的入口参数(perpetual、outboundOnly),让两个产品形态共享了 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 # 每个会话独立工作树
启动后的行为:
- 解析 CLI 参数,构建
BridgeConfig - 通过
createBridgeApiClient()创建 API 客户端 - 调用
registerBridgeEnvironment()注册环境 - 进入
pollLoop()------无限循环轮询工作 - 显示终端 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.ts → replBridge.ts 是 REPL 内嵌的桥接。当用户在正在进行的对话中输入 /remote-control:
- 检查准入(OAuth、GrowthBook、组织策略)
- 注册环境
- 将当前会话历史同步到服务端
- 建立双向消息通道------远程输入作为
user消息注入到当前 REPL 会话
与守护进程的关键差异:REPL 模式不需要 fork 子进程。Agent 就在当前进程中运行------远程消息作为额外的输入源注入到现有 REPL 的消息循环中。
两种模式对应了两种截然不同的产品体验 :守护进程模式是"无头机控制"------服务器的终端能力暴露给远程;REPL 内嵌模式是"会话共享"------正在进行的对话无缝扩展到手机。同一套 V1 架构,入口参数不同(perpetual、outboundOnly),产品形态完全不同。完整的调用全景见完整版第一章。
第二章:六步生命周期------为什么是六步
在深入每一步之前,先回答一个读者自然会问的问题:为什么是六步,不是五步,不是一步?
因为这六个步骤解决的是六个不同的分布式系统经典问题,每个都有独立的存在理由:
- 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_code 和 claude_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),
}
}
为什么要分离父进程和子进程? 两个原因:
- 故障隔离:子进程 OOM 或 crash 不影响守护进程。守护进程可以继续服务其他会话。
- 并发会话 :守护进程模式下,
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)
会话完成后:
-
stopWork :
POST /v1/environments/{env_id}/work/{work_id}/stop- 如果是正常完成,
force = false - 如果用户中断(Ctrl+C),
force = true,服务端会强制停止运行中的 Agent
- 如果是正常完成,
-
archiveSession :
POST /v1/sessions/{sessionId}/archive- 将会话从 claude.ai 的活跃列表移除
- 幂等操作------已归档的会话返回 409(不是错误)
-
deregisterEnvironment :
DELETE /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 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋