Claude Adapter
能力与原理
让后续 agent 快速理解 Eyrie 如何把统一的 agent session/run/event 接口,接到本机 claude CLI:能做什么、靠什么机制工作、改哪里最安全。
一句话
Claude adapter 是 daemon 内部的 AgentProvider 实现:它把 Eyrie 统一的 agent session/run/event 接口,翻译成本机 claude CLI 的 stream-json + stdio 控制协议。
范围:本文描述 Claude adapter 子系统本身。更细的代码索引见当前产品 worktree 中的 docs/claude-code-provider.md。
AgentProvider · AgentRunner · AgentProviderEvent
命令行参数 · stdout stream-json · permission hook · control_request / control_response
runner 把事件交给 SessionSink,由 sink 持久化后再广播
已具备的能力
| 能力 | 当前含义 |
|---|---|
| Provider 注册 | 内置 provider id 是 builtin-claude-code,kind 是 claude-code,transport 是 stdio。 |
| 可用性探测 | 用 claude --version 做有界探测;Windows 默认 claude.cmd,其他平台默认 claude。 |
| Turn 执行 | 把用户文本/图片输入转成 Claude stream-json user message,写入 stdin。 |
| Resume | 已有 providerSessionId 时用 --resume <id> 启动 Claude;可用 resumeAtMessageId 建立首个 resumed turn 的边界。 |
| 输出翻译 | 把 Claude stdout 翻成 message.delta、tool.started、usage.updated、run.completed 等统一事件。 |
| Approval | Claude can_use_tool 普通工具请求会变成 approval.requested,用户选择后写回 control_response。 |
| AskUserQuestion | Claude AskUserQuestion 会变成 Eyrie 的 input.requested,用户回答后写回 Claude。 |
| Slash control | 支持 compact、clear、review、security-review、init、pr-comments、context、cost。 |
| Interrupt | runner 可以通过 Claude control message 请求中断,必要时进入进程树 teardown。 |
| Token usage | Claude usage payload 会映射成 usage.updated。 |
| 持久化事件 | 所有 provider event 经 SessionSink 和 event-codec.ts 落库,再通过事件流发给订阅者。 |
能力边界:Claude provider 对外报告 resume、interrupt、token-usage、approval;不报告 load、structured-diff、fork、rollback。AgentService.startTurn(...)、runControl(...)、respondToApproval(...) 是内部 service/runner 能力;当前公开 tRPC 面主要是 agent.events、providers.listModels、inputRequests.respond。
工作原理
主路径可以压缩成两条线。
IN用户输入进 Claude
OUTClaude 输出回 Eyrie
关键原则
Claude 原生 JSON 只在 apps/daemon/src/agent/providers/claude/ 内部出现。service、repository、UI 不理解 Claude 协议。
SessionSink 先调用 repository 持久化,成功后才 publish。订阅者断线重连时可以靠 sessionSeq 补事件。
进程、stdout loop、pending approvals、pending input requests、resume cursor 都由 ClaudeRunner 管。
run.completed 如何改变 run/session/request 状态,不写在 adapter 里,而在 event-codec.ts 和 repository projection 里。
启动与配置
normalizeClaudeProviderConfig(...) 先校验配置,再允许启动进程。它会拒绝未知字段,也会拒绝覆盖 adapter 自己必须控制的 flag。
adapter 固定加的 Claude 参数
-p
--input-format stream-json
--output-format stream-json
--verbose
--include-partial-messages
--permission-prompt-tool=stdio
可配置项(走 typed config,不要塞进 raw extraArgs)
Turn 和 Resume
fresh session 的特殊点是:Eyrie 一开始还不知道 Claude native session id。runner 会先缓存普通业务事件,等 stdout 里捕获 providerSessionId 后,先发 run.started,再 flush 之前缓存的事件。
resumed session 已经有 providerSessionId,所以可以先发 run.started。如果 resume handle 带 resumeAtMessageId,第一条 resumed user message 会把它作为 parent_tool_use_id 发给 Claude;之后这个边界会被清掉。
这个设计保证
- run 一开始就能记录 provider-native session。
- 之后可以用
--resume <providerSessionId>继续同一 Claude 会话。 - 事件顺序对 UI 和 replay 都稳定。
Approval 与 AskUserQuestion
Claude 的 permission prompt 从 stdout 进来,形状是 control_request.can_use_tool。
普通工具Bash / Edit / Read / ...
AskUserQuestion结构化提问
注意两点:持久化和广播只保存 sensitive answer 的脱敏摘要;发回 Claude 的答案必须是未脱敏版本,否则 Claude 无法继续执行。
Control Command
Claude slash commands 在 commands.ts 声明。ClaudeRunner.runControl(...) 把 Eyrie command 转成类似 /compact 的 user message,写进同一个 Claude stdin,再收集 stdout 直到 terminal result。
- 成功的
clear除了control.completed,还会发context.cleared。 - 未知 command 不写给 Claude,只产生
control.completed(status: unsupported)。
状态机要点
一次 session 只能有一个 active run
repo.beginTurn(...) 在事务里创建 run 并标记 session active。ClaudeRunner 也会拒绝并发 startTurn(...)。
request resolution 必须经过 resolving
resolving 的意义是:用户已经提交响应,但 service 还在尝试把响应送到 provider。只有写回 provider 成功,数据库才进入 accepted/approved 这类终态。
resolution event 归属原 run
用户可能在后续 turn 已经开始后才回答旧 approval/input request。approval.resolved 和 input.resolved 必须挂到请求所属的原 run,而不是当前 sink 的 live run。
lifecycle cancel 和 send failure 不同
daemon restart、runner crash、interrupt 这类生命周期清理可以直接 UPDATE pending rows,因为可能已经没有 live sink。用户响应发送失败则应该记录成 send_failed,因为这是一次活跃响应路径的失败。
读代码顺序
先读共享边界
apps/daemon/src/agent/types.tsapps/daemon/src/agent/service.tsapps/daemon/src/agent/runner-manager.tsapps/daemon/src/agent/session-sink.tsapps/daemon/src/agent/event-codec.ts
再读 Claude adapter
apps/daemon/src/agent/providers/claude/module.tsapps/daemon/src/agent/providers/claude/provider.tsapps/daemon/src/agent/providers/claude/config.tsapps/daemon/src/agent/providers/claude/process.tsapps/daemon/src/agent/providers/claude/input.tsapps/daemon/src/agent/providers/claude/translator.tsapps/daemon/src/agent/providers/claude/control-protocol.tsapps/daemon/src/agent/providers/claude/commands.tsapps/daemon/src/agent/providers/claude/runner.ts
runner.ts 最后读。它是组合点,不是最好的第一入口。
改动入口
| 想改什么 | 从哪里开始 |
|---|---|
| provider metadata / capability | config.ts、module.ts、provider.ts |
| Claude 启动参数 | config.ts、process.ts、spawn.ts |
| 文本/图片输入 | input.ts、service.ts、API schema |
| stdout 普通消息翻译 | translator.ts |
| approval / AskUserQuestion | control-protocol.ts、runner.ts、service.ts、event-codec.ts |
| slash command | commands.ts、runner.ts |
| run/session/request 状态副作用 | event-codec.ts、drizzle-repository.ts |
| 公开 tRPC API | packages/api/src/schemas.ts、packages/api/src/services.ts、packages/api/src/trpc.ts、apps/daemon/src/trpc/services.ts |
真实行为测试覆盖
读完“改动入口”后,可以把测试当成回归边界来读:它们不是按文件名凑覆盖,而是锁住调用者能观察到的结果。这里的“结果”包括 CLI 参数、stdin/stdout 协议、事件、数据库状态投影、tRPC 返回值和错误码。
覆盖 provider id、transport、capability、session config 字段、非法配置拒绝、availability probe,以及 fresh/resume command 的参数形状。
覆盖 Windows claude.cmd 启动、批处理参数安全、环境变量合并、stdout/stderr 读取、早期 spawn 失败诊断,以及终止时的清理顺序。
覆盖启动参数如何进入 live runner、resume 边界如何只用于第一轮、session id 到达前如何缓冲事件、超时/早退如何变成 failed completion、并发 turn 如何被拒绝、文本和图片输入如何进入 stdin。
覆盖 approval、AskUserQuestion、ExitPlanMode、slash command、context clear 投影、重复输出去重、interrupt 后的 pending waiter 清理,以及过期 provider response 不应影响当前 run。
| 真实行为 | 主要测试锚点 | 读者应该看懂什么 |
|---|---|---|
| 公开 provider 表面只暴露已经接通的能力,启动配置不能绕过 adapter 控制的协议参数。 | tests/claude-provider.test.ts |
改 capability、session config 或命令参数时,测试会检查外部调用者看到的 metadata 和实际生成的 Claude command 是否一致。 |
本机 claude 进程按平台安全启动,并且 stdout、stderr、exit、kill 的行为都有确定结果。 |
tests/claude-process.test.ts |
改 process.ts 时,不只要能 spawn,还要保住 Windows batch 参数、错误诊断、输出流消费和进程树清理。 |
一次 turn 从用户输入进入 runner,经过 stream-json 写入,再由 stdout 事件推动 run.started、message delta 和 run.completed。 |
tests/claude-runner-session.test.tstests/claude-runner-turn.test.ts |
读这些测试可以看到普通 turn、resume turn、初始化超时、早期退出、写入失败、图片路径校验和并发拒绝这些主路径边界。 |
| approval 和 AskUserQuestion 的用户响应先占用请求,再送到 provider,最后通过事件落到终态。 | tests/claude-runner-control.test.tstests/agent-service-methods.test.ts |
测试守住 pending → resolving → terminal,也守住“响应事件属于原 run,而不是后来变成 current 的 run”。敏感答案会给 provider 使用,但存储和广播只保留脱敏结果。 |
| control command 走 live Claude stream,成功、失败、interrupt、unknown command 和 stale response 都有可观察结果。 | tests/claude-runner-control.test.ts |
改 commands.ts 或 control protocol 时,要确认 slash command 输出、context-cleared projection、控制 run 终态和 pending 请求清理仍然成立。 |
| 事件编码把 provider event 变成数据库行、外键前置行和状态投影,并且 replay 时以数据库行字段为准。 | tests/agent-event-codec.test.ts |
改 event-codec.ts 时,要先看事件 payload、projection、FK prerequisite 和敏感字段落点,避免破坏落库后的 session/run/request 状态。 |
| 公开 tRPC 输入响应接口会收窄 accept、decline、cancel,并把 missing、跨 session、非 pending 请求映射成稳定错误。 | tests/input-requests-trpc.test.ts |
改 API schema 或 service 边界时,测试会检查客户端看到的成功返回和错误码,而不是只检查内部函数能不能跑。 |
最小心智模型
记住这四句话就够用:
- Claude adapter 是协议翻译器,不是业务状态中心。
ClaudeRunner管 live process;SessionSink管事件落库和广播。- approval/input request 的用户响应先
pending → resolving,送达 provider 后才进入终态。 - 改数据库副作用先看
event-codec.ts,改 Claude 协议先看process.ts/translator.ts/control-protocol.ts,最后再进runner.ts。