Files
MailUI4Agents/docs/PLUGIN-CONTRACT.md
JianFeeeee a60ab66a40 refactor(plugins): 删掉「摆了一套权限规则却没人调用、且形状没人能消费」的死代码
# 问题

`lib/permission-mode.js` 里的 `opencodePermissions(mode)` 实现完整、注释详实
(含 6 条实测结论)、还有 9 条测试把行为钉住;opencode 的 `index.js` 第 45 行
确实 import 了它 —— 然后**全文件再没有第二次出现**。

静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为
「opencode 的 plan 档由这套规则拦着」。实际 opencode 是 advisory。

比 9.17(给了按钮不实现)更深一层:那只是没实现,这个是**看起来像实现**。

# 它不只是没接上,而是接不上

查 1.18.29 的 SDK 类型定义,三条都排除:

  SessionCreateData.body 只有 { parentID, title };query 只有 { directory }
      → 会话级根本没有 permission,也没有 agent
  permission 只存在于 Config / AgentConfig,且是 map 形状
      → { edit, bash, webfetch, doom_loop, external_directory } → ask|allow|deny
  Permission 事件(permission.updated 的 properties)没有 action 字段
      → { id, type, pattern?, sessionID, messageID, callID?, title, metadata, time }

而函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**,
并且 deny 了 `task`(本版本 permission 的合法键里没有 task)。

所以不是「加一行调用就生效」,而是**输出没有任何消费者**。

# 为什么 opencode 的 per-session 强制做不到(如实说明)

Config / AgentConfig 是配置文件级(全局或项目级)。邮件桥若靠改配置给某条会话
加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— 一条 plan 档的邮件
把人身兼的其他工作一起禁掉,不可接受。

opencode 目前唯一的拦截路径是「它自己先问 → 桥转发 → 服务端按档位 409 →
桥当场 block」,**前提是它的配置恰好是 ask**;若配置直接 allow,桥连
permission.updated 都看不到。这就是它只能是 advisory 的原因,不是缺工作量。

# 改动

- 删除 `opencodePermissions` 及其 doc 注释、`OpencodePermissionRule` 接口声明
  (三份共用库同时改,改后 md5 仍逐字节相同)
- 删除 opencode/index.js 里那行未使用的 import
- 删除 9 条针对该函数的断言(三桥各 9 条)
- **6 条实测结论没有丢** —— 搬进 `docs/PLUGIN-CONTRACT.md` 新增的 §9.18,
  连同上面那三条类型证据与「为什么 per-session 做不到」

删掉而非保留,是因为留下的就是陷阱:函数存在、注释写着「实测过」、
测试还全绿,唯一缺的是调用点 —— 下一个人会以为档位在这里被强制。

# 验证

- `deploy/check-shared-libs.sh` → 共用模块三方同源
- 三桥 `npm test` 全绿:pi 400 / dsh 360 / opencode 311,0 失败
  (删除前 pi 409 / opencode 320,各 −9 即被删断言;dsh 另有并发提交新增测试,
  净 −9 后为 360)
- `node --check` 四个改动文件全过;ESM 动态 import 该 lib 成功,
  导出里已无 `opencodePermissions`,index.js 只引用仍存在的符号
- 本改动不影响运行时行为(删的是一个从未执行的函数与一个未使用的 import)
2026-09-11 23:59:24 +08:00

98 KiB
Raw Blame History

AgentMail 插件契约 / Plugin Contract

把一个 Agent 平台接进 AgentMail 需要写一个桥接插件。这份文档是那个插件的 规格说明:必须支持哪些平台能力、每个事件到达时必须做什么、平台缺某项能力时 怎么退化、线协议的确切字段、以及每一条怎么自证做到了。

