feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分

L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
This commit is contained in:
2026-09-06 15:18:06 +08:00
parent a44fd6949b
commit 79c4171c9d
40 changed files with 3369 additions and 116 deletions

View File

@ -193,6 +193,7 @@ name@path.session
| B-2.3 | 带上模型目录(拉不到则**省略字段**,不传空数组) | SHOULD |
| B-2.4 | 带上平台会话快照(同上) | SHOULD |
| B-2.5 | 心跳失败不影响 SSE 与投递 | MUST |
| B-2.6 | 请求体带 `mode_enforcement``native` / `advisory`),告知平台本侧的权限强制力 | MUST |
> **B-2.1 为什么失败不报错**:网络抖动很常见,而 Gateway 已经有可见的失败信号
> —— 持续连不上时 `last_seen` 会让它显示为离线。插件自己再打一串错误日志只会
@ -200,6 +201,14 @@ name@path.session
>
> **B-2.2 为什么范围随心跳回传而不是插件轮询**:管理员在配置页改了范围后最多
> 一个周期30 秒)生效,不需要重启插件;也不需要插件多起一个请求。
>
> **B-2.6 为什么上报 mode_enforcement**:平台表达的档位(`plan` / `workspace` / `full`
> 是「我要求你做到什么」,而平台能实际做到的(沙箱、审批、仅通知)取决于本侧的
> 强制力。两者分开记录,人在界面才能看到「这个平台无法强制这一档」。
>
> **响应里的 `unknown_fields`**:心跳是唯一走宽容解码的端点(`DecodeLenient`
> 容忍插件带了平台不认识的字段。但容忍不等于咽下去 —— 响应里会回 `unknown_fields`
> 数组,插件应据此判断自己是否比平台新得太多,必要时降级。
### B-3 收到 `new_mail`MUST
@ -222,7 +231,7 @@ new_mail 到达
| B-3.1 | cwd **必须**取自 `to_workspace`,不得自己拼临时目录 | MUST |
| B-3.2 | 新建会话时**不传**占位标题(会掐掉平台自己的命名机制) | MUST |
| B-3.3 | 维护 `mail session_id ↔ 平台 session id` 双向映射 | MUST |
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」 | MUST |
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」**(仅 `from_human === true` 时)** | SHOULD |
| B-3.5 | 提示词里带 `mail_id`,让模型能自己查这封 | SHOULD |
| B-3.6 | 投递失败要让人看到(日志 + 见 `B-6` | MUST |
| B-3.7 | `platform_session_id` 非空时**必须**投进那条平台会话,不得新建 | MUST |
@ -235,6 +244,10 @@ new_mail 到达
> **B-3.4 为什么要在提示词里说**:不说的话模型会自己调 `send_mail` 回信,
> 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。
> 说了之后仍要保留 `B-5.3` 的去重兜底:提示词是建议,去重是保证。
>
> **为什么改为 SHOULD**`from_human === false` 时自动转发规则 `B-5.6` 已经拦住,
> 不需要也不应该在提示词里说「回信由插件自动发」—— 那对 Agent 收件方是假话。
> 提示词是建议,去重是保证,这条不变。
#### B-3.7 接管平台会话MUST
@ -301,12 +314,19 @@ pi 的会话是磁盘上的 `.jsonl`**没有任何锁机制**SDK 里 `floc
| # | 要求 | 强度 |
|---|---|---|
| B-5.6 | **收件方是 Agent**`from_human === false`)→ **不转发** | MUST |
| B-5.1 | 只取最后一条 assistant 消息里 **`type === 'text'`** 的块 | MUST |
| B-5.2 | 带 `relay: "summary"` + 平台侧稳定 id 作 `relay_key` | MUST |
| B-5.3 | 本轮模型已亲手回过这条线索 → **不转发** | MUST |
| B-5.4 | 文本为空 → 不发空邮件 | MUST |
| B-5.5 | 只对**邮件驱动**的会话转发(人在平台 UI 里开的会话不转) | MUST |
> **B-5.6 为什么排在最前面**Agent → Agent 的邮件如果自动转发,两边插件都认为
> 「我只要把话说完就行」,实际上彼此持续唤醒 —— 生产实测 pi 与 dsh 互相客套 6 轮
> 直到撞上 hop 上限。此规则的判据是 `from_human`:它由服务端用
> `EXISTS (SELECT 1 FROM users WHERE username = from_name)` 判定,
> 不依赖插件自己的猜测。
> **B-5.1 丢掉 reasoning**:思考过程不该出现在邮件里 —— 它对收件人没有意义,
> 而且经常包含「我先假设…」这类会被误读为结论的内容。
>
@ -434,13 +454,16 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
| # | 要求 | 强度 |
|---|---|---|
| B-8.1 | `relay_key` 用平台的权限 id平台不给 id 时用 `会话:工具:callId` 拼一个 | MUST |
| B-8.2 | 转发失败 → 让位给平台本地 UI,不要占着钩子 | MUST |
| B-8.2 | 转发**暂时**失败5xx / 408 / 429 / 网络)→ 让位给平台本地 UI | MUST |
| B-8.2b | 转发**永久**失败4xx除 408 / 429→ 当场拒绝并把原因告诉模型 | MUST |
| B-8.2c | 拒绝给模型的文本必须是**真实原因**,不能是平台的「用户拒绝了」写死文案 | MUST |
| B-8.3 | 同一次询问重复触发只产生一封邮件(服务端按 `relay_key` 幂等) | MUST |
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要、**触发这次询问的任务与派活人**),让人能判断 | SHOULD |
| B-8.5 | 权限询问**不消耗配额** | MUST |
| B-8.6 | **不传 `to`** —— 决策人由服务端解析 | MUST |
| B-8.7 | 平台支持「永久允许」时,选项里必须给出「一直同意」并**真的记住它** | MUST |
| B-8.8 | 免批的作用域是 **(会话, 工具名)**;决策文本判定用 `lib/permission-grants.js` | MUST |
| B-8.9 | `relay_key` 发出前必须用 `lib/relay-key.js``clampRelayKey` 收敛长度 | MUST |
> **B-8.1 为什么必须是平台的 id**:服务端会随决策事件把 `relay_key` 回传,
> 插件重启丢了内存映射也能对上(`B-4.2`)。自己生成的随机 id 重启后就对不上了。
@ -459,9 +482,74 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
> 409** 解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
> 猜不出「这条 Agent 链最初是谁派的活」。
>
> 收到 409整条链上没有人类时按 `B-8.2` 处理 —— 服务端已经判定没人可问,
> 收到 409整条链上没有人类时按 `B-8.2b` 处理 —— 服务端已经判定没人可问,
> 继续等下去就是死锁。
>
> **B-8.2 / B-8.2b 为什么必须分开(生产事故)**:原本只有「除 409 一律让位」一条。
> 实测碰到 pi 侧 `relay_key 过长(上限 160 字节)` 返回 **400**,被归入「暂时失败」
> 让位给本地决策 —— 而邮件驱动的会话根本没有本地 UI**那条 bash 就在无人
> 批准的情况下执行了**。同一条会话 22 秒后另一次 key 正常则成功发出询问 ——
> 所以守卫是**随机**失效的,比稳定失效更难发现。
>
> 判据(`lib/relay-key.js` 的 `isPermanentFailure`homeagent 侧是 `relay_key.go`
>
> | 状态 | 类别 | 理由 |
> |---|---|---|
> | 4xx除 408 / 429 | 永久 | 请求本身有问题,重试一万次还是同一个结果 |
> | 408 / 429 | 暂时 | 超时与限流,等一会儿真的可能成功 |
> | 5xx | 暂时 | 服务端的问题 |
> | 无状态码 | 暂时 | 网络层DNS、连接被拒 |
>
> 401 归到**永久**:密钥无效要人去后台重新登记,不是等一等就好的事。
> 实测过一次opencode 被停用后拿着已撤销的密钥重试了 18 小时2690 次 401。
>
> **永久失败必须 fail closed**:宁可让模型看到「权限系统坏了」并自己改道,
> 也不能悄悄放行一条没人看过的命令。拒绝时把原因写进 `reason`(平台会当工具
> 报错回给模型),它才知道下一步该换什么做法。
>
> **B-8.2c 为什么单列一条DSH 实例)**:“当场拒绝”在有些平台上不等于
> “模型知道为什么被拒”。DSH 把 `approval/request` 的返回值翻译成模型可见
> 文本时用的是 `@deepseek-ai/dsh-tools` 里写死的句子:
>
> ```js
> case "rejected": reason = `the user rejected tool "${exec.name}"`
> case "unavailable": reason = `... no approval channel is available`
> ```
>
> 于是插件因为「这条链上没有人类」主动拒绝时,模型看到的是
> 「the user rejected tool bash」—— **没有任何用户拒绝过它**。模型会以为人
> 不同意,而不会去换一条路;服务端给的 `suggestion` 只进了日志。
>
> 三个平台的出口不同:
>
> | 平台 | reason 能不能直达模型 | 做法 |
> |---|---|---|
> | pi | 能 | `return { block: true, reason }` |
> | opencode | 能 | `output.status = "deny"` + `output.reason` |
> | DSH | **不能** | 在 `approval/request` 里记下 `(agentId, callId) → 原因`,再在 `tools/post-execute` 返回 `{kind:'block', feedback}` 换掉那句写死的文案 |
>
> DSH 那条路可行的依据:门禁拒绝的调用**也会**进 post-execute
> `pre-execute` 的 deny 走 `{kind:"post-result"}` → `finalizeScheduledExecution`
> → `postExecute`)。替换必须是**一次性**的(同一 callId 只换一次)、
> 按 `(会话, callId)` 隔离、只对 `isError` 的结果生效,否则会把一个原因
> 贴到别的失败上。
>
> **B-8.9 为什么不能直接截断**toolCallId 的长度不在插件控制下。启用
> extended thinking 时 Bedrock 把**思考签名**拼进了 toolCallId实测同一条会话里
> 两种形态混着出现:
>
> ```
> toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
> toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
> ```
>
> 直接截断会让前缀相同的两次调用**撞成同一个键** —— 而这个键的全部意义
> 是幂等,撞键意味着第二次询问被服务端当重复请求丢掉。`clampRelayKey`
> 保留可读前缀(日志里还能 grep 会话 id+ `:sha256:<原始键的完整哈希>`
> 且**未超限时原样返回** —— 否则插件升级前后会算出不同的键,等于把已发出的
> 询问变成新询问。Node 与 Go 两侧必须对同一输入算出同一输出(已用跨语言
> 比对验证)。
>
> **B-8.4 为什么要带派活人**决策人未必是这条会话的参与者。Agent 转派出来的
> 会话,人从没见过它,只给一句「是否允许执行 bash」无从判断 —— 得知道这活是
> 谁派的、为的什么事。
@ -642,11 +730,17 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
| # | 要求 | 强度 |
|---|---|---|
| D-2.1 | 钩子内**不得**阻塞等待(会挂死整个平台请求) | MUST |
| D-2.2 | 转发失败时让位给本地 UI`return next()` 或保持「询问中」) | MUST |
| D-2.2 | 转发**暂时**失败时让位给本地 UI`return next()` 或保持「询问中」) | MUST |
| D-2.2b | 转发**永久**失败4xx时当场拒绝不让位 | MUST |
| D-2.3 | 记住 `平台权限 id → 挂起项` 的映射 | MUST |
> **D-2.2 为什么必须让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远
> 不会来的回答。让位之后本地 UI 还能接管
> **D-2.2 为什么让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远
> 不会来的回答。让位之后本地 UI 还能接管 —— **但这个前提只对人坐在 TUI 前面
> 的会话成立**。邮件驱动的会话没有人在看,让位等于无人把关。
>
> 因此「让位」只能给**暂时**失败网络抖动、Gateway 正在重启 —— 那些情形下
> 插件不知道下一秒会不会好而人确实可能在本地看到弹窗。永久失败4xx
> 已经知道结果了,让位就是静默放行(见 `B-8.2b`)。
### D-3 无法指定单轮模型(缺 `C-13`
@ -828,7 +922,8 @@ POST /api/v1/agent/heartbeat
],
"models": [
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
]
],
"mode_enforcement": "native"
}
```
@ -842,7 +937,8 @@ POST /api/v1/agent/heartbeat
"platform_sessions_synced": 12,
"models_synced": 9,
"allowed_models": [{ "provider": "llmsproxy", "model": "AUTO" }],
"models_unrestricted": false
"models_unrestricted": false,
"unknown_fields": []
}
```
@ -881,7 +977,11 @@ Agent 侧只会收到两个事件:
"session_alias": "refactor-imports",
"reply_address": "admin@.refactor-imports",
"self_address": "pi@/home/program/agentmail.refactor-imports",
"platform_session_id": ""
"platform_session_id": "",
"in_reply_to": "",
"from_human": true,
"permission_mode": "workspace",
"permission_enforcement": "native"
}
```
@ -894,6 +994,10 @@ Agent 侧只会收到两个事件:
| `reply_address` | 「把回信发回这条会话」的现成地址 |
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
| `platform_session_id` | 非空 = 投进**这条已存在的平台会话**(见 `B-3.7`);空 = 照旧 |
| `in_reply_to` | 父邮件 id这封信是回复哪封的空串 = 线索根 |
| `from_human` | `true` = 发件方是人类(服务端用 `EXISTS users` 判定)。**`B-5.6`** 据此决定是否自动转发 |
| `permission_mode` | 所属会话的权限档位(`plan` / `workspace` / `full`)—— 插件应据此设置沙箱/审批策略 |
| `permission_enforcement` | 平台对该档位的实际强制力(`native` / `advisory`)—— 插件据此决定是**强制执行**还是**打日志告警** |
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
@ -1059,6 +1163,71 @@ GET /api/v1/attachments/{id}
| `discovery.js` | 寻址发现工具的渲染(`T-8`~`T-12` |
| `rename-proposal.js` | 会话改名标记的构造与回执文案(`T-13` |
| `permission-grants.js` | 权限决策文本判定 + 免批授权表(`B-8.7` / `B-8.8` |
| `relay-policy.js` | 自动转发适用范围 + 据此给模型说什么话Agent 间不转) |
| `relay-key.js` | `relay_key` 长度收敛 + 永久/暂时失败分类(`B-8.2b` / `B-8.9` |
| `adopt.js` | 接管平台会话的 id 提取与缺失报文(`B-3.7` |
| `permission-mode.js` | 权限档位翻译AgentMail 声明什么 → 平台怎么下发) |
| `bounded.js` | 有界 Map/Set给常驻进程里「只增不减」的映射表兜上界 |
> **Go 子进程插件的例外**homeagent 是 Goimport 不了 Node 模块。
> 那几个模块在它那边是 `relay_policy.go` / `relay_key.go` / `bounded.go`
> 注释与判据原样搬过去,测试逐条对齐(`relay_policy_test.go` / `relay_key_test.go` /
> `bounded_test.go`)。`clampRelayKey` 还额外要求**两边对同一输入算出同一输出**
> (幂等键分叉就失去意义);`bounded.go` 的上限常量必须与 Node 侧同值
> (一侧偷偷调小会让「重复投递」只在那个平台出现),但**淘汰策略允许不同** ——
> Go 的 map 不保证遍历顺序,那边是 FIFO 而不是 LRU理由写在文件顶部。
### 常驻进程里的表必须有出口
四个桥都是常驻进程pi 的守护进程能跑几十天,另三个跟着平台一起活)。里面每一张
「这条会话/这封邮件我处理过吗」的表,键都来自外部事件流 —— 会话数与邮件数随时间
单调增长。**每张这样的表都必须有出口**,两条:
| 出口 | 时机 | 性质 |
|---|---|---|
| `session_archived` 事件 | 会话归档 | 确定性:归档后别名 404、不会再有邮件投进来映射再无用处 |
| 上限淘汰(`bounded.js` | 超过上限 | 兜底:兜的是「一直不归档」 |
> **确定性的出口优先**:能确切知道该删的时候不该靠上限去猜。
> `session_archived` 是 SSE 事件里唯一一个「这条会话到此为止」的信号,
> 四个桥原来全都没处理它。
**不要给「还在等结果的东西」套上界**待决权限询问opencode 的
`pendingPermissions`、DSH 的 `pendingApprovals`)里存的是 `resolve` 回调,
静默淘汰一条会让对应的 `await` 永远不返回 —— 平台侧那次工具调用直接挂死。
那些表有确定的清理路径(决策到达 / 超时 / 拆插件时 fail closed不需要上界。
上界只适合「记录已经发生过的事实」的表。
> **这类表不是内存暴涨的原因**:单条成本只有几十到几百字节。症状是跑够久之后
> 进程里躺着几十万个再也不会被查到的条目,且 GC 回收不了(还被强引用着)——
> 不会在开发和测试里出现,只在生产上跑了几周后表现为「重启一下就好了」。
### pi 专属:不要在心跳路径上调 `SessionManager.listAll()`
心跳每 30 秒要上报平台会话快照,而 `snapshotPiSessions` 只用四个字段
`id` / `cwd` / `name` / `modified`)。`listAll()` 为了拿这四个字段会把
`~/.pi/agent/sessions` 下**每个 `.jsonl` 的每一行**读进来并 `JSON.parse`
还把所有消息正文拼成一个 `allMessagesText` 大字符串。
本机实测115 个文件 / 145MB其中单个会话 29MB、单行最长 2.63MB
| 做法 | 耗时 | RSS |
|---|---|---|
| `listAll()` | 1431ms | 41 → 323MBheapUsed 141MB |
| 只读 header 首行 | 3ms | 41 → 46MB |
| `src/session-scan.mjs`(冷启动) | 516ms | 41 → 131MB |
| `src/session-scan.mjs`(稳态) | 3ms | 重扫 0 字节 |
那 282MB 每 30 秒分配一次、随即变成垃圾。GC 收得掉(所以 RSS 呈锯齿而不是单调
上升),但代价是常驻内存被垃圾撑到 300MB 上下,且每拍有 1.4 秒的**同步解析跑在
事件循环上** —— 那期间 SSE 读循环停着,新邮件事件在 TCP 缓冲区排队。
`src/session-scan.mjs` 的三条省法:`id`/`cwd` 只在首行 header读 4KB 就够);
`name` 来自 `session_info` 行而那种行只有几百字节(按行扫描时长度超上限的行直接
跳过、不 materialize文件是 append-only 的,缓存 `size` 之后每拍只扫新增的尾巴。
> **它必须建一次并复用**:省内存全靠跨拍存活的 size 缓存。每拍新建一个等于每拍
> 都冷启动,退回全量读的开销。
> **为什么必须逐字节相同而不是「行为一致」**:一侧改了另一侧没改,两个平台的行为
> 会悄悄分叉 —— 同一封邮件在 A 平台标了已读、在 B 平台没标,而两处代码看起来都
@ -1130,7 +1299,7 @@ GET /api/v1/attachments/{id}
[ ] sqlite3 <db> "SELECT status, last_seen FROM agents WHERE agent_name='<name>'"
→ status=onlinelast_seen 每 30 秒推进B-2.1
[ ] 断网 60 秒再恢复SSE 自动重连,期间的邮件通过 Last-Event-ID 补回D-7.2
[ ] kill 插件:未决权限询问全部 fail closedB-8.2;平台无审批环节时跳过)
[ ] kill 插件:未决权限询问全部 fail closedB-9.2;平台无审批环节时跳过)
```
### 7.3 主链路
@ -1170,6 +1339,14 @@ GET /api/v1/attachments/{id}
[ ] 模型主动调 send_mail 回信的那一轮
→ 只有一封邮件没有额外的自动转发B-5.3
[ ] Agent → Agent 负向对照B-5.6
→ 向另一个 Agent 发一封from_human === false
→ 收件方 Agent 的日志里**没有**「自动转发」相关条目
→ 收件方 Agent 的 sessions.used_rounds 不因自动转发而涨
→ 如果收件方 Agent 的模型跑了但没调 send_mail → 邮件链到此为止,发件方收不到任何回信
→ 如果收件方 Agent 的模型调了 send_mail 回信 → 那封回信的 from_human === false
→ 收件方的收件方也不自动转发
[ ] 模型带 propose_alias 发信T-13
→ 入库正文里**没有** agentmail:rename-session 标记(已被剥掉)
→ mails.rename_alias / rename_reason 记下了提议
@ -1232,8 +1409,30 @@ GET /api/v1/attachments/{id}
→ 权限邮件的 to_name 是**人**(会话 owner 或线索里最近的人类),不是 Agent
sqlite3 "SELECT to_name FROM mails WHERE mail_type='permission_request' …"
→ 正文里带得出「触发任务」与「任务来自」B-8.4
→ 整条链上确实没有人类时:服务端返回 409插件让位给本地 UI
**不是**无声挂起(日志里要能看到让位那一行)
→ 整条链上确实没有人类时:服务端返回 409插件**当场拒绝**并把原因告诉模型
**不是**无声挂起(日志里要能看到拒绝那一行)
[ ] **永久失败不得静默放行**B-8.2b
造:把转发请求里的 relay_key 换成 200 字节的串(超服务端 160 上限),
或把密钥改错造 401
→ 服务端返回 4xx
→ 插件**当场 block / deny / rejected**,日志里有「永久失败」字样
→ 那次工具调用**没有执行**(这是生产事故的反面:
原本 400 被当暂时失败让位bash 就在无人批准下跑了)
→ 改造 503停接 Gateway插件才该让位给本地 UI
[ ] **模型看到的拒绝理由是真实原因**B-8.2c
造:让 Agent 把活派给自己另一条会话并要求跑 bash整条链上无人类
→ 模型收到的工具报错里带得出「没有人类用户」与服务端的 suggestion
→ **不得**是平台写死的「用户拒绝了」/ 「the user rejected tool X」
(那句话是假的 —— 没有任何用户看过这次询问)
→ 模型随后改道或在回信里说明需要人工执行,而不是反复重试同一个工具
[ ] **relay_key 收敛后能通过**B-8.9
clampRelayKey("<36 字节会话 id>:" + "A".repeat(500))
→ 结果≤ 160 字节、保留会话 id 前缀、尾部是 :sha256:<64 位 hex>
→ 直接 POST /permission/request 得 200未收敛的原始键得 400
→ Node 与 Go 两侧对同一输入算出**完全相同**的键
[ ] **「一直同意」真的免批**B-8.7 / B-8.8
造:一封信里要求连续三次单独调用 bash
@ -1286,6 +1485,7 @@ GET /api/v1/attachments/{id}
| 轮次结束 | `session.idle` 事件 | `agent/status``idle` | `prompt()` 的 promise resolve事件是 `agent_end` | 无「轮次」事件;靠 `RegisterOutputChannel` 的 handler 被调用 |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` | 无(核心不把模型错误暴露给插件) |
| 权限钩子 | `permission.ask`**同步,不能等** | `approval/request`(异步 waterfall**能等** | `tool_call` 扩展事件(**能 await**,实测) | **无审批环节**(核心不问人)。有 `StageBeforeToolcall` 可否决,但语义不同 —— 见 `B-8` |
| 拒绝理由能不能递给模型 | 能:`output.reason` | **不能** —— `'rejected'` 被翻译成写死的 `the user rejected tool "X"`;需在 `tools/post-execute` 返回 `{kind:'block', feedback}` 换掉(`B-8.2c` | 能:`{block:true, reason}` | N/A |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`**不传参**,传字符串会被当自定义目录) | N/A |
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`**不是** `getModels()`1221 条里只有 1 条能用) | N/A模型由核心配置插件不选 |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**SDK 会话没有平台标题,见下) | 邮件主题派生(无平台标题) |