Files
claude-code/docs/outline-output/design/12-acp-bridge-daemon.md
2026-06-15 16:51:29 +08:00

228 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第十二章ACP / Bridge / Daemon —— 三个长驻模式的接线
> 三个 feature-gated 的长驻模式,各自用不同策略共享同一个 query loop。
## 为什么长驻模式是 feature-gated 的
ACPAgent Client Protocol、BridgeRemote Control、Daemonsupervisor`cli.tsx` 的 fast-path 优先级链中各自占据一个分支,全部被 feature flag 守卫。打开 `src/entrypoints/cli.tsx`,你会看到 `if (feature('BRIDGE_MODE'))` 守着 bridge/remote-control 分支,`if (feature('DAEMON'))` 守着 daemon 子命令,`if (feature('ACP'))` 守着 `--acp` 入口。
这不是因为这三个模式"功能不完善所以默认关闭"——实际上它们都已经恢复并且功能完整。根本原因是**启动延迟**。每个长驻模式都需要导入重量级依赖JWT 工具、WebSocket 传输、会话管理),如果把这些 import 放在 fast-path 链之前,`claude --version` 的启动时间会被拖慢。feature flag 让 Bun 编译器在构建时通过 DCEDead Code Elimination把未启用的分支整棵剪掉。
**反事实推演**:如果不做 feature gate`BRIDGE_MODE` 分支会强制每个 CLI 进程都 import `jwtUtils.ts``sessionRunner.ts``workSecret.ts` 等 bridge 模块。在第二章Code Splitting已经解释过JSC 会全量解析 import 的字节码。`--version` 的 RSS 从 35MB 暴涨回去,这不是理论推演,而是已经发生过的现实。
## ACP Agent把 QueryEngine 包装成协议实现
ACP 的核心在 `src/services/acp/agent.ts`。打开这个文件,你会看到一个 `AcpAgent` 类实现了 `@agentclientprotocol/sdk``Agent` 接口——`initialize``authenticate``newSession``prompt``cancel` 等方法一应俱全。
设计上最值得注意的是:**ACP 没有自己的 query loop它直接复用 `QueryEngine`**。打开 `src/services/acp/agent.ts:585`,你会看到:
```ts
const queryEngine = new QueryEngine(engineConfig)
```
ACP 的 `prompt()` 方法(`agent.ts:308`)调用的是 `session.queryEngine.submitMessage(promptInput)`——和 REPL 屏幕、pipe 模式用的是同一个 `submitMessage`。区别在于消息消费方式REPL 把 `SDKMessage` yield 给 Ink 组件渲染ACP 把它们转发给 ACP 协议的 `sessionUpdate` 通知。
### 消息转译层bridge.ts
`src/services/acp/bridge.ts` 是 ACP 最厚的文件(~1000 行),职责单一但沉重:**把 Claude Code 内部的 `SDKMessage` 类型转译成 ACP 协议的 `SessionUpdate`**。它定义了一个本地判别联合类型 `BridgeSDKMessage``bridge.ts:168`),覆盖 9 种消息形态system、result、assistant、stream_event、user、progress、tool_use_summary、attachment、compact_boundary。
打开 `bridge.ts:191`,你会看到 `toolInfoFromToolUse` 函数根据工具名做 switch-case把内部工具调用元数据转译成 ACP 的 `ToolCallContent` 格式。这种"内联 switch"在反编译产物中很常见——原始代码可能用的是策略模式,但反编译后退化成了 switch-case。
### 权限流水线createAcpCanUseTool
`src/services/acp/permissions.ts` 导出的 `createAcpCanUseTool` 是整个 ACP 权限系统的接线点。打开 `permissions.ts:32`,你会看到它返回一个 `CanUseToolFn`——和 REPL 的权限回调签名完全一致。但在内部,它做了三层处理:
1. **本地管道优先**`permissions.ts:79`):先跑 `hasPermissionsToUseTool`,这是 Claude Code 内置的权限规则引擎deny rules、allow rules、tool-specific checks。如果本地管道直接 allow 或 deny就不打扰远程客户端。
2. **客户端委托**`permissions.ts:130`):如果本地管道返回 `ask`(需要用户确认),才通过 `conn.requestPermission()` 委托给 ACP 客户端。
3. **ExitPlanMode 特殊处理**`permissions.ts:57`):退出计划模式时不是简单的 allow/deny而是提供多选项auto、acceptEdits、default、plan如果可用还包括 bypassPermissions
这种设计解决了一个关键问题:**ACP 客户端不应该为每个工具调用都弹权限对话框**。本地规则(如 `.claude/settings.json` 中的 `allow` 规则应该静默放行只有不确定的情况才打扰用户。如果不这么做RCS Web UI 上每秒都会弹出几个权限确认,体验完全不可用。
### bypassPermissions 的三层防护
ACP 的 `bypassPermissions` 模式有严格的三层防护,打开 `agent.ts:1005` 你会看到:
```ts
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
return (
isProcessBypassPermissionModeAvailable() &&
(isAcpBypassLocallyEnabled() ||
isSettingsBypassPermissionMode(settingsMode))
)
}
```
三层分别是:进程级(非 root/sandbox 环境检测,`agent.ts:1013`)、环境变量级(`ACP_PERMISSION_MODE=bypassPermissions``CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS=1`)、配置级(`settings.json``permissions.defaultMode=bypassPermissions`)。三层全部满足才开放。
**反事实推演**:如果不做这三层防护,任何能连接到 ACP agent 的客户端(包括远程 RCS 用户)都能直接绕过所有权限检查,执行任意 shell 命令。在 daemon 场景下这尤其危险——daemon worker 以宿主用户身份运行。
### Prompt 队列化
`agent.ts:278` 实现了一个简单的 prompt 队列:如果当前有 prompt 正在运行,新的 prompt 会被推入 `pendingQueue`,等待当前 prompt 完成后 FIFO 消费。`agent.ts:1054``compactPendingQueue` 函数在队列头部消费超过 1024 个且 head 指针超过长度一半时做数组切片压缩。
这个设计解决的是**并发 prompt 竞争**:如果 ACP 客户端在 Claude 还在处理上一条消息时发了新消息,没有队列的话会导致 `QueryEngine` 的 abort controller 状态混乱(`agent.ts:302``resetAbortController` 注释解释了这个问题)。
### entry.ts为什么重定向 console.log
打开 `src/services/acp/entry.ts:44`,你会看到一行诡异的代码:
```ts
console.log = console.error
```
ACP 通过 `process.stdin` / `process.stdout` 与客户端通信(`entry.ts:36` 创建 `ndJsonStream`),所以 stdout 必须完全留给 ACP 协议消息。任何 `console.log` 调用如果写到 stdout都会被客户端解析为 ACP 消息,导致协议错误。因此所有 console 输出都被重定向到 stderr。
**反事实推演**:如果遗漏了这行,任何 debug 级别的 `console.log` 都会在 Zed 编辑器或 RCS Web UI 上显示为不可解析的消息,触发连接断开。
## acp-linkWebSocket 代理的"进程透明"
`packages/acp-link` 是一个独立的 npm 包,不依赖 Claude Code 源码。它的职责是:**让任何 ACP 客户端(如 Zed 编辑器)通过 WebSocket 连接到一个 ACP agent 进程**。
打开 `packages/acp-link/src/server.ts:279`,你会看到 acp-link 每次 WebSocket `connect` 时都会 spawn 一个 agent 子进程:
```ts
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
cwd: AGENT_CWD,
stdio: ['pipe', 'pipe', 'inherit'],
})
```
然后通过 Node.js 的 `Readable.toWeb` / `Writable.toWeb` 把子进程的 stdin/stdout 转成 Web Stream交给 `@agentclientprotocol/sdk``ndJsonStream`
```ts
const input = Writable.toWeb(agentProcess.stdin!)
const output = Readable.toWeb(agentProcess.stdout!)
const stream = acp.ndJsonStream(input, output)
const connection = new acp.ClientSideConnection(...)
```
对 ACP 客户端来说,它以为自己在和一个原生 ACP 服务通信——它不知道中间隔了一层 WebSocket 代理和一个被 spawn 的子进程。这就是"进程透明"的含义。
### 权限传递:环境变量注入
acp-link 的 `buildAgentEnv()``server.ts:1031`)把 `ACP_PERMISSION_MODE` 注入到子进程的环境变量中。子进程(即 Claude Code ACP agent在启动时读取这个环境变量来决定默认权限模式。
这种设计让 acp-link 可以在启动时通过 CLI 参数 `--permission-mode auto` 或环境变量 `ACP_PERMISSION_MODE` 统一设置权限模式,而不需要每个 ACP 客户端在 `newSession` 时分别指定。权限模式解析链(`server.ts:986`):客户端请求的 mode > acp-link 默认 mode环境变量/CLI 参数)> agent 内部默认。对于 `bypassPermissions`,则强制要求 acp-link 本地已启用该模式(`server.ts:1005`),否则直接抛异常。
### RCS 集成REST 注册 + WS identify 两步流程
打开 `packages/acp-link/src/rcs-upstream.ts:66`,你会看到 `registerViaRest` 方法先通过 REST API `POST /v1/environments/bridge` 注册,获取 `environment_id`,然后建立 WebSocket 连接发送 `identify` 消息(`rcs-upstream.ts:143`)。
两步流程的设计意图REST 注册是无状态的,可以用 API token 认证WebSocket identify 则是在已建立的 WS 连接上用 `Sec-WebSocket-Protocol` header 传递 token`ws-auth.ts:9``encodeWebSocketAuthProtocol` 把 token 编码成 `rcs.auth.<base64url>` 格式)。两者分离的好处是 WebSocket 可以断线重连而不需要重新注册(只要 `environment_id` 还有效)。
打开 `ws-auth.ts:60`,你会看到 token 比较用了 `timingSafeEqual`
```ts
return timingSafeEqual(sha256(providedToken), sha256(expectedToken))
```
先 SHA-256 再比较,防止 timing attack 泄漏 token 长度信息。
### 虚拟 WSContextrelay 的巧妙设计
`server.ts:120``createRelayWs()` 创建了一个假的 `WSContext` 对象——`send` 是 no-op`readyState` 永远返回 1OPEN。这是因为 RCS relay 消息不需要发送到本地 WebSocket而是通过 `rcsUpstream.send()` 发送到 RCS 服务器。虚拟 WSContext 让 relay 消息可以复用 `dispatchClientMessage` 的完整分发逻辑,而不需要为 relay 写一套独立的处理代码。
### 前端重连不重启进程
`server.ts:252` 有一个容易被忽略但很精巧的设计:
```ts
if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) {
logAgent.info('already connected, resending status')
send(ws, 'status', { connected: true, ... })
return
}
```
当 Zed 编辑器因网络波动断开 WebSocket 后重连时acp-link 检查 agent 进程是否还活着。如果进程还健康,只重新发送 status 消息,不重启进程。这避免了每次前端重连都重启 agent 的浪费——agent 进程可能正在执行一个长时间任务。
## Bridge 模式Anthropic 原版的"云端会话调度"
Bridge 模式(`src/bridge/`)是 Anthropic 原版 Claude Code 的 Remote Control 实现——与 ACP 不同,它是围绕 Anthropic 云端 API 设计的。ACP 是后来添加的开放协议Bridge 则是原始的封闭实现。
打开 `src/bridge/bridgeMain.ts:1`,第一行就是 `import { feature } from 'bun:bundle'`——整个 bridge 目录都是 feature-gated 的。Bridge 的核心是 `runBridgeLoop` 函数(`bridgeMain.ts:140`),它实现了一个经典的 poll-dispatch 模式:
1. 向 Anthropic 云端 API 轮询待处理的 work item`bridgeMain.ts` 中的 poll 循环)
2. 收到 work 后 spawn 一个子进程执行(通过 `SessionSpawner`
3. 心跳活跃的 work item`bridgeMain.ts` 的 heartbeat 循环)
4. work 完成后 ack 回云端
Bridge 使用 JWT 认证(`jwtUtils.ts` 中的 `createTokenRefreshScheduler`token 刷新调度器定期刷新 access token。Work secret`workSecret.ts``decodeWorkSecret`)包含编码后的 JWT用于 ack 和心跳的认证。
### Daemon 与 Bridge 的关系
Daemon`src/daemon/`)是 Bridge 的 supervisor。打开 `src/daemon/main.ts:52``daemonMain` 函数处理 `claude daemon start/stop/status/bg/attach/logs/kill` 子命令。其中 `start` 子命令启动 supervisor 循环(`main.ts:230`supervisor 的唯一默认 worker 是 `remoteControl`
```ts
const workers: WorkerState[] = [{
kind: 'remoteControl',
process: null,
backoffMs: BACKOFF_INITIAL_MS,
failureCount: 0,
parked: false,
...
}]
```
每个 worker 通过 `buildCliLaunch` + `spawnCli` 启动子进程,传入 `--daemon-worker=remoteControl` 参数。子进程入口在 `src/daemon/workerRegistry.ts:26`,映射到 `runRemoteControlWorker()``workerRegistry.ts:57`),后者调用 `runBridgeHeadless(opts, controller.signal)`——最终进入 Bridge 的 headless 循环。
**为什么 Daemon 不直接跑 Bridge 循环?** 因为 Daemon 需要监控 worker 进程的健康状态,在崩溃时重启。如果 Bridge 循环直接在 supervisor 进程里跑supervisor 崩溃时没有更高层的恢复机制。worker 进程隔离让 supervisor 可以通过退出码判断是永久错误(`EXIT_CODE_PERMANENT = 78`,来自 `sysexits.h``EX_CONFIG`)还是可重试的临时错误。
### Worker 崩溃的指数退避与快速失败 park
打开 `src/daemon/main.ts:377`,你会看到 worker 退出处理逻辑。快速失败检测(`main.ts:394`):如果 worker 在启动后 10 秒内退出,计入 `failureCount`,连续 5 次快速失败后 park 该 worker不再重启。正常退出的 worker 则重置 `failureCount``backoffMs`
退避策略是标准的指数退避(`main.ts:423`):初始 2 秒,倍数 2上限 120 秒。加上随机 jitter 防止多个 worker 同时重启。
**反事实推演**:如果没有 park 机制,一个配置错误的 worker比如 CWD 不存在)会无限循环 spawn-crash-restart持续消耗 CPU 和日志空间。`EXIT_CODE_PERMANENT` 让 worker 可以主动声明"别重启我了"。
### Daemon 状态持久化
`src/daemon/state.ts` 把 daemon 的 PID、CWD、启动时间、worker 类型写入 `~/.claude/daemon/remote-control.json`。另一个 CLI 进程(比如 `claude daemon status`)通过读取这个文件并用 `process.kill(pid, 0)` 检测进程是否存活来查询状态。如果 PID 已死但文件还在,自动清理(`state.ts:99` 的 stale 检测)。
## 自托管 RCS三层架构的交汇点
`packages/remote-control-server/`(简称 RCS是自托管的 Remote Control Server提供了完整的 Web UI 控制面板。打开 `packages/remote-control-server/src/index.ts:1`,你会看到一个 Hono 应用注册了四组路由:
- **v1 路由**`/v1/environments/bridge`——REST 注册端点,供 acp-link 调用
- **v2 路由**`/v2/code-sessions``/v2/worker`——Worker API供 Bridge 模式使用
- **acp 路由**`/acp/ws`——ACP WebSocket 端点,供 acp-link 连接
- **web 路由**`/web/*`——React 19 + Vite + Radix UI 构建的 Web UI
RCS 的核心传输层在 `packages/remote-control-server/src/transport/`,有三个 WebSocket handler`ws-handler.ts`(原始 Bridge WS`acp-ws-handler.ts`ACP 协议 WS`acp-relay-handler.ts`ACP relay转发现有 ACP 连接的消息)。这三个 handler 共享同一个 `event-bus.ts` 事件总线。
RCS 是三个长驻模式的交汇点:
- **Bridge 模式**通过 v2 Worker API 注册和通信
- **ACP 模式**通过 acp-link 代理注册和通信
- **Daemon**可以管理运行 RCS 或 Bridge worker 的进程
## 三个模式的横向对比
| 维度 | ACP | Bridge | Daemon |
|------|-----|--------|--------|
| 协议 | 开放 ACP 协议ndjson over stdio | Anthropic 私有 REST+WS API | 进程管理spawn + SIGTERM |
| 入口 | `--acp` flag | `BRIDGE_MODE` feature | `DAEMON` feature |
| 通信方式 | stdin/stdout ndjson | HTTP REST + WebSocket | 环境变量 + stdio pipe |
| 认证 | 无(自托管) | JWT + OAuth | 本地文件状态 |
| query 复用 | 直接 new QueryEngine | spawn 子进程跑 REPL/bridge | spawn 子进程跑 worker |
| 超时管理 | prompt 队列 + cancelGeneration | session timeout + work secret | 快速失败 park + 退避 |
### 为什么 ACP 不 spawn 子进程
Bridge 和 Daemon 都通过 spawn 子进程来隔离工作负载,但 ACP 直接在同一进程内创建 `QueryEngine` 实例。原因是ACP 的通信通道是 stdin/stdout——它本身就是被设计为"被某个 IDE 或代理 spawn 的子进程"。如果 ACP 再 spawn 子进程,就变成了两层子进程嵌套,通信复杂度倍增。
### 为什么 Bridge 需要 poll 循环而不是 push
Bridge 的设计受制于 Anthropic 云端 API 的架构——work item 存在云端队列中,本地 bridge 需要主动轮询。这不是技术选择而是架构约束。ACP 模式则不同,客户端直接 push prompt 给 agent不需要云端中间层。
## environment-runner三条线的交汇点
`claude environment-runner` / `claude self-hosted-runner`BYOC runner是产品大纲第十一章提到的功能。它是 ACP、Bridge、CI 三条线的交汇点:在 CI 环境中runner 可以用 ACP 协议暴露 Claude Code 能力,也可以通过 Bridge 模式连接 Anthropic 云端。三者共享的底层是同一个 `QueryEngine`,区别只在于谁发起 promptCI 脚本、IDE 客户端、云端调度器和权限如何传递环境变量、JWT、ACP permission 回调)。
## 延伸阅读
- 想看 QueryEngine 的完整 API见 [第四章:核心 Query Loop](./04-query-loop.md)
- 想看 feature flag 的 DCE 机制如何让这三个模式在构建时被剪掉,见 [第五章Feature Flag 系统的三个硬约束](./05-feature-flags.md)
- 想看权限系统的完整规则引擎,见 [第十一章:三层状态管理](./11-state-management.md) 中的 `AppState.toolPermissionContext`
- 想看 Code Splitting 如何让长驻模式的开销不影响快速路径,见 [第一章Code Splitting 不是优化,是生存需求](./01-code-splitting.md)