PR #42 · Claude Code Provider 审查报告

Eyrie daemon · apps/daemon/src/agent/providers/claude/ · 逐行深读 + Codex 复核 + 竞品 ref 交叉验证
PR head 52f89af base c9a97e6 (origin/main) src 9 文件 5328 行 + 测试 8 文件 4006 行 = 9334 行 ref vibe-kanban 4deb7ec · paseo edd5a99
⚖️ 方向对、不能按现状合并 —— 进程模型 + 审批核心做对了;问题是「提前造」(净删) + 「逆向没验证」(需真 CLI 实测) + 几个安全/上报取舍

01 一眼看懂

做对的
长活 stream-json runner、stdin 握手、structured interrupt、竞态/kill、审批核心 —— 竞品 ref 正面背书
主线问题 ①
抢跑契约:发现 slash / 导入原生 session / metadata 能力真要,但走 Claude 私有方法面绕过契约 —— 该接回 / 扩契约,不是全删
主线问题 ②
逆向没验证:hooks 表 / 输入回传形状 / slash 命令 / tool_use_result,需对真 claude 实测
附带
安全取舍(默认 bypass、永久 bypass、杀进程)+ UI 上报不可信(假 availability、空问题)
体量澄清(重要纠错):runner.ts 1809 行 大半不是过度建造。Claude headless -p --input-format stream-json + piped stdin 是长活、多轮、双向进程,runner 正确建模了它。真正可净删的是 hooks 表 + 重复 schema(图像路径是补 probe 接通优先,删为退路,见 §05);discovery.ts/sessions.ts要接回契约的能力实现,不是垃圾(见下文)。
体量量化(按本报告处置走,合入版预计瘦多少):src 端约 −1930 行 —— sessions.ts 726(本 PR 撤出,待发现契约落地回归)+ discovery.ts 628(留 ~90 行模型基线)+ config schema/metadata ~337(弃自造,留 normalizer + argv 校验)+ /context/cost 解析 ~135 + hooks 表 ~55 + 私有方法面 ~50;测试端约 −540 行(claude-sessions 190 + claude-discovery ~250 + 死面用例若干)。9334 → 约 6900 行;留下的重镇 runner.ts 1809 + claude-runner.test.ts 1746 属必要适配与核心覆盖,不算膨胀。

02 分层架构

分层其实很干净。问题不在「乱」,在「范围」和「地基」。从下往上:

daemon (只持有 AgentProvider 引用 → 只能调 3 个契约方法)
入口层  index.ts · errors.ts
▼  createRunner
大脑  runner.ts
5 态机 + 运行时
◀▶
控制/配置  commands.ts · config.ts
▼  spawn / stdio
传输  process.ts
翻译  translator.ts
真实 claude 子进程 (NDJSON stdout / stdin)
⤓ 旁挂的私有发现面(契约没留门、daemon 够不着):discovery.ts · sessions.ts · index.ts 8 个非契约方法 —— 能力要留,接线要重做(见 §05/§06)
// daemon 侧:只认契约引用 —— 这是它认识 provider 的全部方式
const provider: AgentProvider = await registry.getProvider(id)
provider.getCapabilities()   // ✓ 契约上有,调得到
provider.createRunner(...)   // ✓

// PR #42 的 ClaudeProvider(index.ts)—— 契约 3 项之外又挂了 8 个公开方法:
class ClaudeProvider implements AgentProvider {
  getCapabilities() {...}    // ✓ 契约
  getControlCommands() {...} // ✓ 契约
  createRunner() {...}       // ✓ 契约
  discoverSlashCommands() {...}      // ✗ 契约外 → daemon 持有的引用上根本没有这个方法
  discoverNativeSessions() {...}     // ✗ 背后是 sessions.ts 726 行,只从这里可达
  importNativeSession() {...}        // ✗
  getMetadata() / getAvailability() / discoverModels() / discoverAgents() / readMcpDiagnostic()
}
// git grep 实证:这 8 个方法在 daemon 生产代码里零调用方 —— 能力是对的,接线是死的

03 进程模型 —— 关键纠错

