Pull Request · Eyrie daemon · agent layer · Claude Code provider
phase2-claude-main main

feat(daemon): add Claude Code stdio provider adapter

给 daemon 装上首个内建 agent provider —— Claude Code。把本地 claude CLI 当成长驻的 stream-json stdio 子进程驱动,并将其协议翻译成 Eyrie 归一化的 AgentProviderEvent。本 PR 只实现既有契约,不改契约本身。

6
commits
+3,829
行 / −0
11
新文件 / 0 改动
79
tests · 77+2

01 它落在哪一层

Eyrie agent 层是三层架构。本 PR 只动最底层的 provider 翻译:把 CLI 原生协议译成归一化事件。它不碰 run id、不写库、不发 git diff、不做审批——那些都归 service 层。

调用 ↓ events ↑ Routes / SSE / CLI 只看归一化事件,不知道是哪个 agent 产出 AgentService 编排 + 持久化 + 事件 fan-out(本 PR 不动,仍是 TODO 骨架) AgentProvider · ClaudeProvider / ClaudeRunner 协议翻译器:stream-json ↔ AgentProviderEvent ← 本 PR claude CLI 真正的 agent 进程(stdio · stream-json)
边界:run / session / provider 上下文由 service 在 sink 里附加;runner 只管 sink.emit(event)

02 类图

三个跨模块接口(AgentProvider / AgentRunner / AgentEventSink)+ 进程封装接口 ClaudeProcess + 工厂类型 SpawnClaudeProcessClaudeProvider 只造 runner;ClaudeRunner 依赖另外两者,自己只实现 AgentRunner

AgentProvider «interface» getCapabilities() createRunner(cfg, sink) ClaudeProvider id · kind · transport caps: resume·interrupt·token AgentRunner «interface» startTurn(input) interrupt() respondToApproval() describePersistence() dispose() ClaudeRunner −state(状态机) −providerSessionId AgentEventSink «interface» emit(event) SpawnClaudeProcess «function type» (command) → ClaudeProcess ClaudeProcess «interface» stdout · stderr writeLine() · interrupt() kill() · waitForExit() waitForImmediateExit() implements implements creates emits ▸ calls returns owns ◆
implements(虚线+空心三角) creates / emits / calls owns / returns

03 运行时状态机

runner 用判别式联合表达生命周期,永远无法表示「有活动 turn 却无进程」。这个 PR 的核心修复就在这里:完成 / 干净中断后回 idle 复用同一进程(绿),而非销毁;只有硬杀 / EOF 才回 unstarted 下次 respawn(橙虚线)。

completed → idle ⟳ 复用 clean exit · cancelled → idle ⟳ 复用 EOF / 失败 → respawn 2s 超时 → 硬杀 → respawn spawn + readers startTurn interrupt() dispose() unstarted 无活进程 idle 可复用 running 活动 turn interrupting 正在中断 disposed 终态
完成/干净中断 → idle 复用 EOF/失败/硬杀 → unstarted respawn 正常生命周期推进
idle / running / interrupting 任一活动态都可 dispose() 进终态;图中只画代表边。

04 三条主流程 · 时序

同一套 4 条 lifeline,三种走法。关键差异:Fresh 要缓冲到 session-id 才发 run.startedResume 已知 id,立即发、直发;Interrupt 分干净退出与 2s 超时硬杀两支。

Service ClaudeRunner claude CLI Sink startTurn(input) spawn + writeLine(user msg) stdout 行 (stream-json) 缓冲业务事件 init 行 · session_id emit(run.started) — 恰好一次 flush 缓冲事件(原序) delta / tool_use / tool_result 行 emit(message.delta · tool.*) result 行 emit(run.completed) → state = idle
Service ClaudeRunner claude CLI Sink createRunner(…, { resume }) · startTurn session_id 已缓存 emit(run.started) — 立即,无需等 init spawn --resume <id> + writeLine stdout 行(delta / tool / result) emit(events) — 直发,不缓冲 result 行 emit(run.completed) → idle
Service ClaudeRunner claude CLI Sink interrupt() — 并发调用合流到同一 promise control_request{interrupt}(stdin)→ state = interrupting alt [clean] 进程 2s 内退出 final result 行 remap → cancelled emit(run.completed{cancelled}) → idle ⟳ [timeout] 超 2s 未结束 kill() 进程组 → waitForExit() emit(run.completed{cancelled}) → unstarted
同步调用 stdout 流 / 返回 emit → Sink(归一化事件)

