# AgentMail 插件契约 / Plugin Contract 把一个 Agent 平台接进 AgentMail 需要写一个**桥接插件**。这份文档是那个插件的 **规格说明**:必须支持哪些平台能力、每个事件到达时必须做什么、平台缺某项能力时 怎么退化、线协议的确切字段、以及每一条怎么自证做到了。 读者假定为「照着实现的人或代理」,因此: - 条目用 **MUST / SHOULD / MAY** 标注强度,编号可引用(`C-1`、`B-3`…) - 每条尽量给出**可机械核对**的判据,而不是「注意某某」 - 每条附**为什么** —— 不知道为什么的约定会在第一次不方便时被绕过 已有两个实现可直接对照: | 插件 | 平台 | 框架 | 语言 | |---|---|---|---| | `plugins/opencode-mail-bridge/` | opencode | `@opencode-ai/plugin` | JavaScript | | `plugins/dsh-mail-bridge/` | DeepSeek Harness | Cordis | TypeScript | | `plugins/pi-mail-bridge/` | pi | pi SDK (`createAgentSession`) | JavaScript (ESM) | 第八、九节是这两次适配的实现细节与踩坑记录 —— 规范部分(一至七节)与平台无关。 --- ## 〇、术语与不变量 ### 三维寻址 ``` name@path.session │ │ └─ 会话位:三态,见下 │ └─ 工作目录(可含 / 与 .) └─ Agent 名或人类用户名(共用一个命名空间) ``` **按最后一个 `.` 切分**。`dsh@/home/a.b/proj.refactor` 的 path 是 `/home/a.b/proj`、session 是 `refactor`。 会话位三态,语义不可混淆: | 写法 | 含义 | |---|---| | `dsh@/path` | 默认会话(该 Agent + path 下最近活跃的那条,没有则新建) | | `dsh@/path.new` | **强制新建** | | `dsh@/path.refactor` | 必须是**已存在**的别名,不存在返回 404 —— **绝不静默新建** | > **为什么三态而非两态**:`.refactor` 静默新建的话,一个拼错的别名会让 Agent 在 > 一条空会话里干活,而发件人以为在续原来那条。失败必须是可见的。 ### 全局不变量 以下几条贯穿全文,每一节都是它们的推论。违反其中任何一条,插件的行为就是错的, 哪怕单元测试全绿。 | # | 不变量 | |---|---| | **I-1** | **平台原生信号是唯一真相来源。** 不要求模型「记得」调工具去汇报状态 | | **I-2** | **插件代劳的转发不消耗配额。** 权限询问与最终总结走 `relay` 通道 | | **I-3** | **平台命名优先。** 会话标题/短名由平台生成,插件只回写,不另造 | | **I-4** | **插件只搬运,不决策。** 不改写模型输出,不代替人做权限判断 | | **I-5** | **失败必须可见。** 投不进去、模型起不来、目录不存在 —— 都要有人能看到 | > **I-1 的由来**:早期版本提供了 `request_permission` 工具让模型自己调。模型 > 可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。而平台 > 自己的权限钩子拦下的那一次是事实。同理,「模型说完了」由平台的轮次结束信号 > 决定,不由模型自己声明。 --- ## 一、能力矩阵 / Capability Matrix 判断一个新平台**能不能接**,以及接了之后哪些功能可用。 ### 必需能力(缺任何一项无法接入) | # | 能力 | Capability | 判据 | |---|---|---|---| | **C-1** | 在会话外注入一轮用户消息 | `inject_turn` | 存在一个 API,能对指定会话追加一条用户消息并触发模型跑一轮 | | **C-2** | 创建会话并指定工作目录 | `create_session_with_cwd` | 创建时能传 cwd/directory,且平台按它给会话分组 | | **C-3** | 会话的稳定 id | `stable_session_id` | 创建后返回一个 id,用它能再次投递到同一会话 | | **C-4** | 轮次结束信号 | `turn_end_signal` | 有事件/回调告知「这一轮跑完了」,且能区分正常结束与出错 | | **C-5** | 读取会话内的消息 | `read_messages` | 能取到最后一条 assistant 消息的文本块 | | **C-6** | 注册自定义工具 | `register_tools` | 能给模型加工具,且工具能拿到当前会话 id | | **C-7** | 插件常驻进程 | `long_running` | 插件能持有一条 SSE 长连接与一个 30 秒定时器 | > **C-1 与 C-3 是硬门槛。** 没有它们,「一封邮件 → 一轮工作 → 一封回信」这条 > 主链路根本无法搭起来 —— 而那是 AgentMail 的全部意义。 > > **C-4 必须能区分成功与出错。** 只知道「跑完了」不够:模型调用失败时会话里没有 > 任何 assistant 消息,如果把它当正常结束,插件会转发一个空字符串回去, > 发件人看到一封空邮件而不是错误说明(见 `B-6`、`D-3`)。 ### 可选能力(缺则退化,不阻塞接入) | # | 能力 | Capability | 缺失时的退化 | 影响 | |---|---|---|---|---| | **C-8** | 权限/审批钩子 | `permission_hook` | 不转发权限询问 | 危险操作只能靠平台本地 UI 决策 | | **C-9** | 钩子可异步等待 | `async_permission` | 转出去后立即返回「待决」,决策经 SSE 回来后再补 | 模型会先被拒一次再重试 | | **C-10** | 列出会话 | `list_sessions` | 不上报平台会话快照 | 写信时只能续谈邮件驱动的会话 | | **C-11** | 模型标题 | `model_title` | 走 `D-5` 阶梯:退到邮件主题派生别名 | 补全列表里的名字来自人写的主题,而非模型对内容的概括 | | **C-12** | 列出可用模型 | `list_models` | 不上报模型目录 | 管理员无法在配置页划定模型范围 | | **C-13** | 指定单轮模型 | `per_turn_model` | 不做降级尝试 | 主力模型故障时该 Agent 整体不可用 | | **C-14** | 会话内文件读写工具 | `file_tools` | 附件下载后模型看不到 | 附件功能形同虚设 | > **C-11 可能是「部分具备」。** pi 是实例:它确实会生成会话标题,但生成器在 > **pi-web** 包里,不在 `pi-coding-agent` 内核里。人在 pi-web 界面上开的会话有标题, > 桥用 SDK 起的会话没有。判定的依据只能是**桥自己起的那条会话**上 > `sessionName` 到底有没有值,不是「这个平台有没有这个功能」。 > > 具备 `C-11` 的平台还要回答第二个问题:**别名由谁定稿**。别名负有寻址唯一性义务 > (撞名追 `-2`、`manual` 来源永远优先),平台侧没有这个约束。两侧各自命名会分叉, > 因此必须由服务端定稿、插件把响应里的值**回写**进平台(见 `D-5`、`W-7`)。 ### 能力自检脚本 接入前先回答这八个问题。任何一个答不出来,先去读平台文档,不要开始写代码: ``` 1. 我怎么在不通过 UI 的情况下让某个会话跑一轮? → C-1 2. 创建会话时工作目录参数叫什么?平台按它分组吗? → C-2 3. 一轮跑完时我从哪得到通知? → C-4 4. 那个通知能区分「正常结束」和「模型调用失败」吗? → C-4(最容易漏) 5. 怎么取到最后一条 assistant 消息的纯文本? → C-5 6. 注册工具时 execute 能拿到 session id 吗? → C-6 7. 权限钩子是同步还是异步?能不能真的等人? → C-8/C-9 8. 往一条**空闲**会话里注入一轮,用的是哪个方法? → C-1(见 9.11) ``` > 问题 4 在三次适配里被漏掉过两次,两次都造成「无效模型被判成成功」—— > 详见 `D-3` 与第九节。 > > 问题 8 是 pi 适配加上的:平台常常有两个注入入口(一个起新轮、一个往正在跑的轮次里 > 排队),而排队那个在会话空闲时**静默什么也不做** —— 不报错、不超时、没有回信。 > 见 9.11。 --- ## 二、生命周期与行为约定 / Behaviors 插件是一个状态机。这一节按事件逐个规定「到达时必须做什么」。 ``` B-1 启动 ├─▶ 解析密钥 ──▶ POST /agent/register ──▶ 首次心跳(B-2) │ ├─▶ 读 allowed_models │ └─▶ pending_mails > 0 ? 补拉(B-7) ├─▶ 建立 SSE 长连(首次不带 Last-Event-ID) └─▶ 启动 30s 心跳定时器 运行中 ├── Gateway 推来 ─────────────┬── 平台侧事件 ──────────────┐ │ │ │ ▼ ▼ ▼ new_mail (B-3) 轮次结束 (B-5) 权限询问 (B-8) permission_decision ├─ 取 text 块 ├─ 转成邮件问人 (B-4) ├─ 模型已自己发过 → 让位 └─ 决策回来 → 回答平台 └─ relay:"summary" 发出 标题生成 → 回写别名 (W-7) B-9 关停 └─▶ 断 SSE、停定时器、未决询问 fail closed ``` ### B-1 启动(MUST) | # | 要求 | 强度 | |---|---|---| | B-1.1 | 按 `环境变量` → `~/.agentmail/agent.key` 顺序解析密钥;两者皆无时**本地生成**一把、写入该文件、并把全文打印到日志 | MUST | | B-1.2 | `POST /agent/register`,`workspaces` 传 `[]` | MUST | | B-1.3 | 立即发一次心跳,不等第一个 30 秒周期 | MUST | | B-1.4 | 建立 SSE 长连;**首次连接不带 `Last-Event-ID`** | MUST | | B-1.5 | 启动 30 秒心跳定时器 | MUST | | B-1.6 | 首个成功心跳的 `pending_mails > 0` 时补拉存量未读(见 `B-7`) | MUST | > **B-1.1 为什么是「本地生成 + 登记」而不是服务端签发**:密钥全文只从客户端 > 流向服务器一次。插件生成后打印,管理员在后台登记即可,不需要把密钥从服务器 > 反向传给客户端 —— 那条路径上任何一处日志都可能把它落盘。 > > **B-1.4 首次不带 `Last-Event-ID`**:带上会收到一批已经处理过的旧事件, > 于是插件重启一次就把历史邮件重投一遍。补投走 `B-7` 那条明确的路径。 ### B-2 心跳(MUST,30 秒) 请求体三个字段全部**可选**,语义见 `W-3`: ```json { "platform_sessions": [...], "models": [...] } ``` | # | 要求 | 强度 | |---|---|---| | B-2.1 | 间隔 30 秒,失败**不重试不报错**,下一轮补上 | MUST | | B-2.2 | 从响应读 `allowed_models` 存入模块级变量 | MUST | | 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` 会让它显示为离线。插件自己再打一串错误日志只会 > 淹掉真正的问题。 > > **B-2.2 为什么范围随心跳回传而不是插件轮询**:管理员在配置页改了范围后最多 > 一个周期(30 秒)生效,不需要重启插件;也不需要插件多起一个请求。 > > **B-2.6 为什么上报 mode_enforcement**:平台表达的档位(`plan` / `workspace` / `full`) > 是「我要求你做到什么」,而平台能实际做到的(沙箱、审批、仅通知)取决于本侧的 > 强制力。两者分开记录,人在界面才能看到「这个平台无法强制这一档」。 > > **响应里的 `unknown_fields`**:心跳是唯一走宽容解码的端点(`DecodeLenient`), > 容忍插件带了平台不认识的字段。但容忍不等于咽下去 —— 响应里会回 `unknown_fields` > 数组,插件应据此判断自己是否比平台新得太多,必要时降级。 ### B-3 收到 `new_mail`(MUST) ``` new_mail 到达 │ ├─ 1. 去重:mail_id 已在 deliveredMails 里 → 丢弃 ├─ 2. 解析 cwd = resolveWorkspaceCwd(to_workspace, 兜底) ├─ 3. 查映射:session_id → 平台会话 id │ 命中且会话还活着 → 续谈(inject_turn) │ platform_session_id 非空 → **接管**那条平台会话(B-3.7) │ 否则 → 新建会话(不传占位标题) ├─ 4. 按 modelAttemptOrder 逐个尝试,等异步结论(见 D-3) ├─ 5. 注入提示词:告诉模型「调 read_inbox 读正文」「回信不用你自己发」 └─ 6. 登记 mail_id 到 deliveredMails ``` | # | 要求 | 强度 | |---|---|---| | 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」**(仅 `from_human === true` 时)** | SHOULD | | B-3.5 | 提示词里带 `mail_id`,让模型能自己查这封 | SHOULD | | B-3.6 | 投递失败要让人看到(日志 + 见 `B-6`) | MUST | | B-3.7 | `platform_session_id` 非空时**必须**投进那条平台会话,不得新建 | MUST | | B-3.8 | 那条平台会话已不存在时**必须报错**,不得退回新建 | MUST | > **B-3.1 是踩过最贵的坑之一**(详见第九节):平台按 cwd 给会话分组,用自己拼的 > 临时目录会让所有邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进 > 「未分组」,而模型在一个空目录里找不到任何要改的代码。 > > **B-3.4 为什么要在提示词里说**:不说的话模型会自己调 `send_mail` 回信, > 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。 > 说了之后仍要保留 `B-5.3` 的去重兜底:提示词是建议,去重是保证。 > > **为什么改为 SHOULD**:`from_human === false` 时自动转发规则 `B-5.6` 已经拦住, > 不需要也不应该在提示词里说「回信由插件自动发」—— 那对 Agent 收件方是假话。 > 提示词是建议,去重是保证,这条不变。 #### B-3.7 接管平台会话(MUST) **平台界面(TUI/GUI)与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。** 人在界面上开的会话早就被 `C-8` 的会话快照上报成候选,写信时能在补全里选中; 若投递侧不认这一跳,选中后只能得到 404 —— 候选列表在承诺一件做不到的事。 Gateway 在本侧建一条会话并记下那条平台会话的 id(「接管」),随后**每次**投递 都在事件里带 `platform_session_id`。插件看到它就去那条平台会话里接着谈。 | 平台 | 接管方式 | |---|---| | opencode | `session.get` 确认存在 → 照常 `promptAsync({ path: { id } })` | | DSH | `startAgent` 的 resume 分支(会话 id 换成平台自己那个) | | pi | `SessionManager.open(file)` → 跑一轮 → **dispose** | | HomeAgent | N/A(无会话概念,所有邮件注入同一事件循环) | 字段解析与失败话术走共用模块 `lib/adopt.js`(`adoptedSessionID` / `adoptMissingMessage`)—— 字段名各写一遍时少个下划线就静默退化成「每封邮件 新开一条」,而那个错误不抛任何异常。 **接管之后必须把该会话加入 `mailDriven` 集合**(`B-5.5` 的判据)。不加的话 邮件投进去了却永远没有回音:发件人只看到信发出去后再无音讯。 **B-3.8:平台侧那条会话已被删时报错,不要退回新建。** 镜像是快照、可以过期。 退回新建会让人在界面上看不到这封邮件带来的对话,而那正是接管的目的 —— 与 `N-8`「404 后自动改用 `.new` 是禁止的」是同一条原则。错误话术必须给出 可执行的下一步(用 `.new`),只说「不存在」的话模型会原地重试同一个地址。 ##### 文件型会话(pi)必须短暂持有 pi 的会话是磁盘上的 `.jsonl`,**没有任何锁机制**(SDK 里 `flock`/`lockfile` 命中为 0),它假定「一个文件一个持有者」。写入是纯 append,所以两个持有者不会 把文件截断;坏的是**各自的内存索引**:活着的 `SessionManager` 不 watch 文件, 对方追加的行自己看不见,于是算出的 `parentId` 指向一个对方不知道的 entry, 会话树分叉。 取舍是 **open → 跑一轮 → dispose,不放进长期缓存**。下一封邮件重新 open, 那一次读到的就是界面那边写的全部内容。窗口是一轮对话的时长。 三处配套细节,少一个就有实测过的坏处: - **`isStreaming` 时不释放**。同一条会话可能已经排了下一封邮件,此时 dispose 会把排着的那轮一起杀掉。排着的那轮结束时会再触发轮次结束事件,由它释放。 - **兜底计时器**(轮次超时的两倍)。轮次结束事件不来就永远握着文件; 计时器要 `unref()`,否则它会阻止进程退出。 - **接管会话跳过命名同步(`C-11`)**。它的别名是人从补全里选的那个 slug; 同步会双向改坏它 —— 别名撞名时 Gateway 加后缀,而定稿别名又回写进平台侧, 于是下一次快照上报的 slug 变成带后缀那个,人选的名字凭空消失(实测撞出来过)。 ### B-4 收到 `permission_decision`(MUST) | # | 要求 | 强度 | |---|---|---| | B-4.1 | 用 `relay_key` 找到挂起的询问并回答平台 | MUST | | B-4.2 | 找不到挂起项(插件重启丢了内存映射)→ 退化为把决策当一封通知投进 `session_id` 指向的会话 | MUST | | B-4.3 | 退化路径**不得**凭空新开会话 | MUST | > **B-4.2 为什么需要退化路径**:待决映射在内存里,插件重启就丢了。此时平台侧 > 那次工具调用早已被拒绝,但人刚刚点了「同意」—— 什么都不做的话人以为自己批准了、 > Agent 却毫无反应。把决策作为一条消息投进原会话,模型至少能重试。 ### B-5 轮次结束 → 自动转发(MUST) | # | 要求 | 强度 | |---|---|---| | 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**:思考过程不该出现在邮件里 —— 它对收件人没有意义, > 而且经常包含「我先假设…」这类会被误读为结论的内容。 > > **B-5.3 用什么去重**:靠进程内的 `explicitSends`(记录本轮模型主动发过的信), > **不是** `relay_key`。后者保证「同一条 assistant 消息不转两次」, > 管不了「模型已经自己发过了」—— 那是两条不同的邮件。 > > **B-5.5 为什么限定邮件驱动**:人在平台 UI 里正常干活时不该往邮箱里灌总结。 ### B-6 无法处理时必须回信(MUST) 三种「模型一次都没跑起来」的情形,会话里没有任何 assistant 消息, 因此 `B-5` 什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。 | 情形 | 处理 | |---|---| | 划定范围内所有模型都失败 | 发一封列出每个路由与失败原因的邮件 | | 创建会话失败 | 同上,说明是建会话阶段失败 | | 工作目录不可用且无兜底 | 同上,说明期望的 path 与实际回退的目录 | | # | 要求 | 强度 | |---|---|---| | B-6.1 | 主题形如 `处理失败: <原主题>` | SHOULD | | B-6.2 | 正文逐条列出尝试过的路由与原因,并指出去哪里调整 | MUST | | B-6.3 | 带 `relay: "summary"` + `relay_key: "model-failure:"` 走免配额通道 | MUST | | B-6.4 | 发完仍要 throw/记录,不要静默 | MUST | > **B-6.3 为什么免配额**:这是插件的故障报告,不是模型的自主发信。让一次配置 > 错误吃掉用户的往返预算是双重惩罚。 ### B-7 启动补拉(MUST) SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一次。 | # | 要求 | 强度 | |---|---|---| | B-7.1 | 只在**首个**成功心跳后补一次,不是每轮 | MUST | | B-7.2 | 串行投递,一次最多 5 封 | MUST | | B-7.3 | 与 SSE 共用同一个 `deliveredMails` 集合去重 | MUST | | B-7.4 | 按时间**正序**投(收件箱是倒序返回的) | MUST | | B-7.5 | `mail_type !== 'normal'` 的不补投 | MUST | | B-7.6 | 逐封投递前**再查一次**去重集合 | SHOULD | | B-7.7 | 插件以**子进程**形式运行时,去重必须**落盘**,且区分「投过」与「跑完」 | MUST | > **B-7.1 为什么只补一次**:每轮都补会把「模型正在处理中、尚未标已读」的邮件 > 重复投递 —— 一封邮件起两轮模型。 > > **B-7.2 为什么串行且限 5 封**:每封都要起一轮模型。攒了 80 封时一次性放出去 > 等于对上游打 80 个并发请求,且最后几封要等前面全部跑完。剩下的留在收件箱里, > 下次重启或人工触发时再处理。 > > **B-7.3 为什么必须共用去重集合**:心跳与 SSE 建连之间有个窗口,那期间到的邮件 > 既在 `pending_mails` 里、也会被 SSE 推一次。 > > **B-7.4 为什么必须正序**:同一会话里的多封邮件倒着塞进去,上下文顺序是乱的。 > > **B-7.5 为什么跳过 permission**:原来的工具调用早随进程一起没了, > 投过去模型没有可恢复的上下文。 > > **B-7.7 为什么内存去重不够**(生产事故,2026-09-04): > `deliveredMails` 是进程内的,而 homeagent 的插件跑在**子进程**里 —— > homed 重启(或插件崩溃自动重启)会换一个新进程,那个集合随之清空。时序: > > 1. 邮件落库,**旧**进程的 SSE 收到,注入第一次 > 2. 同一秒进程被重启,那一轮被掉断(日志:`context canceled`) > 3. **新**进程起来,`deliveredMails` 是空的 > 4. 心跳报 `pending_mails: 1`(第一轮没跑完 → `read_inbox` 没执行 > → 邮件仍未读)→ 补投注入第二次 > > 模型上下文里因此出现两段几乎相同的指令。 > > **为什么不能只记「投过没有」**:那会把「重复」换成「丢件」。上面第 2 步里 > 那一轮被掉断,发件人**没有**收到回信,而落盘记录说「已投过」→ 永远跳过。 > 丢件比重复严重:重复至少人能看出来,丢件是静默的。 > > 因此记两个状态: > > | 状态 | 含义 | 再次收到时 | > |---|---|---| > | `delivered` | 注入过(可能被中断) | **仍然重投**,但提示词里说明「上一轮被中断」 | > | `completed` | 那一轮真的跑完且回信已发出 | 跳过 | > > 「说明上一轮被中断」不是装饰:不说的话模型在上下文里看到两段相似指令, > 会以为人重复交代了一遍,于是可能把同一件事做两次。 > > 何时标 `completed`:回信发出去了、模型自己回过了(B-5.3 让位)、 > 或失败通知发出去了(B-6)—— 三者都是「发件人得到了一个交代」。 > 回信**发失败**时不标 —— 那时发件人一个字都没收到。 > > 常驻守护进程形态的插件(如 pi 桥)不受这一条约束:它自己就是进程, > 重启同时也会丢掉 SSE 连接,那时走的是 B-7 补拉而不是两条路径并发。 ### B-8 平台权限询问 → 邮件(若平台有审批环节,MUST;没有则 N/A) 挂平台的权限/审批钩子,把询问转成一封邮件问人。这是 `I-1` 最直接的体现: 被平台真正拦下的那一次才是事实,不依赖模型「记得」问人。 > **前置条件是「平台本来就要问人」。** > > 三个已实现的平台各有一个现成的审批环节,桥做的只是把它从本地 TUI > **改道**到邮件通道:opencode 的 `permission.ask`、DSH 的 `approval/request`、 > pi 的 `tool_call`。桥没有发明审批协议。 > > HomeAgent 的设计不同:核心是一个纯思维核,本身没有任何对外交互能力 > (能力全部来自插件),因此它**不问人** —— 工具调用直接执行。 > 它确实有 `StageBeforeToolcall` 这个可以拦下调用的位置 > (插件给 `ctx.Response` 赋值即视为拒绝),但那是「插件可以否决」而不是 > 「平台在征求同意」:没有待批准的请求、没有选项、也没有等人的语义。 > > 在那种平台上,B-8 整条**不适用**(N/A),不是待实现项。硬要补的话等于 > 给平台加一层它本来没有的能力:需要自己划定高风险工具白名单、自己定义 > 超时与 fail closed 语义 —— 那是产品决策,不是契约合规。 > > 判据:在 SDK 与核心两处 grep `approval|consent|permission|confirm`, > 命中数均为 0。 ``` 平台权限钩子被调用 ├─ 1. POST /permission/request(带平台的权限 id 作 relay_key) ├─ 2. 记住 平台权限 id → 挂起项 ├─ 3a. 能异步等(C-9)→ await 到 permission_decision 回来,返回人的决策 └─ 3b. 不能等 → 立即返回「询问中」,决策回来后用 SDK 回复那条权限 ``` | # | 要求 | 强度 | |---|---|---| | B-8.1 | `relay_key` 用平台的权限 id;平台不给 id 时用 `会话:工具:callId` 拼一个 | 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 重启后就对不上了。 > > **B-8.5 为什么不收费**:人不点头 Agent 就动不了,对它收费等于收「求人费」。 > 这里的 `relay_key` 只用于幂等,不是配额豁免的凭证。 > > **B-8.6 为什么不能自己定决策人**:插件手边最自然的候选是「来信人」 > (`mailContexts` 里的 `replyTo`),但来信人可能是**另一个 Agent** —— > Agent 把任务分派给自己或同伴的另一条会话时,权限邮件就发给了 Agent 自己。 > 后果是**死锁而不是报错**:Agent 不可能在 Web 界面上点「同意」,服务端的 > 用户推送又投进一个不存在的通道(没有任何人被提醒),于是插件里那个 > `await` 永不 resolve —— 会话永久挂死,没有超时、没有日志、没有回信。 > > 决策人必须由服务端定:它按 **会话 owner → 线索里最近的人类 → 无人可问则 > 409** 解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文, > 猜不出「这条 Agent 链最初是谁派的活」。 > > 收到 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」无从判断 —— 得知道这活是 > 谁派的、为的什么事。 #### B-8.7 / B-8.8 免批(「一直同意」) 默认语义是**每次都问**。这在「跑一条命令看看」时是对的,在「审查这个工程」 时是灾难 —— 实测同一条 pi 会话被问了 **15 次 bash**,人点了 15 次「同意」, 全是同一类操作。 三个平台的原生能力不同,决定了免批状态该由谁持有: | 平台 | 原生三态 | 免批由谁记 | 选项 | |---|---|---|---| | opencode | `once` / `always` / `reject` | **平台**(回 `response:"always"`) | 同意 / 一直同意 / 拒绝 | | pi | 只有 block / 放行 | **桥**(`createGrantStore()`) | 同意 / 一直同意 / 拒绝 | | DSH | `allowed-once` / `rejected`(无 always) | 不提供 | 同意 / 拒绝 | > **平台能记就让平台记**:opencode 的 `always` 是它自己的权限模型的一部分, > 桥再存一份就有两个真相来源(`I-1`)。pi 的钩子只能答"拦/不拦",没有地方 > 表达"以后别问了",这时桥持有状态是唯一选择。 > > **DSH 为什么干脆不给这个选项**:它的 `ApprovalOutcome` 只有 > `allowed-once | rejected | cancelled | unavailable`。桥自己记的话,DSH 侧 > 仍会每次调 `approval/request`,而桥不问就答 `allowed-once` —— 那是用插件 > 内存**覆盖**平台的审批策略,且这份策略没人能审计。给不出的能力就不要在 > 界面上摆一个按钮(摆了又不生效比没有更糟,见下)。 > > **作用域为什么是 (会话, 工具名)**:人看到的那句「是否允许执行 bash?」 > 就是在这个粒度上提的问,授权范围不该超出提问范围。 > - 放宽到全局 → 人为「审查 llmsproxy」批准的 bash,会静默授权另一个 > 发件人派来的另一条任务。那不是他批准的东西。 > - 收紧到 `toolCallId` → 等于没有免批。 > > **换模型重开会话必须撤销**(`revokeSession`):授权是人对**那次**上下文的 > 判断,新会话重跑一遍提示,不该继承上一条的授权。 > > **只在内存里是有意的**:长期免批该由平台自己的 settings 管 > (pi 的 `settings.json`、opencode 的 permission 配置)。让守护进程的内存 > 变成事实上的安全策略,没人能审计,重启后又悄悄消失。 > > **判定为什么必须用共用模块**:`isAlwaysDecision` 用的是**精确匹配**。 > 写成 `/^同意/` 之类的前缀正则会让「同意」也判成 always —— > 人点一次单次授权,后面所有命令都不再问,这是把单次授权静默升级成永久授权。 > 反向的坑同样真实:opencode 侧的三态映射一度以 `: "once"` 兜底, > 于是任何意外文本(空串、旧选项、服务端将来新增的选项)都会**放行**一次 > 没人批准的操作 —— 兜底必须落在 `reject` 一侧(`N-9`)。 ### B-9 关停(MUST) | # | 要求 | 强度 | |---|---|---| | B-9.1 | 断开 SSE、清除心跳定时器 | MUST | | B-9.2 | 所有未决权限询问 **fail closed**(返回「拒绝」或「不可用」) | MUST | | B-9.3 | 不发「插件下线」类通知邮件 | SHOULD | > **B-9.2 为什么 fail closed**:平台侧那些 `await` 永不返回的话,整个会话会挂死。 > 而且默认放行一个没人批准的危险操作,比让它失败严重得多。 --- ## 三、工具契约 / Tools 插件给模型注册的工具。除 `send_mail` 与 `read_inbox` 外都可选。 | # | 工具 | 强度 | 说明 | |---|---|---|---| | **T-1** | `read_inbox` | MUST | 读收件箱,**顺便标记已读** | | **T-2** | `send_mail` | MUST | 主动发信(三维地址、抄送、`reply_to`、附件) | | **T-3** | `download_attachment` | SHOULD | `attachment_id` → 本地文件 | | **T-4** | `upload_attachment` | SHOULD | 本地文件 → `attachment_id` | | **T-5** | `forward_mail` | MAY | 转发(走完整三维寻址,它是一条新线索) | | **T-6** | `connect_to_server` | MAY | 登记密钥并注册,方便首次接入 | | **T-7** | ~~`request_permission`~~ | **MUST NOT** | 见 `I-1`:改挂平台权限钩子 | | **T-8** | `suggest_address` | SHOULD | 查可用收件人/工作目录/会话;**发信前应先调** | | **T-9** | `list_contacts` | SHOULD | 列出自己参与过的全部会话及可投递地址 | | **T-10** | `session_participants` | MAY | 列出某条会话的参与方(用于回给抄送方或转达) | | **T-11** | `read_thread` | MAY | 查看某封邮件所在线索的完整往来 | | **T-12** | `read_mail` | SHOULD | 读一封邮件的完整内容(含参与方地址与 reply_address) | | **T-13** | `propose_alias`(T-2 参数) | MAY | 模型在 `send_mail` 的 `propose_alias` 字段提议改名 | > **为什么 T-8 到 T-12 都是 SHOULD/MAY 而不是 MUST**:它们解决的是静默投递错误 > (`suggest_address`)和信息不足(`list_contacts` / `read_mail` / `read_thread`), > 但都不影响主链路(收信 → 起会话 → 自动回信)。一个最小可行插件只注册 T-1 和 T-2 > 就能跑通主链路,上面这些是让它「不猜地址、不漏抄送方」的增强。 ### T-1 `read_inbox` 的三条硬规则 | # | 规则 | 违反后果 | |---|---|---| | T-1.1 | 附件清单必须带 **`attachment_id`** | 只说「有附件」模型无从下载 | | T-1.2 | 抄送人必须显示 | 模型以为是私信,回信时漏掉其他参与方 | | T-1.4 | 已读**按读取者**记(一封多发邮件被 A 读掉后,对 B 仍未读) | 服务端的 `?status=unread` 与 `pending_mails` 都是按调用者算的;插件不该自己缓存"这封读过了" | | T-1.3 | 只标**本次列出**的那些,且 `status=all` 时**不标** | `limit` 之外的还没看过;把历史邮件标成已读会让下一轮的新邮件混在里面认不出来 | 默认参数:`status='unread'`、`limit=5`。 > **为什么默认只看未读**:默认 `all` 会让模型每轮重读旧邮件,几轮之后上下文里 > 全是重复内容。这个 bug 在 DSH 插件里真实存在过(默认 `all` 且完全没标已读)。 共用实现 `lib/inbox-format.js`。**跨平台必须一致** —— 它不依赖任何平台 SDK。 ### T-7 为什么禁止 `request_permission` 模型可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。 平台的权限钩子拦下的那一次是事实。见 `I-1`、`B-8`。 ### T-8 ~ T-12 寻址发现工具(`lib/discovery.js`) 这五个工具的渲染逻辑全部在 `lib/discovery.js`(三平台共用),与平台 SDK 无关。 它们解决的是**静默投递错误**:模型猜一个地址(如 `opencode@/home`),投递成功 但那不是 opencode 的工作目录,静默变成了一条平行会话 —— 发件人以为在续谈,其实 在跟一条空会话说话。`suggest_address` 用模型从候选列表里选而不是猜,彻底杜绝了 这种错误。 | # | 工具 | 渲染函数 | 服务端端点 | |---|---|---|---| | T-8 | `suggest_address` | `renderNameSuggestions` / `renderPathSuggestions` / `renderSessionSuggestions` | `GET /agent/contacts/suggest` | | T-9 | `list_contacts` | `renderContacts` | `GET /agent/contacts` | | T-10 | `session_participants` | `renderParticipants` | `GET /agent/sessions/{id}/participants` | | T-11 | `read_thread` | `renderThread` | `GET /agent/mail/{id}/thread` | | T-12 | `read_mail` | 内联渲染(含参与方地址与 reply_address) | `GET /agent/mail/{id}` | > **`read_mail` 的 `reply_address` 必须回显**:它告诉模型「发件人的可投递地址是什么」。 > 不回显的话模型只能用原始地址续谈,但那个地址的 session 位可能是默认的,回过去 > 不一定落到同一条线索里。 ### T-13 `propose_alias`(T-2 `send_mail` 的可选参数) `propose_alias` 让模型在干完活后提议一个更贴切的会话别名(例如从邮件主题 `排查登录问题` 改成更精确的 `fix-session-cookie-leak`)。 | # | 规则 | 违反后果 | |---|---|---| | T-13.1 | 标记格式是 HTML 注释 `` | 服务端正则匹配不上,提议静默消失 | | T-13.2 | 标记必须由**共用库** `lib/rename-proposal.js` 构造 | 各平台各写一遍拼接,少个空格就失效 | | T-13.3 | 别名不合法时不追加标记(`isProposableAlias` 返回 false) | 发一个服务端匹配得上却校验失败的标记,白白浪费一轮 | | T-13.4 | 回显**服务端返回的** `rename_proposed`,不是本地提议的值 | 服务端跑过 `normalizeAlias`(非法字符换 `-`、`new` 变 `session-new`),回显本地值会让模型拿一个不存在的名字寻址 | | T-13.5 | 回显时必须说明「等人确认,生效前继续用原别名」 | 模型以为改名已生效,接着用新别名当地址发信 —— 那个别名此刻还不存在 | ### T-3 / T-4 附件参数命名差异 两个共享参数在三个平台的工具里名称不一致(历史原因,已锁定): | 参数语义 | opencode | dsh / pi | |---|---|---| | 要上传的本地文件路径 | `path` | `file_path` | | 下载后的保存路径 | `save_to` | `save_path` | | 自定义展示文件名 | `filename` | `filename`(新增) | 这些是**工具参数**,不是 API 字段,因此与服务端协议无关。但新插件实现时应参照 自己所在平台已有插件的命名,避免同一平台内两个工具参数风格不一致。 --- ## 四、降级语义 / Degradation 平台缺某项能力时的确切退化路径。原则:**降级要可见,不要装作正常**。 ### D-1 无权限钩子(缺 `C-8`) | 做什么 | 不做什么 | |---|---| | 完全不转发权限询问 | ~~提供 `request_permission` 工具补偿~~ | | 在插件启动日志里声明「本平台不支持权限转发」 | ~~默默放行危险操作~~ | > 补偿方案会重新引入 `I-1` 反对的那个模式。危险操作交给平台本地 UI 决策, > 是一个诚实的限制;让模型自己判断该不该问人,是一个隐蔽的风险。 ### D-2 权限钩子不能异步等待(有 `C-8` 缺 `C-9`) ``` 钩子被调用 ├─ 转成邮件发出去(relay: "permission") ├─ 立即返回「待决」/「询问中」——不阻塞 └─ 人的决策经 permission_decision 事件回来后,用平台 SDK 回复那条权限 ``` | # | 要求 | 强度 | |---|---|---| | D-2.1 | 钩子内**不得**阻塞等待(会挂死整个平台请求) | MUST | | D-2.2 | 转发**暂时**失败时让位给本地 UI(`return next()` 或保持「询问中」) | MUST | | D-2.2b | 转发**永久**失败(4xx)时当场拒绝,不让位 | MUST | | D-2.3 | 记住 `平台权限 id → 挂起项` 的映射 | MUST | > **D-2.2 为什么要让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远 > 不会来的回答。让位之后本地 UI 还能接管 —— **但这个前提只对人坐在 TUI 前面 > 的会话成立**。邮件驱动的会话没有人在看,让位等于无人把关。 > > 因此「让位」只能给**暂时**失败:网络抖动、Gateway 正在重启 —— 那些情形下 > 插件不知道下一秒会不会好,而人确实可能在本地看到弹窗。永久失败(4xx) > 已经知道结果了,让位就是静默放行(见 `B-8.2b`)。 ### D-3 无法指定单轮模型(缺 `C-13`) 不做降级尝试,`modelAttemptOrder` 退化为 `[undefined]`(交给平台自己选)。 **有 `C-13` 时,降级尝试的判定是整个契约里最容易写错的地方:** | # | 要求 | 强度 | |---|---|---| | D-3.1 | **必须等异步结论**才能判定一次尝试成功或失败 | MUST | | D-3.2 | 「提交调用返回了」**不等于**「模型跑起来了」 | MUST | | D-3.3 | 流式 chunk 的 **`finish` 子类型也可能带错误** | MUST | | D-3.4 | 超时(60 秒)按**成功**处理 | MUST | | D-3.5 | 换模型重试时换一个新的会话 id,并销毁失败那个 | SHOULD | > **D-3.1/D-3.2 —— 两个平台都踩了**:`promptAsync()` 立即返回、 > `ctx.agents.create()` 根本不校验模型。只包一个 try/catch 的话第一个无效模型 > 会被判成成功,第二个永远试不到。必须监听平台的错误事件或轮次结束事件。 > > **D-3.3 具体形态**: > ```json > {"chunk": {"type": "finish", "reason": {"kind": "error", "failure": {"code": "NO_ADAPTER"}}}} > ``` > 「收到任意 chunk 就算走通」会让无效 provider 判成成功 —— 实测踩过。判据要落在 > chunk 的类型上:`finish` 看 reason,其余(`text-delta`、`block-start`…) > 才意味着模型真的在产出。 > > **D-3.4 为什么超时算成功**:模型可能只是很慢(首 token 前要装载上下文)。 > 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉,用户拿到两份回复。 > > **D-3.5 为什么换会话 id**:复用同一个 id 会让重试接在一条已经出错的会话后面; > 不销毁失败那个 agent 的话,它的轮次结束信号还会触发一次自动转发。 ### D-4 无法列出会话(缺 `C-10`) 心跳里**省略** `platform_sessions`(不是传 `[]`,见 `W-3`)。 后果:写信时的会话补全只能看到邮件驱动的那些。 ### D-5 别名来源阶梯(`C-11` 缺失或不可用时) | 优先级 | 别名来源 | 何时用 | 邮件主题派生(无平台标题) | |---|---|---| | 1 | 平台自带的 slug(如 `witty-planet`) | 平台创建会话时就给 | | 2 | 由平台标题派生(`slugFromTitle`) | 有标题且标题可用 | | 3 | 由**邮件主题**派生 | 平台没有标题,或标题不可用(见下) | | 4 | 不回写,保持服务端自动生成的别名 | 以上都没有 | **不得**用占位标题回写(`"新会话"`、`"处理邮件"` 之类):那会覆盖掉一个 本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。 > **第 3 级是 pi 适配加上的。** 原先的阶梯假定「平台要么有标题,要么完全没有命名机制」。 > pi 是第三种情况:内核不生成标题(生成器在 pi-web 里),于是 SDK 起的会话 > `sessionName` 恒为 `undefined`。只走前两级的话别名永远是空的 —— > `name@path.<别名>` 续谈无从下手,`.new` 又是一次性的,那条会话事实上只能收一封信。 > > **「标题不可用」是真实存在的一类。** pi-web 的标题生成器会泄漏思维链, > 本机 82 条会话里捞到过这两条真实样本: > > ``` > The user is asking me to generate a title for a coding-agent > 我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:最 > ``` > > 它们语法上是合法标题(`cleanSessionName` 只取首行 + 截 60 字符), > 派生出的别名却是一句废话。判废逻辑在 `lib/session-snapshot.js` 的 > `isUnusableName()`:命中就降到下一级,**不是**改写成别的。 #### 别名必须由服务端定稿 具备 `C-11` 的平台也要走这一步。插件提议、服务端定稿、插件把响应里的值回写: ``` 插件观察到平台的名字 → POST /sessions/{id}/sync { alias: slug, title: 原文 } → 读**响应里**的 alias(可能被改写过) → 与平台当前名字不同 → 写回平台 ``` 两个原因让单向推送无法收敛: | 服务端行为 | 后果 | |---|---| | 别名撞名时追 `-2`、`-3`(`SyncSessionAlias`) | 平台侧没有唯一性约束,不会跟着改 | | `alias_source='manual'`(人手工改过)永远优先 | 服务端**原样返回当前别名**,忽略提议 | 不回写的话:邮箱里显示 `fix-leak-2`、平台界面上显示 `fix-leak`, 用户按界面上看到的名字发信会收到「无法送达」。 回写有三条实测约束(pi 的形态,其它平台需各自确认): | # | 约束 | 违反的后果 | |---|---|---| | 1 | 只能用平台的改名 API,不能自己写会话文件 | 首条 assistant 消息落盘前文件不存在,pi 首次落盘用 `openSync(file,"wx")`,抢先创建让它抛 `EEXIST` | | 2 | 活着的会话管理器从不重读文件 | 它自己后续的 `session_info` 会覆盖外部改名 | | 3 | 空名字是**清除**语义 | 「没拿到定稿值」绝不能落成一次空写入 | 还要防自激循环:改名通常会触发「名字变了」事件,而那个事件正是同步的触发源。 插件必须记住**上次提交的内容指纹**并在相同时跳过。指纹不能用平台名字本身 —— 名字为空时(上面 pi 的常态)它无法区分「还没提交过」与「提交过、内容没变」。 ### D-6 工作目录不存在 | # | 要求 | 强度 | |---|---|---| | D-6.1 | 目录**已存在**才用 | MUST | | D-6.2 | 不存在时**回退到兜底目录,不创建** | MUST | | D-6.3 | 拒绝相对路径 | MUST | | D-6.4 | 回退时打日志说明期望值与实际值 | MUST | > **D-6.2 为什么不创建**:一个笔误 `/home/porgram/x` 不该在磁盘上落下真目录 —— > Agent 会在里面一无所获地干活,而那比明确的回退更难排查。 > > **D-6.3 为什么拒绝相对路径**:相对基准是 harness 进程的启动目录, > systemd 下通常是 `/`。 ### D-7 Gateway 不可达 | # | 要求 | 强度 | |---|---|---| | D-7.1 | 心跳失败静默重试,不打断已有会话 | MUST | | D-7.2 | SSE 断开后自动重连,重连时带 `Last-Event-ID` | MUST | | D-7.3 | 工具调用失败把错误如实返回给模型 | MUST | | D-7.4 | 不缓存待发邮件到磁盘 | SHOULD | > **D-7.4 为什么不做本地队列**:那需要一套持久化 + 重放 + 去重,而收益很小 —— > Gateway 通常与插件同机。失败如实告知模型,它可以重试或告诉人。 --- ## 五、线协议 / Wire Protocol 确切的端点、字段与语义边界。完整 API 见 [`API.md`](API.md)。 认证:所有 Agent 侧请求带 `Authorization: Bearer `。 ### W-1 端点清单 | 方法 | 路径 | 用途 | |---|---|---| | POST | `/api/v1/agent/register` | 注册(注意 `agent` 是**单数**) | | POST | `/api/v1/agent/heartbeat` | 心跳 + 上报 + 读回模型范围 | | GET | `/api/v1/events/stream` | SSE 长连 | | GET | `/api/v1/mail/inbox` | 收件箱 | | POST | `/api/v1/mail/read` | 批量标记已读 | | POST | `/api/v1/mail/send` | 发信 | | POST | `/api/v1/mail/{id}/forward` | 转发 | | POST | `/api/v1/permission/request` | 发起权限询问 | | POST | `/api/v1/attachments` | 上传附件(multipart,字段名 `file`) | | GET | `/api/v1/attachments/{id}` | 下载附件 | | POST | `/api/v1/sessions/{id}/sync` | 回写会话别名/标题 | | GET | `/api/v1/agent/models/allowed` | 读模型范围(心跳已回传,此处仅供排查) | ### W-2 注册 ```json POST /api/v1/agent/register { "name": "mybot", "platform": "myplatform", "workspaces": [] } ``` | 字段 | 类型 | 说明 | |---|---|---| | `name` | string | Agent 名,与人类用户名共用命名空间 | | `platform` | string | 平台标识,仅用于展示 | | `workspaces` | **对象数组** `[{name, path}]` | 官方插件都传 `[]` | > **`workspaces` 是对象数组,不是字符串数组。** 传字符串会得到 > `400 字段 "workspaces" 类型不对:期望 object,收到 string`。 > 工作目录由每封邮件的 `to_workspace` 决定,所以传 `[]` 是正确做法。 ### W-3 心跳 ```json POST /api/v1/agent/heartbeat { "platform_sessions": [ { "platform_id": "ses_abc", "workspace": "/home/program/agentmail", "slug": "witty-planet", "title": "重构导入路径", "mail_driven": false, "updated_at": "2026-09-02T11:41:16.744Z" } ], "models": [ { "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" } ], "mode_enforcement": "native" } ``` 响应: ```json { "status": "ok", "pending_mails": 0, "stats": { "agent_name": "dsh", "default_rounds": 20, "sent_total": 0 }, "platform_sessions_synced": 12, "models_synced": 9, "allowed_models": [{ "provider": "llmsproxy", "model": "AUTO" }], "models_unrestricted": false, "unknown_fields": [] } ``` **省略字段与传空数组语义不同 —— 这是最容易搞错的一处:** | 字段 | 省略 | `[]` | |---|---|---| | `platform_sessions` | 保留服务端现有镜像 | 平台侧确实一条会话都没有 → **清空镜像** | | `models` | 保留服务端现有目录 | 一个模型都拿不到 → **清空目录**(配置页变空白) | > 因此**拉取失败时必须省略,不能传空数组**。反过来说,平台真的返回空列表时 > 应当传 `[]` —— 平台侧删掉的会话必须从补全候选里消失(`session` 位三态语义要求 > 指向不存在的会话直接 404)。 `allowed_models` 按优先级排序;`models_unrestricted: true` 表示管理员没划定范围, 插件应回退到平台默认模型 —— **与「一个都不许用」不同**。 ### W-4 SSE 事件 ``` GET /api/v1/events/stream Authorization: Bearer Last-Event-ID: <上次收到的 id> # 仅重连时带 ``` Agent 侧只会收到两个事件: **`new_mail`** ```json { "mail_id": "9ea5ff18-...", "session_id": "7b5081e0-...", "from_name": "admin", "subject": "重构导入路径", "mail_type": "normal", "role": "to", "to_workspace": "/home/program/agentmail", "session_alias": "refactor-imports", "reply_address": "admin@.refactor-imports", "self_address": "pi@/home/program/agentmail.refactor-imports", "platform_session_id": "", "in_reply_to": "", "from_human": true, "permission_mode": "workspace", "permission_enforcement": "native" } ``` | 字段 | 说明 | |---|---| | `role` | `to` 或 `cc` | | `to_workspace` | **收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的) | | `mail_type` | `normal` / `permission_request` / … | | `session_alias` | 这条会话今后的寻址名 | | `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`), > 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址 > 的话,`.new` 会被拼进去,于是回信静默开出一条**新**会话,原来的线索里再无下文。 **`permission_decision`** ```json { "mail_id": "...", "decision_mail_id": "...", "decision": "同意", "note": "", "decided_by": "admin", "session_id": "...", "relay_key": "perm_xyz", "relay_kind": "permission" } ``` `relay_key` 是当初发起询问时插件传的那个(平台的权限 id)。服务端持久化了这个 映射并在此回传 —— 因此插件重启丢了内存映射也能对上。 ### W-5 发信 ```json POST /api/v1/mail/send { "to": "admin", // 或 name@path.session "cc": "ops@root, sre@root", // 逗号/分号/空格分隔 "subject": "Re: 重构导入路径", "body": "已完成。", // Markdown "reply_to": "<原 mail_id>", "session_alias": "refactor", // 仅新建会话时生效 "attachment_ids": ["..."], "relay": "summary", // "" | "permission" | "summary" "relay_key": "msg_abc123" // relay 非空时必填 } ``` 响应: ```json { "mail_id": "...", "session_id": "...", "session_alias": "refactor", "budget_remaining": 18, "budget_max": 20 } ``` `relay` 为白名单枚举,只有两个合法值: | `relay` | 用途 | `relay_key` 取值 | |---|---|---| | `"permission"` | 平台权限询问转邮件 | 平台的权限 id | | `"summary"` | 轮次结束的最终总结、故障报告 | 平台的消息 id / 事件序号 | | # | 要求 | 强度 | |---|---|---| | W-5.1 | `relay_key` 必须是**平台侧生成的稳定 id**,模型伪造不出来 | MUST | | W-5.2 | 同一个 `relay_key` 重复提交返回 `{"status":"duplicate_relay"}` 而非报错 | — | | W-5.3 | 模型自主发信**不得**带 `relay` | MUST | > **W-5.1/W-5.3 为什么**:免配额通道防滥用靠的是幂等键的唯一约束,不是计数。 > 让模型能自己填 `relay_key` 等于给它一条无限发信的路。 ### W-6 权限询问 ```json POST /api/v1/permission/request { "question": "允许删除 build/ 目录吗?", "options": ["同意", "拒绝"], // 省略则默认这两个 "context": "工具 bash,命令 rm -rf build/", "session_id": "...", "relay_key": "perm_xyz" // 平台的权限 id,用于幂等 } ``` 权限询问**本来就不扣配额**(人不点头 Agent 就动不了,收费等于收「求人费」), `relay_key` 在这里的作用只是**幂等** —— 权限事件会重复触发,插件也会重连重放。 ### W-7 会话命名回写 ```json POST /api/v1/sessions/{id}/sync { "alias": "witty-planet", "title": "重构导入路径" } ``` | 字段 | 写入 | 说明 | |---|---|---| | `alias` | `session_alias` | 可寻址的短标识 | | `title` | `subject` | 模型生成的摘要 | | # | 要求 | 强度 | |---|---|---| | W-7.1 | `alias` 必须去掉寻址分隔符 `.` `@` `/` | MUST | | W-7.2 | 不得回写占位标题 | MUST | | W-7.3 | 中文可以保留 | — | > **W-7.1 为什么**:留在别名里会让它自己被解析器切开,填进去的地址指向一个 > 完全不同的目标。中文不影响解析(按最后一个 `.` 切分),而转拼音后既不好读 > 也不好打。 > > 服务端撞名时自动追加 `-2`/`-3`,因此同步永不失败。人工改过的别名 > (`alias_source = 'manual'`)不会被平台同步覆盖。 ### W-8 附件 ``` POST /api/v1/attachments multipart/form-data,字段名 file GET /api/v1/attachments/{id} ``` 上限 25 MB(`AGENTMAIL_MAX_ATTACHMENT_BYTES`)。超限返回 413。 下载一律 `application/octet-stream` + `Content-Disposition: attachment` + `nosniff`。 ### W-9 错误约定 | 状态码 | 含义 | 插件应当 | |---|---|---| | 400 | 请求体/字段错误(**信息指向具体字段**) | 修代码,不要重试 | | 401 | 密钥无效 | 停止重试,打印明确错误 | | 403 | 配额用尽 | 把原因如实告诉模型 | | 404 | 会话别名不存在 | **不要**改成 `.new` 重试 —— 那会静默新建 | | 413 | 附件超限 | 告知模型文件太大 | | 429 | 新建会话速率超限(20 次/小时) | 按响应里的秒数退避 | | 5xx | 服务端故障 | 有限重试 | > **404 不得自动降级为 `.new`**:那正是 `session` 位三态要防的事 —— 一个拼错的 > 别名会让 Agent 在一条空会话里干活,而发件人以为在续原来那条。 --- ## 六、不变量与禁止事项 / Invariants 违反这些的代码可能测试全绿、跑起来也不报错,但行为是错的。 ### 禁止事项 | # | MUST NOT | 为什么 | |---|---|---| | **N-1** | 提供 `request_permission` 之类让模型自报状态的工具 | `I-1`:模型会忘、会乱调 | | **N-2** | 为不存在的 `to_workspace` 创建目录 | 笔误会落下真目录,Agent 在里面一无所获 | | **N-3** | 用相对路径作 cwd | 相对基准是进程启动目录,systemd 下是 `/` | | **N-4** | 新建会话时传占位标题 | 掐掉平台自己的命名机制(`I-3`) | | **N-5** | 模型自主发信带 `relay` | 免配额通道防滥用靠幂等键,不是计数 | | **N-6** | 把 reasoning 块转发进邮件 | 思考过程不是结论 | | **N-7** | 拉取失败时传 `[]` 而非省略字段 | 会清空服务端的镜像/目录(`W-3`) | | **N-8** | 404 后自动改用 `.new` 重试 | 静默新建,违反三态语义 | | **N-9** | 未决权限询问在关停时 fail open | 放行一个没人批准的危险操作 | | **N-10** | 修改模型输出的文本内容 | `I-4`:插件只搬运 | | **N-11** | 首次 SSE 连接带 `Last-Event-ID` | 会重投历史邮件 | | **N-12** | 让共用模块依赖平台 SDK | 那是它们能跨平台共用的前提 | | **N-13** | 给 `/permission/request` 传 `to`(尤其是来信人) | 来信人可能是 Agent,权限邮件发给它必然死锁(`B-8.6`) | ### 共用模块必须逐字节相同 `lib/` 下的文件在所有插件里**逐字节相同**,由 `deploy/check-shared-libs.sh` 校验, 并纳入 `deploy/install.sh` 的门禁。 | 文件 | 职责 | |---|---| | `relay-dedup.js` | 自动转发去重:本轮模型是否已亲手回过这条线索 | | `inbox-format.js` | 收件箱渲染 + 已读策略 | | `session-snapshot.js` | 平台会话快照整理(subagent 过滤、slug 派生、去重) | | `workspace.js` | 寻址 path 位 → 可用的 cwd | | `model-scope.js` | 模型目录整理 + 降级顺序 + 失败报告 | | `catchup.js` | 离线期间积压邮件的补投选择 | | `addressing.js` | 三维地址的拆分与校验 | | `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 是 Go,import 不了 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 → 323MB(heapUsed 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 平台没标,而两处代码看起来都 > 「对」。这类分叉**没有测试能发现**,只能靠 diff。 > > 接新平台时把 `lib/` 与 `test/` 整个拷过去,平台专属逻辑写在入口文件里。 > TypeScript 插件另需手写 `.d.ts`。 ### 平台会话快照的四条规则 | # | 规则 | 为什么 | |---|---|---| | S-1 | 无 `slug` 的会话不报 | slug 是填进 session 位的值,没有它这一项点下去只能得到空的 session 段 | | S-2 | subagent 子会话不报 | 父 agent 内部的工作单元,人往里发邮件毫无意义;且标题就是派活提示词前缀,slug 全撞名 | | S-3 | slug 撞名只留最近那条 | 服务端只能取一条,上报同名项只会让补全里出现几个点哪个都不确定的候选 | | S-4 | 按最近活跃排序,截断到 200 条 | 上千个候选对人没有意义 | > **S-2 的实测数据**:DSH 一次列出 49 条子会话,其中九条标题都叫 > `You are auditing ONE file`。 ### 状态持久性 以下状态目前**只在内存**,插件重启后丢失。这是已知取舍,不是 bug: | 状态 | 丢失后果 | |---|---| | `sessionMap` / `reverseMap` | 续谈的邮件会新开一条平台会话 | | `mailDrivenSessions` | 快照里 `mail_driven` 全变成 `false` | | `pendingPermissions` | 决策回来时走 `B-4.2` 的退化路径 | | `explicitSends` | 重启后的第一轮可能重复转发 | | `deliveredMails` | 同一进程内的 SSE 重放靠它;**跨进程**的重复必须由 `B-7.7` 的落盘账本兜住 | 要持久化的话应当落在平台的会话元数据里,而不是插件自己的文件 —— 那样才能跟着会话一起被平台清理。 > **例外:投递账本必须落盘**(`B-7.7`)。上面那些丢了只是「多开一条会话」 > 或「多转发一次」,而投递去重丢了是「同一封邮件被注入两遍」—— 模型上下文 > 里出现两段相同指令,可能把同一件事做两次。子进程形式的插件尤其如此: > 它的重启频率由宙主进程决定,不是罕见事件。 --- ## 七、验收清单 / Acceptance Checklist 每条都可机械核对。`[ ]` 全打完之前不要说「接好了」。 ### 7.1 静态检查 ``` [ ] lib/ 与 test/ 下的共用文件与参照插件逐字节相同 ./deploy/check-shared-libs.sh [ ] 共用模块的纯函数测试全绿 cd plugins/<新插件> && npm test [ ] 入口文件不导出除 default 之外的东西(若平台按导出扫插件) [ ] grep 自查禁止事项: grep -rn "request_permission" src/ index.js # 应为空(N-1) grep -rn "mkdir" src/ index.js # 不得用于 to_workspace(N-2) grep -rn "'\[\]'" 上报处 # 拉取失败处不得传空数组(N-7) ``` ### 7.2 连接与生命周期 ``` [ ] 首次启动无密钥时生成一把并打印全文(B-1.1) [ ] 管理员登记后重启,日志显示「已接入 ,身份 」 [ ] sqlite3 "SELECT status, last_seen FROM agents WHERE agent_name=''" → status=online,last_seen 每 30 秒推进(B-2.1) [ ] 断网 60 秒再恢复:SSE 自动重连,期间的邮件通过 Last-Event-ID 补回(D-7.2) [ ] kill 插件:未决权限询问全部 fail closed(B-9.2;平台无审批环节时跳过) ``` ### 7.3 主链路 ``` [ ] 发一封到 @<存在的目录>.new → 平台里出现新会话,且 cwd == 那个目录(B-3.1) → 模型调了 read_inbox(T-1) → 一轮结束后收到自动回信(B-5) → 回信不消耗配额:sessions.used_rounds 保持不变(I-2) [ ] 会话别名回写:sqlite3 "SELECT session_alias, subject FROM sessions ..." → 别名是平台生成的短名,subject 是模型生成的标题(W-7) → 别名里没有 . @ /(W-7.1) [ ] 用回写的别名续谈:@<目录>.<别名> → 落进同一条平台会话,不新建(B-3.3) [ ] 发到一个不存在的别名 → 404,且平台侧没有新建任何会话(N-8) [ ] 接管平台会话(B-3.7):在平台界面里手动开一条会话并聊几句 → 等一次心跳,sqlite3 "SELECT slug FROM agent_platform_sessions WHERE agent_name='' AND mail_driven=0" → 出现它的 slug → 发一封到 @<那条会话的 workspace>.<那个 slug> → 日志显示「接管/resume」而**不是**「新会话」 → 让模型回答「这条会话之前在谈什么」→ 答案含界面上聊过的内容(上下文装回来了) → sqlite3 "SELECT platform_id FROM sessions WHERE session_alias=''" → 等于那条平台会话的 id → 收到自动回信(接管后也进 mailDriven,B-5.5) → 再发第二封:复用**同一条**本侧会话,不再新建(避免线索裂成多条) → 平台侧会话文件/记录里**没有**新增改名条目(接管会话跳过 C-11) [ ] 平台侧删掉那条会话后再投同一个别名 → 报错且话术含 `.new`,平台侧**没有**新建任何会话(B-3.8) [ ] 模型主动调 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 记下了提议 → GET /sessions/{id}/rename-proposal 返回该提议 → 人接受(PUT /sessions/{id}/alias)后别名真的改了,alias_source 变 manual → 接受后 rename-proposal 返回 null(提示条不再反复弹) → 新别名可寻址:@<目录>.<新别名> 落进同一条会话 → 桥的后续命名同步**不覆盖** manual 别名 ``` ### 7.4 工作目录 ``` [ ] to_workspace 指向存在的目录 → 会话 cwd 是它 [ ] to_workspace 指向不存在的目录 → 回退到兜底目录,且磁盘上没有新目录(D-6.2) ls <那个不存在的路径> → No such file or directory [ ] to_workspace 是相对路径 → 拒绝并回退(D-6.3) [ ] to_workspace 为空 → 用兜底目录 ``` ### 7.5 降级与失败 ``` [ ] 把模型范围设成两个都无效的路由,发一封 → 收到「处理失败: <原主题>」邮件,正文列出两个路由与原因(B-6) → sessions.used_rounds 保持 0(B-6.3 走免配额通道) → relayed_mails 里有一条 model-failure: [ ] 把范围设成「第一个无效 + 第二个有效」,发一封 → 日志出现「<有效模型> 成功(前 1 个失败)」(D-3.1) → 收到正常回信 [ ] 停插件 → 发一封 → 启插件 → 日志出现「补投 N 封离线期间的邮件」(B-7) → 收到回信 [ ] 插件在线时再发一封 → 只收到一封回信,没有重复投递(B-7.3) [ ] (子进程形式的插件)发一封 → 模型跑到一半时重启宙主进程(B-7.7) → 数据库里只有一封 `Re:`(模型上下文里也只有一段有效指令) → 日志出现「上一轮被中断,带说明重投」 → 账本里该 mail_id 先一行 `c:false` 后一行 `c:true` → 再次重启(邮件已跑完)→ **不再投递**,会话邮件数不变 ``` ### 7.6 权限(若平台支持) ``` [ ] 触发一次平台原生的权限询问 → 邮箱里出现一封 mail_type=permission_request 的邮件 → 不消耗配额(I-2) [ ] 在界面上点「同意」 → 平台侧那次工具调用继续执行(D-2 / B-4.1) [ ] 重复触发同一次询问(事件重放) → 只产生一封邮件(W-6 幂等) [ ] **Agent 自己给自己派活的会话里触发权限询问**(B-8.6 / N-13) 造:让 Agent 用 send_mail 发给自己 @<目录>.new,正文要求它跑 bash → 权限邮件的 to_name 是**人**(会话 owner 或线索里最近的人类),不是 Agent sqlite3 "SELECT to_name FROM mails WHERE mail_type='permission_request' …" → 正文里带得出「触发任务」与「任务来自」(B-8.4) → 整条链上确实没有人类时:服务端返回 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 → 第一次弹权限,点「一直同意」 → 后两次**不再产生邮件**: sqlite3 "SELECT COUNT(*) FROM permission_requests WHERE session_id='…'" → 应为 1,不是 3 → 换个工具(write)仍会问一次 —— 授权不跨工具 → 另一条会话的 bash 仍会问 —— 授权不跨会话 反例(修复前的真实数据):同一条会话 15 封 permission_request, result 全是「同意」,界面上那个「一直同意」按钮点了等于没点 ``` ### 7.7 上报 ``` [ ] 心跳带模型目录 sqlite3 "SELECT COUNT(*) FROM agent_model_catalog WHERE agent_name=''" → 与平台实际可用模型数一致 [ ] 心跳带会话快照 sqlite3 "SELECT COUNT(*) FROM agent_platform_sessions WHERE agent_name=''" → 与平台会话数一致,且不含 subagent(S-2) → 没有重复 slug(S-3) [ ] 平台侧删掉一条会话后 → 下次心跳后镜像里也消失(W-3 整表替换) [ ] 断开平台的模型服务(让 list_models 失败) → 心跳里省略 models 字段,服务端目录**不变**(N-7) ``` ### 7.8 端到端脚本 上面的检查可以攒成一个脚本。参照 `deploy/remote-agent-demo.py` —— 它用纯标准库跑完了注册 / 心跳 / SSE / 收发信,可以拿来当协议层的对照实现。 --- ## 八、平台差异对照 / Platform Matrix 三次真实适配的对照表。接新平台时逐行回答「我这边是什么」。 | 关注点 | opencode | DeepSeek Harness | pi | HomeAgent | |---|---|---|---|---| | 插件形态 | `export default async function(input)` | Cordis:`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) | 独立子进程 `plugin.bin`,SDK 走 C ABI | | 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` | **没有会话概念** —— 单事件循环,不做会话映射 | | 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` | `s.InjectText(source, channel, text)` / `InjectInputSync`(同步等回复) | | 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema | `s.RegisterTool(name, ToolDef, handler)`,Parameters 是裸 JSON Schema map | | 轮次结束 | `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 会话没有平台标题,见下) | 邮件主题派生(无平台标题) | | 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | `console.error` | `log.Printf` 进 homed 的 journalctl(带 `[plugin]` 前缀) | | 工作区分组 | `session.create({directory})` 自带 | `ctx.get('workspaceRegistry')` → `create(cwd)` + `attachSession()` | 按 cwd 自动分目录(`~/.pi/agent/sessions/--tmp-x--/`),无需注册 | N/A(无会话,无 cwd 概念) | | 工具参数命名 | `path` / `save_to` | `file_path` / `save_path` | `file_path` / `save_path` | 自定(本桥用 `file_path` / `save_path` 对齐其他平台) | | 权限三态 | 原生 `once`/`always`/`reject` | **只有** `allowed-once`/`rejected` | 只有 block/放行 | N/A | | 「一直同意」 | 给,平台自己记 | **不给**(表达不了,桥代劳会覆盖平台策略) | 给,桥用 `createGrantStore()` 记 | N/A | > **HomeAgent 为什么整列都是 N/A**:它的核心是一个纯思维核 —— > 本身没有任何对外交互能力,全部能力来自插件,也没有「会话」这一层 > (单事件循环)。因此桥在这个平台上不做会话映射:所有邮件注入同一个循环, > 靠 `output_send__agentmail` 输出通道把回复送出去。 > 它是四个平台里唯一**不需要**为每封邮件开一条平台侧会话的。 > > **pi 为什么是守护进程而不是扩展**:pi 扩展被加载进**一条已经存在的**会话, > 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace` > 成为会话 cwd —— 扩展做不到「按邮件新开一条 cwd 不同的会话」。 > 桥因此用 SDK 起会话,一个进程里并存多条不同 cwd 的会话(实测可行)。 > 代价是它需要自己的 systemd 单元(`deploy/pi-mail-bridge.service`)。 > > **pi 的 `C-11` 是有条件的**:pi 会生成会话标题,但生成器在 **pi-web** 包里 > (`sessionNameGenerator.js`),不在 `pi-coding-agent` 内核里。桥用 SDK 起的会话 > 走不到那条路径,`session.sessionName` 一直是 `undefined`。因此 pi 侧的别名走 > `D-5` 阶梯的第二级(邮件主题派生),并把 Gateway 的定稿值**回写**进 > `session.setSessionName()` —— 这一步让 pi-web 界面上显示的名字与邮箱里一致。 > > **回写只能用 `setSessionName()`**,三条实测约束:首条 assistant 消息落盘前 > 会话文件还不存在,pi 首次落盘用 `openSync(file,"wx")`,外部抢先创建会让它抛 > `EEXIST`;活着的 `SessionManager` 从不重读文件,它自己后续的 `session_info` > 会覆盖外部改名;空名字是**清除**语义,不是「不改」。 --- ## 九、踩过的坑 / Pitfalls 按排查成本降序。新接平台前先扫一遍这一节 —— 每一条都是几小时到一天的代价。 ### 9.1 `followup()` 的参数形状(花了一下午) DSH 的 `agent.followup(message)` 要完整的 `UserMessage`: ```ts agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) ``` 照抄 opencode 的 parts 数组 `[{ type: 'text', text }]` **不会当场报错** —— agent-loop 会一路走到 `preStep` 里读 `message.source.kind`,然后抛 `Cannot read properties of undefined (reading 'kind')`。错误落在框架内部, 既不指向调用点也不说是哪个字段,turn 一 start 就 end、模型请求根本不发出去。 > **教训**:「错了不当场报错、只在深处炸一个无关错误」的约定必须用测试钉住。 > `lib/message.js` + `test/message.test.mjs` 就是为此存在的。 ### 9.2 模型失败不是同步抛出的(两个平台都踩) 见 `D-3`。两次都表现为「第一个无效模型被判成成功,降级从不发生」。 opencode 侧还有个额外复杂度:event 钩子在 `deliverMail` 之外,需要一张 `turnWatchers` 表把两者接起来。 ### 9.3 工作目录用了自己拼的临时路径 DSH 插件最初用 `~/.dsh/mail-sessions/mail-` 作 cwd,结果所有邮件会话在 平台界面上全落进「未分组」(平台按 cwd 分组)。而且 Gateway 当时根本没把地址的 path 位发给插件 —— 补了 SSE payload 的 `to_workspace` 才修好。 修好前后的会话目录名:`--root-.dsh-mail-sessions-mail---` → `--home-program-agentmail--`。 ### 9.4 opencode 插件入口只能有 `default` 一个导出 opencode 用 `Object.values(mod)` 把**每个导出**都当插件工厂检查(反编译确认)。 入口多导出一个 Map 就报 `Plugin export is not a function`,插件**静默失效**、 邮件全投不进去。 > 因此所有可测试的逻辑必须放 `lib/` 子模块,入口只 `export default`。 ### 9.5 Cordis 插件必须导出 `inject` 没有它 `ctx.tools` / `ctx.agents` 根本不存在(报 `cannot get property "tools" without inject`)。 但**不要把可选服务写进 `inject`** —— 那是硬依赖,服务没挂载时整个插件不启动。 DSH 的 `sessionQuery` 用 `ctx.get('sessionQuery')` 取:会话上报只是补全体验, 不该能把邮件投递整体拘死。 ### 9.6 DSH 的 `setup` 应当留空 给 `ctx.agents.create({ setup })` 挂 preset + `installModelSelection` 会让整个 turn 崩溃。base bundle 已经注册了 agent-loop / llm / tools, `agentOptions: { provider, model }` 就够了。 ### 9.7 DSH 工具必须经 `defineTool` 直接给 `ctx.tools.register` 原始对象会报 `parameters must be lossless JSON before schema projection`。 `defineTool` 负责把 spec 格式的 `parameters` 转成 JSON Schema 并在 `execute` 前 校验,还必须声明 `output: { schema, render }`。 ### 9.8 部署环境的坑 | 现象 | 原因 | 处理 | |---|---|---| | 读不到 `~/.agentmail/agent.key` | systemd 不注入 `HOME` | service 里显式 `Environment=HOME=/root` | | 插件像没加载 | opencode 插件懒加载 | `ExecStartPost` 发一个空请求预热 | | `ctx.logger.info` 看不到 | DSH 的 logger 不进 journalctl | 用 `console.error` | | `dsh --patch` 不生效 | `--patch` 只对 `dsh --profile ` 有效 | 配置写进 profile 的 `cordis.patch.yml` | | 第二个实例起不来 | task-board 的 ledger 文件锁 | 注入现有实例,不另起 | | 平台活着但外网访问不了 | 转发层(socat / nginx)没跟着平台重启 | 见下 | **转发层的依赖要写两个方向。** 平台监听 loopback、靠一个 socat 单元暴露到 LAN 或公网时,只写 `Requires=` 是不够的: | 指令 | 管什么 | 少了会怎样 | |---|---|---| | `Requires=平台` | 平台**停止**时转发也停 | —— | | `PartOf=平台` | 平台**重启**时转发也重启 | `restart` 后转发永久消失 | | 平台侧 `Wants=转发` | 平台**启动**时拉起转发 | 手工 `start` 后转发不起来 | `Requires` 不含重启语义,`PartOf` 不含启动语义,单元自己的 `WantedBy=multi-user.target` 只在开机时生效 —— 三者缺一, `systemctl restart <平台>` 或 `stop` + `start` 之后就会出现 **「平台进程活着、loopback 通、外网全不通」** 这个很难联想到转发层的现象。 2026-09-02 真实发生过一次,隐形 9 小时:为验证插件的离线补投重启了 dsh, socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start` 起不来, 才补上平台侧的 `Wants`。 另外给转发单元加 `SuccessExitStatus=143`:被 SIGTERM 停掉是正常路径, 不加会在 `systemctl status` 里留一条红色 `failed`,掩盖真正的故障。 > 用 `Wants` 而不是 `Requires` 引用转发层:转发起不来不该阻止平台本身启动, > loopback 访问仍然可用(插件走的正是 loopback)。 ### 9.9 登记密钥的字段名是 `key_token` `POST /api/v1/admin/agent-keys` 登记自带密钥时字段名是 **`key_token`**,不是 `key`。 传错服务端会静默生成一个随机 token 且全文只回一次。 ### 9.10 `node --test test/` 在 node 24 上报 MODULE_NOT_FOUND 它把目录名当模块解析。必须写 glob:`node --test 'test/*.test.mjs'`。 ### 9.11 pi 的 `followUp()` 在会话空闲时什么也不做(静默丢邮件) `session.followUp(text)` 只往 `followUpQueue` 里塞消息,而那个队列**只在运行中的 轮次末尾**被 drain(`pi-agent-core/agent.js` 的 run 循环,以及 `continue()`)。 会话空闲时(上一轮早已结束)塞进去的消息永远没人取。 表现极具欺骗性:日志打了「续谈成功」,`prompt()` 没报错,收件箱里却只有来信、 没有回复。既没有异常也没有超时。 > **正确做法**:按 `session.isStreaming` 分流 —— 空闲用 `prompt(text)` 直接起一轮, > 正在跑用 `prompt(text, { streamingBehavior: 'followUp' })` 排到当轮之后 > (缺 `streamingBehavior` 时 pi 会抛 `Agent is already processing.`)。 > 不要用 `steer`:那会打断当前轮,而当前轮正在处理**上一封邮件**。 ### 9.12 pi 的全局扩展会 `listen` 固定端口 `~/.pi/agent/extensions/` 下的 `pi-a2a` / `pi-acp` 在加载时就 bind `127.0.0.1:12010` / `12011`。同机已有 pi 在跑时,任何新起的 pi 进程(包括 `pi --help`)都会 `EADDRINUSE` 并把整条会话拖死。 > 桥用 `noExtensions: true` + 内联 `extensionFactories` 起会话:既避开端口冲突, > 也不继承那套给人类交互用的扩展(TUI 命令、快捷键、状态栏对邮件没有意义)。 > 邮件工具走 `customTools`,权限钩子走内联工厂。 ### 9.13 `SessionManager.listAll(dir)` 的参数不是 agentDir 它的字符串参数是**自定义会话目录**,会直接在里面找 `.jsonl`。传 `getAgentDir()`(`~/.pi/agent`)得到的是空列表 —— 会话在它的 `sessions/` 子目录下按 cwd 分目录存放。**不传参数**才会走默认的逐 cwd 扫描。 同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉 所有真正在干活的会话。 ### 9.14 改名提议的标记拼错是**静默失败** `` 由服务端正则解析 (`server/internal/handler/rename_proposal.go`)。少个空格、把双引号写成单引号、 或把 `reason=""` 写成空属性 —— 邮件照常发出,提议凭空消失,而模型以为自己提过了, 在后续对话里当作已完成的事引用。 > 这就是为什么拼接必须收在共用库 `lib/rename-proposal.js`(`T-13.2`): > 三平台各写一遍的话,任一处的偏差都不会有人发现。 > `test/rename-proposal.test.mjs` 里内置了一份服务端正则的等价实现来验证生成结果。 ### 9.15 换 Gateway 地址后必须清 `lastEventID` 并重连 SSE 守护进程形态的插件(pi)提供 `connect_to_server` 时有个额外义务:改完 `baseURL` 要**重建 SSE 长连**,因为旧长连仍连着旧地址。 更微妙的是断点:`lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿它去问新 Gateway 会命中一段完全无关的历史(或被拒),收到的事件属于别人的会话。 `GatewayClient.reconfigure()` 在地址变化时清空它,于是重连以「首次连接」姿态 (不带 `Last-Event-ID`,见 `N-11`)进行。 插件形态(opencode/dsh)没有这个问题:它们的 SSE 由宿主生命周期管, `connect_to_server` 只改配置,下次宿主重连时自然用新值。 ### 9.16 把来信人当权限决策人 → 会话永久挂死 插件手边最自然的决策人候选是「来信人」(`mailContexts` 里的 `replyTo`), pi 侧一度就是这么传的。它在人给 Agent 派活时看起来完全正确,直到 **Agent 给 Agent 派活**: ``` jianf → pi(会话 A) pi 把子任务分给自己(会话 B) 会话 B 触发 bash 权限询问 replyTo = "pi" → 权限邮件发给 pi 自己 ``` 后果不是报错而是**死锁**,而且是三重静默: 1. Agent 不可能在 Web 界面上点「同意」 2. 服务端 `SendToUser("pi")` 投进一个不存在的用户通道 —— 没有任何人被提醒 3. 插件里 `await new Promise(...)` 永不 resolve —— 没有超时、没有日志、没有回信 会话就那么停在那里,从外面看像「模型在思考」。实测发生过:两条会话排了任务, 只有一条在干活,另一条的最后动静是一封 `to_name=pi` 的 `permission_request`。 > 修法见 `B-8.6` / `N-13`:**不传 `to`**,决策人由服务端沿会话上溯解析。 > 服务端是唯一能看到整条线索的地方;插件只有本地那点上下文,猜不出 > 「这条 Agent 链最初是谁派的活」。 > > 排查这类问题的第一个动作: > `SELECT to_name FROM mails WHERE mail_type='permission_request'` —— > 出现 Agent 名就是它。 ### 9.17 摆一个「一直同意」按钮却不实现它 比不给这个选项更糟的是给了但不生效。pi 侧一度在 `options` 里放了 `['同意','一直同意','拒绝']`,放行正则也认 `一直同意` —— 但**没有任何地方记住它**,所以点完下一条命令照样来一封邮件。 生产数据(修复前): ``` 同一条 pi 会话被问了 15 次 bash permission_requests.result 全是「同意」—— 一次「一直同意」都没有 ``` 人不是不想点,是点了发现没用,于是退回去一条一条点「同意」。 > 判断一个平台该不该提供这个选项,看它的钩子能不能表达"以后别问了": > - opencode 能(`response:"always"`)→ 给,且让平台记 > - pi 不能(钩子只回 block/放行)→ 给,桥自己记(`B-8.8`) > - DSH 不能且不该由桥代劳(会覆盖平台审批策略)→ 不给 > > 自查:在 `options` 里加任何选项之前,先在代码里搜一遍这个选项的文本, > 看它除了"被展示"之外还出现在哪里。只出现在 `options` 和一条放行正则里, > 就是这个坑。 --- ### 9.18 摆一套权限规则,却从不调用,且形状根本没人能消费 比 `9.17`(给了按钮不实现)更深一层的错:**代码看起来像强制力的实现**。 `lib/permission-mode.js` 里曾有 `opencodePermissions(mode)`,实现完整、注释详实 (含 6 条实测结论)、还有 9 条测试把它的行为钉住。opencode 的 `index.js` 第 45 行确实 import 了它 —— 然后**全文件再也没有第二次出现**。 静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为 「opencode 的 plan 档由这套规则拦着」。实际不是:opencode 是 `advisory`。 #### 它不只是没接上,而是**接不上** 查 1.18.29 的 SDK 类型定义,三条都排除: ```ts // ① 会话级参数里没有 permission,也没有 agent(只有这两个字段) export type SessionCreateData = { body?: { parentID?: string; title?: string }; query?: { directory?: string }; }; // ② permission 只存在于 Config / AgentConfig,而且是 map 形状 export type Config = { permission?: { edit?: "ask"|"allow"|"deny"; bash?: …; webfetch?: …; doom_loop?: …; external_directory?: …; }; }; // ③ 权限事件也没有 action 字段 export type Permission = { id, type, pattern?, sessionID, messageID, callID?, title, metadata, time }; ``` 而那个函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**, 并且 deny 了 `task`(本版本 `permission` 的合法键里**没有 task**)。 所以这不是「加一行调用就能生效」,而是**输出没有任何消费者**。 #### 为什么 per-session 强制在 opencode 上做不到 `Config` / `AgentConfig` 是**配置文件级**(全局或项目级)。邮件桥若靠改配置来 给某条会话加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— 一条 plan 档的邮件把人身兼的其他工作一起禁掉,不可接受。 opencode 目前的拦截路径只能是「它自己先问 → 桥转发 → 服务端按档位 409 → 桥当场 block」,**前提是它的配置恰好是 `ask`**;若配置直接 `allow`, 桥连 `permission.updated` 事件都看不到。这就是它只能是 `advisory` 的原因。 #### 处理:删掉函数,知识搬进本节 死代码留着就是陷阱。函数与 9 条测试已删除,6 条实测结论保留在这里: 1. **`findLast` 胜出** —— 规则数组里 deny 必须排在 allow **之前**, 反了的话最后匹配到 deny,连允许的路径也被拒。 2. **pattern 匹配 worktree 相对路径** —— 写 `/tmp/**` 这种绝对 pattern 永远匹配不上 (`/tmp/x` 相对 `/home/program/agentmail` 是 `../../../tmp/x`)。 3. **write / edit / patch 共用 `edit` 一个权限名**(`if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。 4. **全 deny 会让工具从模型清单里消失**(模型自述「I don't have a bash tool available in this session」),部分 deny 则工具保留、越界调用才报错。 5. **`task`(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write, 主动 `task` 委派给一个带 write 的子代理去写成了。 6. **`bash` 能绕过 `edit` 的路径限制** —— 模型用 shell 重定向写成了本该被 deny 的文件。 另注:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档 **撞名但语义不同**(那是它自己的计划模式开关),不要碰它们。 > 自查:一个 `export function` 写完之后,除了它的测试,还有谁调用它? > 只有 import 行、没有调用点时,先查**它的输出形状在目标平台里到底有没有消费者** —— > 「形状没人认」比「忘了接线」更常见,且更隐蔽。 --- ## 附:文档关系 | 文档 | 内容 | |---|---| | **本文** | 插件的规格:能力矩阵、行为约定、降级语义、线协议、不变量、验收清单 | | [`API.md`](API.md) | 完整 HTTP API(含人类侧接口) | | [`PLAN.md`](PLAN.md) | 分阶段实施记录与决策依据 | | [`PHASE7-REMAINING.md`](PHASE7-REMAINING.md) | 未完成项与已知取舍 | 参照实现:`plugins/opencode-mail-bridge/`、`plugins/dsh-mail-bridge/`、 `plugins/pi-mail-bridge/`、`deploy/remote-agent-demo.py`(纯标准库的协议层对照)。