✗ 误区
-p headless = 跑一次就退,runner 在伪装 server」
✓ 实际(团队实测 + ref + 他自己的测试证实)
parent 持有 stdin pipe 即存活,一个进程跑多轮。runner 正确建模:一次 spawn、turn 间停 idle 复用、单 reader 跨生命周期
unstarted
ensureProcess
spawn 仅一次
idle
running
写 user msg
result
↺  turn 完成 → 回 idle(进程留活、同一 stdout reader 继续)→ 下一轮 startTurn 复用同进程,无需 --resume  (测试 claude-runner.test.ts:207 证实)
— 异常分支 —
进程中途崩
emit run.completed{failed}
resetToUnstarted
下轮 --resume 重开
--resume <sid> 只在重开时用(崩溃自愈 / 续旧会话),不是每轮都用
F2 / M1 待地基拍板:closed 只在 dispose() resolve、崩溃永不 resolve(进程死 → 自愈复用)。这是刻意设计,但顶到 RunnerManager 契约边界,是最 load-bearing 的取舍,需地基统一定。

04 控制协议 / 审批流

审批、输入、中断都挤在同一条 stdout/stdin 里(带内 JSON 请求/响应)。核心桥接有据可行(对齐团队实测 + SDK 类型),但 PR42 自己加塞的 hooks 表是多余的。

spawn(--permission-prompt-tool=stdio)
▼ stdin 握手
control_request
initialize (hooks:null)
control_request
set_permission_mode
user message
▼  CLI 原生权限模式门控(写操作触发)
◀ stdout
control_request(can_use_tool)
runner emit
approval.requested
UI 决策
stdin ▶
control_response
(allow/deny)
✗ PR42 还在 initialize 里塞了 hooks 表(PreToolUse + 凭空造的 callback id)→ 偏离实测基线、无接收端 → 该整块删,发 hooks:null
// PR 现在的 initialize(runner.ts:969,表来自 buildClaudeHooks :1731)
{ "subtype": "initialize", "hooks": { "PreToolUse": [{
    "matcher": "^(?!(Glob|Grep|Read|...)$).*",         // 手写工具名正则
    "hookCallbackIds": ["auto-approve-callback"] }] } } // ✗ 凭空造的回调 id
// ✗ 接收端 parseClaudeControlRequest 只认 can_use_tool,没有任何 hook 回调处理 ——
//   CLI 若真按表回调,请求会挂起;实际审批门控由 set_permission_mode 原生触发,这张表全程多余

// 团队 2026-05-21 实测基线(hands-on-notes:359)
{ "subtype": "initialize", "hooks": null }
核心桥接已实证:握手序列、can_use_tool 5 字段、PermissionResult = allow{updatedInput,updatedPermissions} | deny{message,interrupt} 逐条对齐团队 2026-05-21 实测 + sdk.d.ts:1861-1873,且 vibe-kanban 同套路。这块不是瞎栓。

05 逐文件:功能 + 处置 + 原因

KEEP 保留 KEEP-CORE 核保留 REWORK 重做 DELETE 净删 WIRE 提升进契约 PARTIAL 部分 VERIFY 待实测
index.ts111 行契约 3 项 KEEP8 方法 WIRE
职责:ClaudeProvider implements AgentProvider —— provider 工厂入口。
  • 契约 3 项(getCapabilities / getControlCommands / createRunner)+ id/kind/transport —— KEEP createRunner 干净(normalize → 拒 load replay → 取 providerSessionId → 委托)。
  • 另挂 8 个非契约 public 方法(getMetadata/getAvailability/discoverSlashCommands/discoverAgents/discoverModels/readMcpDiagnostic/discoverNativeSessions/importNativeSession)。
