给 AI 编程客户端做外部审批面板,做到第三个月才想明白一件事:难点根本不在 UI。
同样是「代理要写文件了」,有的客户端允许你在终端之外回一个「允许 / 拒绝」,有的只能让你看见它想干什么,你照样得切回去点。这两类混在一起做,产品就会变成「看起来什么都能干,一用全是割裂」。
我把接入拆成三层,只有第一层是普遍能做到的。

一、能观察 / 能应答 / 能审批
| 层级 | 需要什么 | 典型实现方式 | 覆盖面 |
|---|---|---|---|
| 能观察 | 单向事件推送 | hooks、日志 tail、状态查询接口 | 几乎全部 |
| 能应答 | 双向通道 | 提问事件 + 回写答案 | 一部分 |
| 能审批 | 原生批准结果回写 | PermissionRequest + 把 decision 交回客户端 | 很少 |

分水岭在第三条。「我收到了一个通知」和「我能替用户批准」是两件事,中间差一个语义层级。 前者只需要读;后者要求你交回的结果被客户端当成「用户按了允许」原生接受。
做不到这一点的后果不是「少一个功能」,是用户点了没反应------然后他怀疑整个软件是坏的。
二、六个真实的坑
坑 1:通知通道不是批准通道
现象:Gemini CLI 的按钮点了「允许」,客户端那边依旧在等它自己的确认。
原因:它有通知通道,但通知本身不是批准接口。你把按钮接在通知上,等于对着一个只读的日志喊话。
做法:先确认这个通知后面有没有对应的回写接口。没有,就只显示状态,不放按钮。
坑 2:没有 PermissionRequest 事件,就不要放按钮
现象:Factory Droid 只核到观察类事件。
做法:不显示任何审批按钮。提前放一个假的「允许」,等于让用户对着一个不会生效的开关点。
这条后来成了整个项目的底线:UI 上没有的按钮,比点了没反应的按钮更诚实。
坑 3:「阻断」不等于「批准」
现象:Windsurf / Cascade 的前置 hook 可以拦住一次调用。
原因:拦住的语义是「我阻止了」,不是「我允许了」。用阻断冒充批准,结果通常是工具直接跑不起来,用户以为软件坏了。
做法:阻断只能拿来做「危险操作拦截」,不能当审批通道。
坑 4:没有安全回写通道,就不做应答
现象:Roo Code 固定在 3.54.0 的任务事件 API,能读任务状态。
做法:只显示状态,审批留在编辑器里。缺一条安全回写路径的时候,「只读」是唯一不会骗人的选择。
坑 5:配置写对 ≠ 被加载 ≠ 被信任
这是第二件反直觉的事。生成一份 hooks 配置,跟它真的被加载、真的被信任,是三件事。
各家普遍还有一层信任确认:你写了配置,它还要用户手动进去点一次「允许这些 hooks」。这层不做,文件写得再对,一个事件都进不来。
反过来也有一个坑:别替用户批量信任。 用户可能有自己的 hooks 在跑,你替他勾上「全部信任」,是在改他的安全边界。
做法:只写自己那一条,只读别人的信任状态,不改。
坑 6:同一个品牌,不等于同一套协议
这条吃过亏。
| 客户端 | 真实情况 | 后果 |
|---|---|---|
| Trae | CN 版与国际版是两套协议 | CN 的验收结果不能挪给国际版 |
| Cline | VS Code 扩展与 SDK CLI 是两条路径 | 扩展通了不代表 CLI 通 |
| Zed / JetBrains | 内置 AI 拿不到外部通道 | 能接的只是显式启动的外部 ACP Agent,权限仍留在编辑器里 |
| Continue | 只能靠 serve /state 轮询快照 |
能用,但会漏掉短暂状态,得老实说不精确 |
所以接入矩阵是按「客户端 × 形态」逐格填的:CLI、IDE 插件、桌面端分开记,一个格子验过不能代表隔壁。
三、「接入了吗」不该是一个布尔值
大多数工具把接入状态做成 connected / disconnected。这个模型在真实世界里会持续给出错误答案。
我把状态做成六档:
ts
type AgentState =
| 'needs_config' // 配置不完整
| 'needs_trust' // 客户端明确报告尚未信任
| 'needs_setup' // 需要导入插件 / 选启动入口 / 连外部服务
| 'awaiting_activity' // 配置已就绪,但还没收到真实事件
| 'connected' // 收到过有效活动
| 'check_failed'; // 读不到授权状态
关键在两条规则:
规则一:没有「等待活动」到「已接入」的自动推定。
配置文件存在、进程起来了、开关打开了,全都只到 awaiting_activity。必须收到一条真实任务产生的事件 ,才允许显示 connected。
ts
function resolveState(s: ProbeResult): AgentState {
if (!s.configComplete) return 'needs_config';
if (s.trust === 'denied') return 'needs_trust';
if (s.setupPending) return 'needs_setup';
if (s.lastEventAt === null) return 'awaiting_activity';
return 'connected';
}
规则二:能查就问,查不到就说查不到。
客户端有只读的授权查询接口就去查真实状态,别猜。比如 Codex 可以读它的 hooks 列表,拿到启用状态、信任状态和当前定义指纹------查得到就查,查不到就写 check_failed。
不要写「未授权」,也不要写「已授权」。猜错的方向刚好相反的两个错误,都会让用户做错决定。
四、会话身份是另一个坑
把事件收上来只是开始,还得知道这条事件属于哪个会话。
各家给的身份线索都不一样:
| 客户端 | 身份线索 | 需要注意 |
|---|---|---|
| Windsurf | trajectory_id |
与窗口不一一对应 |
| Kimi | 任务树 | 一个任务下有多个子节点 |
| Qoder | 会话 ID | 要区分原生会话与子任务 |
| GitHub Copilot CLI | 并发 ID | 并发时要消歧,不能只看时间 |
| Roo Code | 窗口来源 | 多个窗口会同时产生事件 |
| Amazon Q(旧 CLI 包装器) | 无稳定 ID | 拿不到调用标识和回复正文,只能进程级观察,不碰内容 |
认错会话的后果很难看:代理 A 的问题显示在代理 B 的卡片上,你点了一个允许,批的是另一件事。
这类问题不会在单客户端测试里暴露,只在「三个代理同时跑」的时候暴露。
五、现在到哪一步了(不美化)
31 个接入目标,全部都有通过项目契约测试的基础通道;完整的端到端验收,一个都还没做完。
差别只在深度:
- Claude Code 走得最远:2.1.234 的隔离真实读取、以及「在终端之外拒绝写入」已经实机验过;完整审批、并发权限、子代理和 TUI 还在验
- Codex / Cursor / Gemini CLI / Copilot CLI / CodeBuddy 到「契约测试通过 + 事件类型核对」,真实客户端加载待验
- Aider / Cody / Amazon Q / Zed / JetBrains 只能包一层显式启动器做观察,交互不覆盖
矩阵和每个版本的实际验证记录都公开在仓库里(docs/compatibility/agent-matrix.json 和 release-verification 系列),每一格写的是「验到哪一步」,不是「支持 / 不支持」。
我宁可这份表看起来难看,也不想让人装完发现某一行是假的。
六、小结
- 「能观察」「能应答」「能审批」是三层能力,别用一层的说法描述三层
- 通知不是批准,阻断不是批准,两者都只能用来做更弱的那件事
- 配置写对、被加载、被信任是三件事,缺一层就没有事件
- 接入状态不要做成布尔值,没有「自动推定已接入」这条捷径
- 品牌相同不代表协议相同,矩阵要按「客户端 × 形态」逐格填
- 会话 ID 不稳的时候,只做进程级观察,别碰内容
这套东西最后落成了一个小工具,跑在 Mac 的刘海上:XIsland (客户端闭源,接入矩阵和验收记录开源,GitHub 上搜 Meteorkid/XIsland)。
想听两件具体的事:
- 你主力用哪几个 AI 编程客户端?我按使用量排验收优先级,现在这张表是按「能不能接」排的,不是按「多少人用」排的
- 有没有人清楚某家客户端其实有对外审批接口、但这里标成了「只能观察」------那说明我漏了,比我自己测出来更值钱