05 事件缓冲时间线

新 turn 里 到达序 ≠ 发出序。session-id(init 行)到达前的业务事件先进 pendingEvents 缓冲;一旦拿到 id,先发一次 run.started,再按原序 flush 缓冲,之后的行直发。保证 run.started 永远第一、run.completed 永远最后

stdout 到达序 Sink 发出序 pendingEvents 缓冲 delta₁(text) delta₂(tool_use) init · session_id delta₃(live) result run.started message.delta(flush) tool.started(flush) message.delta(直发) run.completed 触发 ▸ 抢到首位
左侧 delta₁/delta₂init 之前到达,被缓冲;run.started 经由 init 触发后插到队首,再 flush 缓冲,故连线交叉。

06 translator 映射

translateClaudeLine()纯函数:一行 stream-json → 0..N 个归一化事件。对 Claude schema 增长保持容忍——抽已知字段、原样留 raw、未知形状忽略(除非命中失败启发式)。

Claude stream-json 形状 AgentProviderEvent system/init · session_id content_block_delta · text_delta content_block_delta · thinking_delta assistant · text content assistant · is_plan: true assistant · tool_use user · tool_result result · usage system/compact result · subtype run.started (+providerSessionId) message.delta · assistant message.delta · reasoning message.completed plan.updated tool.started tool.completed usage.updated context.compacted run.completed
生命周期 · init/result 消息文本 · delta/completed 工具 · tool_use/result 元信息 · usage/plan/compact
interrupt 进行中时,runner 把 result → run.completed 覆写为 cancelled;translator 本身不感知中断,只产原始形状。

07 失败隔离

四类异常各有归宿,但都不让 runner 卡死或泄漏进程。sink 失败是特例:sink 已坏,不能再经它发终态——只内部记录、直接 kill + waitForExit 后回 unstarted。其余三类收敛成一条合成的 run.completed{failed},带末 4 KB stderr 尾巴。

malformed stream-json 提前 EOF(running 中) 异常退出码 / signal sink emit 失败 收敛 + 4 KB stderr 尾巴 run.completed{failed} 经 sink 上报 → 状态 idle/unstarted SinkEmitError:不再经 sink 发事件 kill() → waitForExit() → unstarted(避免递归 emit)
不变量:run.started 必先于任何 run.completed——合成失败也经 ensureRunStartedBeforeCompletion 守卫补发。

08 落地清单 · 给 reviewer

6 commits · 全在 apps/daemon · 纯增量

featprocess / runner / translator + provider 入口
testfake-process 夹具 + 4 单测 + 1 集成
fix长/中断后 runner 留 idle 复用,去 per-turn 超时
refactor统一 run.started 顺序守卫
refactor共享 stderr limit、清理超时竞态
fix硬化 teardown:kill 升级、resume 写失败补发

5 源码模块 · src/agent/providers/claude/

index.tsClaudeProvider · caps 保守(无 approval)
errors.tstyped code:configuration/runtime/unsupported
process.tsargv 构造 · config 校验 · kill 升级
runner.tssession 级状态机,独占一进程
translator.ts纯函数 line → events

测试 · 77 单测 + 2 集成(CI 跳过)

套件n
claude-runner33
claude-translate22
claude-provider14
claude-process8
claude-integration(gated)2
CLAUDE_CODE_INTEGRATION=1 bunx vitest run \
  apps/daemon/tests/claude-integration.test.ts

对现有代码的影响

  • 纯增量:11 新文件,0 改动,+3,829 / −0
  • 运行时惰性:尚未被任何 registry / service 引用,合并不改可观测行为。
  • 无新依赖:仅 Node 内建,bun.lock 不变。
  • 0 冲突:与 main 的 client/scripts 改动零路径重叠。
⚑ review 重点 · 安全姿态

adapter 以 --permission-mode bypassPermissions 起进程、不声明 approval 能力,被拉起的 Claude 在 session 的 cwd 下无交互权限提示运行。这对无头 daemon 是有意为之,但它是 review 时最该掂量的一点;交互式审批是已记录的后续项。Unix 下 spawn detached,kill 路径覆盖整个进程组。

09 后续项 · 不在本 PR

合并方式:squash-merge,PR 标题成为落地 commit(.github/workflows/pr-title.yml)。本分支自 b1be059 起带 6 个 commit 叠在 main 之上。