那 8 个方法 → 不是删功能,是接错线。 daemon 只持有 AgentProvider 引用,这些方法够不着 = 当前死代码。但能力是要的:5 项契约已有口子(metadata/availability/models/config → AgentAvailability;native resume → AgentPersistenceHandle)直接接;2 项契约缺方法(slash 发现、原生 session 发现)→ 契约方法已立项(spec 0008,owner 侧推进;见 §06,别在本 PR 自行扩契约)。
errors.ts48 行KEEP
职责:typed error —— 3 个码(configuration / runtime / unsupported-operation)+ builder + 结构化 reason/detail。
保留,且是加分项。 这正是地基该跨 provider 统一的错误分类范式(typed code + 结构化 reason/detail)。
process.ts624 行KEEP1 处 REWORK
职责:子进程封装 —— argv 构造、行式 stdout/stderr、stdin 写入、kill 阶梯。纯 plumbing,craft 高。
  • kill 升级(stdin.end → SIGTERM → SIGKILL 打进程组;Windows taskkill;ESRCH 吞;unref);早退窗口探测;写背压经 'error' 监听;CVE-aware 的 Windows .cmd 处理 —— 都是真功夫。
  • M5:argv 硬编码 --permission-mode bypassPermissions(386 行)。起步过门有据,但到 set_permission_mode 落地前那段窗口无沙箱。
REWORK(小):起步窗口无沙箱需收口(与 M8 默认权限策略一起改)。其余保留。
translator.ts366 行KEEP 架构REWORK 保真
职责:纯函数 translateClaudeLine —— stream-json 行 → AgentProviderEvent[]。结构漂亮、可独立测。
  • M2 双发:partial 发 message.delta + 末尾又发带全文的 message.completed,都不带 itemId 配对 → 消费者整条重复。
  • M3:usage 只读 input/output_tokens,丢 cache/context/model。
  • N3:只读 tool_result.content,丢消息级富结构 tool_use_result(stdout/stderr/interrupted)。
  • N5(待核):is_plan / occurredAt 是逆向字段。
