docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)

两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和
代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的
逻辑提到共用模块。

## docs/PLUGIN-GUIDE.md

八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、
平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。

三条设计原则贯穿全文,后面每一节都是它们的推论:

1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供
   request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」
   的平台信号上自动转发)
2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key
3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制)

「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。

## 共用模块提取

`lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为,
而它们与平台 SDK 无关:

- 附件必须带 attachment_id(只说「有附件」模型无从下载)
- 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方)
- 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读
  会让下一轮的新邮件混在里面认不出来)

顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批),
且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。

`lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为
(workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory,
DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。
opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」
这两条保护。

## deploy/check-shared-libs.sh

`lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。

一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现,
只能靠 diff。

## 文档同步

- PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与
  7.7.2(平台会话快照)两节,记录根因而非只记改法
- API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与
  permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途)
- PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展
  (repo 层已就绪,handler/插件/前端待做)
- README 文档索引与项目结构

验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿;
端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
This commit is contained in:
2026-09-02 20:28:19 +08:00
parent ca64d12057
commit 7c9be9fd58
20 changed files with 1482 additions and 100 deletions

View File

@ -1023,11 +1023,102 @@ execute: {
业务代码不感知 Cookie 与密钥的差异,`src/api/` 可整体抽成 SDK
- [x] `docs/API.md`:完整接口清单、三类调用者的认证边界、错误码约定
### 7.7 DeepSeek Harness 插件
### 7.7 DeepSeek Harness 插件(已完成)
- [ ] 基于 Cordis 框架开发 `dsh-mail-bridge`
- [ ] 利用 `PreToolUse`、`SessionStart` 钩子
- [ ] 与 Pi 插件共享相同 Gateway API
`plugins/dsh-mail-bridge/`,Cordis 插件框架 + TypeScript。
适配方法与踩坑记录已固化为 **[`docs/PLUGIN-GUIDE.md`](PLUGIN-GUIDE.md)**,
后续接入新平台按那份清单走。
- [x] Cordis 插件骨架:`export const inject` + `export const name` + `apply(ctx, config)`
- 没有 `inject` 时 `ctx.tools` / `ctx.agents` 根本不存在
(报 `cannot get property "tools" without inject`)
- 但**可选服务不能写进 `inject`** —— 那是硬依赖,服务没挂载时整个插件不启动。
`sessionQuery` 用 `ctx.get()` 取:会话上报只是补全体验,
不该能把邮件投递整体拘死
- [x] 四个工具经 `defineTool` 注册(`send_mail` / `read_inbox` /
`upload_attachment` / `download_attachment`)
- 直接给 `ctx.tools.register` 原始对象会报
`parameters must be lossless JSON before schema projection`
- 还必须声明 `output: { schema, render }`
- [x] `ctx.agents.create()` 建会话 + `agent.followup()` 投递消息
- **`followup()` 要完整的 `UserMessage`(`content` + `source`)**,
不是 opencode 那种 parts 数组。传错不当场报错,而是在 agent-loop 的
`preStep` 里抛 `Cannot read properties of undefined (reading 'kind')` ——
错误落在框架内部,不指向调用点,turn 一 start 就 end、模型请求根本不发出去。
这个坑花了一下午,已用 `lib/message.js` + 测试钉住
- `setup` 留空:base bundle 已注册 agent-loop / llm / tools,
`agentOptions: { provider, model }` 就够了;挂 preset 反而多余
- [x] `agent/status` → `idle` 时自动转发最后一条 assistant 消息
(对应 opencode 的 `session.idle`),复用 `lib/relay-dedup.js` 让位于
模型的主动回信,带 `relay: 'summary'` 走免配额通道
- [x] `approval/request` 钩子把权限询问转成邮件问人
- 与 opencode 的关键差异:那边的 `permission.ask` 是**同步**钩子,
卡住会挂死整个请求,只能「转出去 + 立即返回 ask」;
DSH 这边是**异步 waterfall**,返回 `Promise<ApprovalOutcome>`,可以真的等人
- 拆插件时未决询问一律 fail closed(`unavailable`),
否则 DSH 侧那些 `await` 永不返回
- DSH 不给询问发 id,用 `会话:工具:callId` 作幂等键
- [x] 会话别名由**模型生成的标题**派生(与「别名复用平台命名」的既定决策一致)
- `slugFromTitle` 保留中文(转拼音后既不好读也不好打,
而三维地址按最后一个 `.` 切分,中文不影响解析)
- 但必须去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开
- fallback 占位标题不派生别名:DSH 在模型生成真标题前会先落一个
内容是「用户第一句话截断」的标题,而那句话是插件自己拼的提示词
- [x] **补上心跳** —— 之前完全没有,Gateway 靠 `last_seen` 判在线,
一直靠注册那一次撑着
- [x] 与 opencode 插件共用 `lib/` 下的纯函数模块(逐字节相同)
### 7.7.1 工作目录归属(修复)
**症状**:dsh 指定工作目录完全失效,所有会话落进「未分组」。
**根因两层**:
1. 插件建会话时的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。平台按 cwd 给会话分组,于是所有邮件会话
既不属于任何项目、彼此也不同组
2. Gateway 从来没把地址的 path 位发给插件:`notifyRecipients` 的 payload
只有 `mail_id`/`session_id`/`from_name`/`subject`,`to_workspace` 虽然入库了
却不在 SSE 事件里 —— 插件即使想用也拿不到
- [x] SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**,
不是主收件人的 —— 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个工作区
- [x] 两个插件的 cwd 都改为取寻址的 path 位(共用 `lib/workspace.js`)
- [x] 不存在的目录**不创建**而是回退到兜底目录:一个笔误
(`/home/porgram/x`)不该在磁盘上落下真目录,Agent 会在里面一无所获地干活
- [x] 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`
### 7.7.2 平台会话快照上报(新增)
**症状**:会话别名列不出工作区下的历史会话,无法选择。
人直接在平台界面上开的会话,Gateway 一无所知;而邮件驱动的那些也因为
`workspace` 没存在会话上(只在 `mails.to_workspace`,且 Agent 回信的
`from_workspace` 填的是 Agent 名而不是路径)而匹配不上。
- [x] `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入
- [x] `agent_platform_sessions` 镜像表 + 心跳携带 `platform_sessions`
- [x] **插件上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连
Gateway,Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,
那是另一套信任模型
- [x] 与 `sessions` 表**分开存**:镜像里是别人家的会话,id 属于平台的 id 空间,
没有本侧的 owner/预算/邮件。混进 `sessions` 会让每一处「按会话鉴权」
都要先判断这条到底是不是真的本侧会话
- [x] **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 ——
session 位是三态语义,指向不存在的会话直接 404
- [x] **`platform_sessions` 省略与传空数组语义不同**:拉不到列表时省略该字段
(保留镜像),传空数组的语义是「平台侧确实一条会话都没有」
- [x] **subagent 子会话不上报**:实测 DSH 一次列出 49 条子会话,标题就是派活的
提示词前缀(九条都叫 `You are auditing ONE file`),slug 全撞名;
它们是父 agent 内部的工作单元,人往里发邮件毫无意义
- [x] **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里
出现几个点哪个都不确定的候选
- [x] `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的
`workspace` 为权威,历史会话(该列为空)回退到 mails 反推 ——
升级后老会话不该从候选列表里消失
- [x] 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端
与第三方客户端),新增同序的 `candidates`;过滤时标题也参与匹配 ——
人记得的是「缓存选型」而不是 `brisk-harbor` 这种随机短名
### 7.8 跨主机 Agent 发现
@ -1199,7 +1290,9 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。
- [x] 插件自动转发平台原生权限询问与最终总结(不消耗配额)
- [x] 配额下沉到会话:写信时给、对话页里随时改
- [x] 工作列表卡片视图(中间栏,与列表视图切换)
- [ ] DeepSeek Harness 插件(`dsh-mail-bridge`)
- [x] DeepSeek Harness 插件(`dsh-mail-bridge`)
- [x] 平台会话快照同步:工作区下的历史会话可在写信时选中
- [x] 插件适配方法固化为 `docs/PLUGIN-GUIDE.md`
- [ ] 跨主机 Agent 发现(Gateway + Registry 拆分)
### 7.10 窄屏适配(已完成)