Files
MailUI4Agents/docs/PLUGIN-CONTRACT.md
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

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

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

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

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

94 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" --> 由服务端正则解析 gateway/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 和一条放行正则里, 就是这个坑。


附:文档关系

文档 内容
本文 插件的规格:能力矩阵、行为约定、降级语义、线协议、不变量、验收清单
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(纯标准库的协议层对照)。