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 持密钥主动连
GatewayGateway 从不外呼反向拉取需要它保存各平台的地址与凭证
那是另一套信任模型
- [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 窄屏适配(已完成)