读者假定为「照着实现的人或代理」,因此:

  • 条目用 MUST / SHOULD / MAY 标注强度,编号可引用(C-1B-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-6D-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 的平台还要回答第二个问题:别名由谁定稿。别名负有寻址唯一性义务 (撞名追 -2manual 来源永远优先),平台侧没有这个约束。两侧各自命名会分叉, 因此必须由服务端定稿、插件把响应里的值回写进平台(见 D-5W-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/registerworkspaces[] 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 心跳MUST30 秒)

请求体三个字段全部可选,语义见 W-3

{ "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_enforcementnative / 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_mailMUST

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 的去重兜底:提示词是建议,去重是保证。

为什么改为 SHOULDfrom_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.jsadoptedSessionID / 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_decisionMUST

# 要求 强度
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 收件方是 Agentfrom_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:<mail_id>" 走免配额通道 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.jsclampRelayKey 收敛长度 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.jsisPermanentFailurehomeagent 侧是 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 里写死的句子:

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"}finalizeScheduledExecutionpostExecute)。替换必须是一次性的(同一 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_mailread_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_aliasT-2 参数) MAY 模型在 send_mailpropose_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.3 只标本次列出的那些,且 status=all不标 limit 之外的还没看过;把历史邮件标成已读会让下一轮的新邮件混在里面认不出来

默认参数:status='unread'limit=5

为什么默认只看未读:默认 all 会让模型每轮重读旧邮件,几轮之后上下文里 全是重复内容。这个 bug 在 DSH 插件里真实存在过(默认 all 且完全没标已读)。

共用实现 lib/inbox-format.js跨平台必须一致 —— 它不依赖任何平台 SDK。

T-7 为什么禁止 request_permission

模型可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。 平台的权限钩子拦下的那一次是事实。见 I-1B-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_mailreply_address 必须回显:它告诉模型「发件人的可投递地址是什么」。 不回显的话模型只能用原始地址续谈,但那个地址的 session 位可能是默认的,回过去 不一定落到同一条线索里。

T-13 propose_aliasT-2 send_mail 的可选参数)

propose_alias 让模型在干完活后提议一个更贴切的会话别名(例如从邮件主题 排查登录问题 改成更精确的 fix-session-cookie-leak)。

# 规则 违反后果
T-13.1 标记格式是 HTML 注释 <!-- agentmail:rename-session alias="x" reason="y" --> 服务端正则匹配不上,提议静默消失
T-13.2 标记必须由共用库 lib/rename-proposal.js 构造 各平台各写一遍拼接,少个空格就失效
T-13.3 别名不合法时不追加标记(isProposableAlias 返回 false 发一个服务端匹配得上却校验失败的标记,白白浪费一轮
T-13.4 回显服务端返回的 rename_proposed,不是本地提议的值 服务端跑过 normalizeAlias(非法字符换 -newsession-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-8C-9

钩子被调用
  ├─ 转成邮件发出去relay: "permission"
  ├─ 立即返回「待决」/「询问中」——不阻塞
  └─ 人的决策经 permission_decision 事件回来后,用平台 SDK 回复那条权限
# 要求 强度
D-2.1 钩子内不得阻塞等待(会挂死整个平台请求) MUST
D-2.2 转发暂时失败时让位给本地 UIreturn 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 具体形态

{"chunk": {"type": "finish", "reason": {"kind": "error", "failure": {"code": "NO_ADAPTER"}}}}

「收到任意 chunk 就算走通」会让无效 provider 判成成功 —— 实测踩过。判据要落在 chunk 的类型上:finish 看 reason其余text-deltablock-start…) 才意味着模型真的在产出。

D-3.4 为什么超时算成功:模型可能只是很慢(首 token 前要装载上下文)。 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉,用户拿到两份回复。

D-3.5 为什么换会话 id:复用同一个 id 会让重试接在一条已经出错的会话后面; 不销毁失败那个 agent 的话,它的轮次结束信号还会触发一次自动转发。

D-4 无法列出会话(缺 C-10

心跳里省略 platform_sessions(不是传 [],见 W-3)。 后果:写信时的会话补全只能看到邮件驱动的那些。

D-5 别名来源阶梯(C-11 缺失或不可用时)

| 优先级 | 别名来源 | 何时用 | 邮件主题派生(无平台标题) | |---|---|---| | 1 | 平台自带的 slugwitty-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.jsisUnusableName():命中就降到下一级,不是改写成别的。

别名必须由服务端定稿

具备 C-11 的平台也要走这一步。插件提议、服务端定稿、插件把响应里的值回写:

插件观察到平台的名字
  → POST /sessions/{id}/sync { alias: slug, title: 原文 }
  → 读**响应里**的 alias可能被改写过
  → 与平台当前名字不同 → 写回平台

两个原因让单向推送无法收敛:

服务端行为 后果
别名撞名时追 -2-3SyncSessionAlias 平台侧没有唯一性约束,不会跟着改
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

认证:所有 Agent 侧请求带 Authorization: Bearer <agent_key>

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 注册

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 心跳

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"
}

响应:

{
  "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 <agent_key>
Last-Event-ID: <上次收到的 id>     # 仅重连时带

Agent 侧只会收到两个事件:

new_mail

{
  "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 tocc
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

{
  "mail_id": "...", "decision_mail_id": "...",
  "decision": "同意", "note": "", "decided_by": "admin",
  "session_id": "...", "relay_key": "perm_xyz", "relay_kind": "permission"
}

relay_key 是当初发起询问时插件传的那个(平台的权限 id。服务端持久化了这个 映射并在此回传 —— 因此插件重启丢了内存映射也能对上。

W-5 发信

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 非空时必填
}

响应:

{ "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 权限询问

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 会话命名回写

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 MBAGENTMAIL_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/requestto(尤其是来信人) 来信人可能是 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 是 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 平台没标,而两处代码看起来都 「对」。这类分叉没有测试能发现,只能靠 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_workspaceN-2
      grep -rn "'\[\]'" 上报处                        # 拉取失败处不得传空数组N-7

7.2 连接与生命周期

[ ] 首次启动无密钥时生成一把并打印全文B-1.1
[ ] 管理员登记后重启,日志显示「已接入 <URL>,身份 <name>」
[ ] 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-9.2;平台无审批环节时跳过)

7.3 主链路

[ ] 发一封到 <name>@<存在的目录>.new
      → 平台里出现新会话,且 cwd == 那个目录B-3.1
      → 模型调了 read_inboxT-1
      → 一轮结束后收到自动回信B-5
      → 回信不消耗配额sessions.used_rounds 保持不变I-2

[ ] 会话别名回写sqlite3 "SELECT session_alias, subject FROM sessions ..."
      → 别名是平台生成的短名subject 是模型生成的标题W-7
      → 别名里没有 . @ /W-7.1

[ ] 用回写的别名续谈:<name>@<目录>.<别名>
      → 落进同一条平台会话不新建B-3.3

[ ] 发到一个不存在的别名
      → 404且平台侧没有新建任何会话N-8

[ ] 接管平台会话B-3.7):在平台界面里手动开一条会话并聊几句
      → 等一次心跳sqlite3 "SELECT slug FROM agent_platform_sessions
          WHERE agent_name='<name>' AND mail_driven=0"  → 出现它的 slug
      → 发一封到 <name>@<那条会话的 workspace>.<那个 slug>
      → 日志显示「接管/resume」而**不是**「新会话」
      → 让模型回答「这条会话之前在谈什么」→ 答案含界面上聊过的内容(上下文装回来了)
      → sqlite3 "SELECT platform_id FROM sessions WHERE session_alias='<slug>'"
          → 等于那条平台会话的 id
      → 收到自动回信(接管后也进 mailDrivenB-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提示条不再反复弹
      → 新别名可寻址:<name>@<目录>.<新别名> 落进同一条会话
      → 桥的后续命名同步**不覆盖** 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 保持 0B-6.3 走免配额通道)
      → relayed_mails 里有一条 model-failure:<mail_id>

[ ] 把范围设成「第一个无效 + 第二个有效」,发一封
      → 日志出现「<有效模型> 成功(前 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 发给自己 <name>@<目录>.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.9clampRelayKey("<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='<name>'"
      → 与平台实际可用模型数一致

[ ] 心跳带会话快照
      sqlite3 "SELECT COUNT(*) FROM agent_platform_sessions WHERE agent_name='<name>'"
      → 与平台会话数一致,且不含 subagentS-2
      → 没有重复 slugS-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) Cordisexport const inject + apply(ctx, config) 常驻守护进程,不是插件(见下) 独立子进程 plugin.binSDK 走 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/statusidle prompt() 的 promise resolve事件是 agent_end 无「轮次」事件;靠 RegisterOutputChannel 的 handler 被调用
模型失败信号 session.error 事件 turn/endreason.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.errorctx.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

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-<uuid> 作 cwd结果所有邮件会话在 平台界面上全落进「未分组」(平台按 cwd 分组)。而且 Gateway 当时根本没把地址的 path 位发给插件 —— 补了 SSE payload 的 to_workspace 才修好。

修好前后的会话目录名:--root-.dsh-mail-sessions-mail-<uuid>----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 的 sessionQueryctx.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 projectiondefineTool 负责把 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 <name> 有效 配置写进 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

它把目录名当模块解析。必须写 globnode --test 'test/*.test.mjs'

9.11 pi 的 followUp() 在会话空闲时什么也不做(静默丢邮件)

session.followUp(text) 只往 followUpQueue 里塞消息,而那个队列只在运行中的 轮次末尾被 drainpi-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 改名提议的标记拼错是静默失败

<!-- agentmail:rename-session alias="x" reason="y" --> 由服务端正则解析 server/internal/handler/rename_proposal.go)。少个空格、把双引号写成单引号、 或把 reason="" 写成空属性 —— 邮件照常发出,提议凭空消失,而模型以为自己提过了, 在后续对话里当作已完成的事引用。

这就是为什么拼接必须收在共用库 lib/rename-proposal.jsT-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=pipermission_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 类型定义,三条都排除:

// ① 会话级参数里没有 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 完整 HTTP API含人类侧接口
PLAN.md 分阶段实施记录与决策依据
PHASE7-REMAINING.md 未完成项与已知取舍

参照实现:plugins/opencode-mail-bridge/plugins/dsh-mail-bridge/plugins/pi-mail-bridge/deploy/remote-agent-demo.py(纯标准库的协议层对照)。