// translator.ts 现状:同一段助手文本发两遍,且都没有 itemId 可配对
emit({ type: 'message.delta',     text: '正在分析…' })  // 流式增量,逐段到达
emit({ type: 'message.delta',     text: '…完成' })
emit({ type: 'message.completed', text: '正在分析……完成' }) // ✗ 末尾再带一遍全文
// 消费者把 delta 拼完,又收到 completed 全文 → 整条消息渲染两遍;
// 修法:completed 不重复携带全文,或两边都带 itemId 让消费者可配对去重
架构 KEEP,翻译保真 REWORK:双发去重、usage 补全、保 tool_use_result;is_plan/occurredAt 待实测。
runner.ts1809 行KEEP-CORE删 hooks/图像REWORK
职责:ClaudeRunner —— 5 态机 + 全部运行时(session-id 缓冲、控制协议桥、interrupt、EOF/失败处理、turn/slash 双模式、竞态)。
  • 核保留 状态机 + 竞态/kill/自愈 = 长活双向进程 + 带内协议的必要适配,非 gold-plating。
  • M7 buildClaudeHooks hooks 表(多余 + 半接线 + 偏离实测,发 hooks:null)。
  • 重做 N1 图像输入 —— capabilities.imageInput'unknown'、probe 没建 → 任何图像 turn 必抛,~80 行处理代码永跑不到。image 是契约 AgentInput.parts 一等成员(#50)→ 优先补 probe 接通;确认 CLI 不支持再删。
  • 重做 M8 默认权限不能 bypass · N2 AskUserQuestion 回填按问题文本+保数组 · M10 空问题别进 input 状态机 · M4 过滤中断哨兵 · ExitPlanMode→永久 bypass 改掉 · parseLine 非 JSON 别杀进程。
  • 待核 session-id 缓冲疑似冗余(session_id 第一条就到、早于业务事件)· N6/N7 中断 2s 偏激 / cancelled 误标。
// runner.ts:65 —— 能力探针初始化之后,全仓没有任何代码再更新它
imageInput: 'unknown',

// runner.ts:1436 —— 于是图像轮永远走进 throw,后面 ~80 行图像处理永跑不到
if (capabilities.imageInput !== 'supported') {
  throw unsupportedOperation('Claude stream-json image input is not supported.')
}
// image 是契约 AgentInput.parts 一等成员(#50)→ 修法是把 probe 建起来接通,不是留死路
核留、皮削、错修。 这是全 provider 重量集中处,但重量来自适配,不是堆砌。
commands.ts339 行PARTIALVERIFY
职责:8 个内置 slash 命令定义(compact/clear/review/...)+ /context /cost 输出解析。
  • 命令定义 + semantic 映射 —— 干净,可留。
  • /context /cost 正则刮取人类可读输出 —— 投机。
  • N4 待核 整个 slash 面赌「把 /compact 当 user 消息发,CLI 会当命令解释」,实测无确认 —— 若被当聊天文本则整面非功能
// commands.ts:193-328 —— 把 /context、/cost 的「人类可读输出」用正则刮成数字(~135 行,示意)
readLabeledNumber(rawText, /Context size:\s*([\d,]+)/i)   // ✗ CLI 文案改一个词就全失效
// 命令定义与 semantic 映射保留;这套解析删 —— 这类数据该走结构化口子(usage 事件),不是刮终端文案
定义留、解析删、可行性先验。 落地前必须对真 CLI 验证 slash 命令能否走 stdin。
config.ts587 行PARTIALREWORK
职责:provider config 类型 + normalize + 校验 + metadata + availability。
  • argv 冲突校验(ADAPTER_OWNED_FLAGS / DISALLOWED_FLAG_PATTERNS)—— 防 extraArgs 破协议,有用。
  • 提升契约 自造的 ClaudeProviderConfigFieldMetadata + 14 条 configFields 与契约 AgentConfigField 干同一件事(描述字段给 UI 渲染)→ 改填 AgentAvailability.sessionConfig,删平行类型(契约本来就有这个口子)。
  • 重做 M8 DEFAULT_PERMISSION_MODE='bypassPermissions' 但 capability 声明 approval → 矛盾,默认应审批开启。
  • 重做 M9 getClaudeProviderAvailability() 直接 available:true —— 假阳性,必须真探针(paseo isAvailable() 是真去 resolve binary)。
  • N9 extraArgs / advancedRawArgs 两字段做同一件事,冗余。
// config.ts:129 —— 默认权限模式
const DEFAULT_PERMISSION_MODE: ClaudePermissionMode = 'bypassPermissions' // ✗ 默认全放行
// 同时 getCapabilities() 声明了 'approval' —— 名义上有审批能力,默认配置却整个绕过它

// config.ts:326 —— 可用性上报
getClaudeProviderAvailability() {
  return { available: true, ... } // ✗ 从不探测 claude 是否安装 → 没装的用户到 createRunner 才炸
}
校验留、schema 删、默认权限 + availability 重做。
discovery.ts718 行WIRE / 扩契约模型基线 KEEP
职责:扫 ~/.claude + 项目 .claude/ 的 slash 命令 / agent markdown / model / MCP 配置。能力 Eyrie 要,接线接错了。
  • 直接接 BASELINE_MODELS + getClaudeAvailabilityModelOptions()(~90 行)是活的 → 接进 AgentAvailability.models / 注册机制 ProviderModule.models
  • 扩契约 自定义 slash 命令发现 当前契约没方法(getControlCommands() 只管静态内置)→ 需接口层补统一 discovery 方法,实现挪到其后。
  • agent markdown / MCP 诊断扫描:已拍定 defer(抽象层无对应建模,spec 0008 D12)—— 本期不入契约、不接线。
不是「提前造的垃圾」,是「契约没留门、他从墙上凿了 Claude 形状的洞」。 扫描逻辑大多可改造保留,接线重做。
sessions.ts726 行WIRE / 扩契约实现质量待审
职责:逆向 Claude 私有磁盘格式(~/.claude.json 索引 + projects/**/*.jsonl transcript),把已有原生 session 列出来供导入 Eyrie。「导入我的 CLI session」是 Eyrie 想要的产品功能。
  • 扩契约 原生 session 发现 契约缺方法 → 需接口层补统一 discovery 方法,返回统一 AgentNativeSession
  • 已有口子 发现后的导入→续:产出 AgentPersistenceHandlecreateRunner({resume}) —— 这段契约本来就有。
  • 质量保留意见 逆向 Claude 私有、不保证的磁盘格式,天生脆 —— 要做这功能某种逆向不可避免,但必须版本兜底 + 失败降级,不能当稳定契约。
// sessions.ts 逆向的磁盘布局(Claude 私有格式,无兼容承诺):
~/.claude.json                                    // 全局索引:项目 → 会话清单
~/.claude/projects/<path-slug>/<sessionId>.jsonl  // transcript;文件名即 session id(已实测核对)
// 能力(把已有 CLI 会话导入 Eyrie 续聊)是真产品功能;但私有格式必须版本兜底 + 失败降级,
// 且产出要走统一契约出口(AgentPersistenceHandle),不能自成第二套持久化权威
功能要留、契约要补、实现要加固。 不是删功能,是把它从私有面搬到正经契约后面,并补健壮性。

06 发现汇总(按处置)

🟢 该留 / 采纳
  • 长活 -p stream-json 进程
  • stdin 握手 + set_permission_mode
  • structured interrupt · --resume
  • spawn/dispose/interrupt 竞态
  • 跨平台 kill 阶梯 · typed error
  • config argv 冲突校验
🔴 该删(真死/重复)
  • hooks 表(M7)—— 偏离实测、无接收端
  • 图像死路(N1) → 移入「该重做」:补 probe 接通优先(契约 parts 一等成员)
  • /context/cost 正则刮取 —— 投机
  • index.ts 那 8 个私有方法面本身(能力另接契约,见右侧)
🟠 该重做
  • 默认权限别 bypass(M8)+ 起步窗口(M5)
  • ExitPlanMode 永久 bypass 改掉
  • availability 真探针(M9)
  • AskUserQuestion 按问题文本+数组(N2)
  • 空问题别进状态机(M10)
  • tool_use_result 保真(N3)· 双发(M2)· usage(M3)· 哨兵(M4)· parseLine 容错
🔵 动手前对真 CLI 实测
  • N2 确切 answer key / 多选数组形状
  • N4 slash 命令能否走 stdin
  • N5 is_plan / occurredAt 真伪
  • N3 tool_use_result 确切字段
  • session-id 缓冲是否真冗余

🔼 能力要留 → 提升进契约(PR42 想做的发现/导入/metadata)

这些不是删功能。问题是走了 Claude 私有方法面,绕过契约。7 项里 5 项契约已有口子(直接接),2 项契约缺方法(需扩契约 + spec):

想要的能力接口层契约口子状态 / 处置
provider metadata / availabilityAgentAvailability(registry.detectAll())已有 改走它,弃 getMetadata/getAvailability
models 列表AgentModelOptionProviderModule.models已有(#50/#55) ~90 行基线填进你的 ProviderModule 声明
session 配置 schemaAgentConfigFieldProviderModule.sessionConfig已有(#50/#55) 弃自造 ClaudeProviderConfigFieldMetadata
原生 session (resume)AgentPersistenceHandle + createRunner({resume})已有 import 产物直接喂
原生 session 发现—— 契约已立项:spec 0008 discoverNativeSessions + 统一 AgentNativeSession(owner 侧推进,落地后对齐)
内置 slash 命令getControlCommands()已有
用户/项目自定义 slash 发现—— 契约已立项:spec 0008 discoverCommands,复用既有 AgentControlCommandDef——不造 AgentSlashCommand 新类型(落地后对齐)
结论:作者直觉对(这些 Eyrie 要)、执行错(私有面 + Claude 专属类型抢跑契约)。5 项接回现成契约;2 项(原生 session 发现、自定义 slash 发现)的统一契约方法已立项(spec 0008,owner 侧推进)——等契约落地后把实现挪进去对齐,别在本 PR 自行扩契约
⚙️ 跨地基(非 provider 单方改):F1 两 PR 合并编译 · F2 closed 语义 · F3 注册(已解决:PR #55 ProviderModule 自注册已合入)· model 归属(落点 = ProviderModule.models)—— 归注册/接口层,见 00-foundation-arch-review.md

07 合并路径

① rebase 到含 PR #55 的 main(ProviderModule 注册地基已合入)
② 删真死(hooks 表 / /context/cost 解析)+ 私有面能力接回契约(5 项);sessions.ts + discovery.ts 扫描 + 对应测试本 PR 先撤出(待发现契约落地另 PR 回归)→ 约 −2470 行
③ 修「该重做」清单(默认权限 / availability / 输入回传 / 容错 ...)
④ 对真 claude 验 4 处 wire shape
⑤ 收成 providers/claude/* + claudeProviderModule 一行声明
结论:不能按现状合;但方向(长活 runner)被竞品 ref 背书,不需要推倒重写。落地以减法 + 修正为主。