Files
MailUI4Agents/docs/PLUGIN-GUIDE.md
JianFeeeee 89356d4a9b feat: 每平台可用模型范围 + 降级尝试 + 失败回报
配置页为每个 Agent 平台划定「邮件场景下可用的模型」,插件按顺序逐个尝试,
全部失败把原因封装成邮件回复。目录由插件上报、管理员只做勾选 —— 手打模型名
会打错,而打错的后果要到真发邮件时才暴露成一次失败。

## 目录上报走心跳,不另设端点

模型清单会在运行中变(换 provider 配置、上游上下线、换 API key)。
只在注册时报一次的话目录会静静变陈,管理员在配置页选中一个平台其实调不到的
模型。心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案,排查时要同时看两处。

心跳响应回传 `allowed_models`,因此管理员改了范围后最多一个周期生效,
不必重启插件。

与 platform_sessions 同一约定:拉不到目录时**省略字段**(保留现有目录),
传空数组会把配置页清成空白。

## 目录与选择分两张表

模型会从平台目录里消失(上游临时下线、换了 provider 配置)。合成一张带
allowed 标记的表时,整行被删就连带把管理员的选择也删了,模型回来还得重配一遍。
分开存之后「选了什么」是持久的,目录只决定「这一项现在是否可用」;
已选但不在目录里的标为 stale 显示出来 —— 不显示会让人以为自己没选过它。

## 最难的一点:模型失败不是同步抛出的

两个平台都踩了。`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,
只包 try/catch 的话第二个模型永远不会被试到 —— 第一个无效模型会被判成成功。

必须等异步结论:
- opencode → `session.error` 事件(event 钩子在 deliverMail 之外,
  因此用 turnWatchers 表把两者接起来)
- DSH → `turn/end` 的 `reason.kind === 'error'`

DSH 还有个陷阱:**`assistant/chunk` 不能当成功信号**,它的 `finish` 子类型
也带错误 —— `{chunk:{type:'finish',reason:{kind:'error',failure:{code:'NO_ADAPTER'}}}}`。
实测「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。判据要落在
chunk 的类型上:finish 看 reason,其余才意味着模型真的在产出。

超时按成功处理(60 秒窗口):模型可能只是很慢,把慢当成失败会在换模型的同时
把已经在跑的那一轮丢掉。

DSH 换模型要换会话 id(`<原 id>-r1`)并 dispose 失败那个 agent:复用同一个 id
会让重试接在一条已经出错的会话后面,不 dispose 则 agent/status 还会为那个
死会话触发一次自动转发。

## 其他决策

- **范围优先于环境变量**:范围是运行时可改的策略,`AGENTMAIL_REPLY_*` 是部署时
  的兜底。反过来的话管理员在配置页改了却不生效,得去改 service 文件重启
- **范围为空返回 `[undefined]` 而非 `[]`**:空数组会让调用方一次都不试,
  而「管理员没配」的正确含义是不限定,不是「一个都不许用」
- **上限 10 个**:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- 前端 key 按**第一个** `/` 切分 provider/model:model id 可能含 `/`
  (如 `org/model-name`),按最后一个切会把 provider 切错
- 保存后用服务端返回的结果刷新界面而非回显入参:repo 层会跳过重复与空字段

## 验证

- Go 10 个新测试(含「模型从目录消失后选择必须留存」的直接回归)
- 两插件各 18 个模型范围测试,共 180 个
- 端到端四轮:正常路由 → 全部无效(收到失败回报邮件,used_rounds 保持 0
  确认走了免配额通道)→ DSH 降级(fake-a 失败 → llmsproxy/AUTO 成功)→
  opencode 降级(nonexistent/bad 失败 → AUTO 成功,日志确认「前 1 个失败」)
- 生产已部署,前端「模型范围」页可用
2026-09-02 21:34:55 +08:00

517 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Agent 平台插件适配指南
把一个新的 Agent 平台接进 AgentMail 需要写一个**桥接插件**。这份文档描述插件的
职责边界、必须实现的六件事以及两次真实适配opencode、DeepSeek Harness
踩过的坑。
现有实现可直接对照:
| 插件 | 平台 | 框架 | 语言 |
|---|---|---|---|
| `plugins/opencode-mail-bridge/` | opencode | `@opencode-ai/plugin` | JavaScript |
| `plugins/dsh-mail-bridge/` | DeepSeek Harness | Cordis | TypeScript |
---
## 一、插件的职责
插件是**平台与 Gateway 之间的翻译层**,只做搬运,不做决策。
```
SSE (new_mail / permission_decision)
AgentMail ─────────────────────────────────────────▶ 插件 ──▶ 平台会话
Gateway ◀─────────────────────────────────────────
HTTP (register / heartbeat / mail.send / …)
```
三条设计原则贯穿全文,先说清楚,后面每一节都是它们的推论:
### 原则一:平台原生信号才是真相来源,不要求模型「记得」调工具
模型可能忘了调,也可能在不需要时乱调。真正被平台拦下的那次权限询问、
模型真正说完的那段话,都是平台自己知道的事实。
**推论**:不提供 `request_permission` 工具(改为挂 `permission.ask` / `approval/request` 钩子),
不要求模型主动调 `send_mail` 回信(改为在「一轮结束」的平台信号上自动转发)。
### 原则二:插件代劳的转发不消耗配额
配额约束的是模型的自主发信。插件把平台原生的权限询问与最终总结搬到邮件里,
对它收费会导致配额用尽时 Agent 连交代都做不了。
**推论**:这两类转发带 `relay` + `relay_key`,走服务端的免配额通道。
### 原则三:平台命名优先,不另造一套
各平台本来就会由模型为会话生成摘要标题与短标识。平台那边叫什么,
AgentMail 这边的 `session_alias` 就叫什么。
**推论**:创建会话时**不要**传占位标题(那会掐掉平台自己的命名机制),
标题生成后通过 `POST /sessions/{id}/sync` 回写。
---
## 二、必须实现的六件事
### 1. 注册与心跳
```
POST /api/v1/agent/register { name, platform, workspaces: [] }
POST /api/v1/agent/heartbeat { platform_sessions?: [...] }
```
认证用 `Authorization: Bearer <agent_key>`。密钥来源按优先级:
1. 环境变量systemd 部署走这条)
2. `~/.agentmail/agent.key` —— 首次启动时**本地生成**并打印到日志
**为什么是登记式而不是服务端签发**:密钥全文只从客户端流向服务器一次。
插件生成后打印出来管理员在后台「Agent 密钥」页登记即可,
不需要把密钥从服务器反向传给客户端。
登记接口的字段名是 **`key_token`**(不是 `key`)。传错服务端会静默生成一个
随机 token 且全文只回一次 —— 这个坑踩过。
**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线
DSH 插件最初就漏了心跳,靠注册那一次撑着)。间隔 30 秒。
### 2. SSE 订阅
```
GET /api/v1/events/stream
```
关心两个事件:
| 事件 | 处理 |
|---|---|
| `new_mail` | 投递到平台会话(新建或续谈) |
| `permission_decision` | 回答之前挂起的权限询问 |
`new_mail` 的 payload
```json
{
"mail_id": "...", "session_id": "...", "from_name": "admin",
"subject": "...", "mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail"
}
```
`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的),
见下一节。
服务端支持 `Last-Event-ID` 补投:断线重连时带上它,能取回断线期间的事件。
首次连接不传该头(否则会收到一批已处理过的旧事件)。
### 3. 工作目录:必须用寻址里的 path 位
三维地址 `name@path.session``path` 就是「希望它在哪个工作目录干活」。
```js
// 正确
const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, sessionId);
// 错误:每封邮件一个新的临时目录
const cwd = join(homedir(), '.dsh', 'mail-sessions', sessionId);
```
**这是踩过最贵的坑之一。** 平台按 cwd 给会话分组,用自己拼的临时目录会让所有
邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进「未分组」。
`lib/workspace.js` 是共用实现,三条规则:
- 目录**已存在**才用,不存在时回退到兜底目录而**不创建**
(一个笔误 `/home/porgram/x` 不该在磁盘上落下真目录Agent 会在里面一无所获地干活)
- 拒绝相对路径cwd 的相对基准是 harness 进程的启动目录systemd 下通常是 `/`
- `path` 为空(地址写成 `dsh` 而不带 `@/path`)时用兜底目录
### 4. 六个工具
| 工具 | 说明 |
|---|---|
| `send_mail` | 主动发信。三维地址、抄送、`reply_to`、附件 |
| `read_inbox` | 读收件箱,**顺便标记已读** |
| `forward_mail` | 转发可选opencode 有 / DSH 暂无) |
| `upload_attachment` | 本地文件 → `attachment_id` |
| `download_attachment` | `attachment_id` → 本地文件 |
| `connect_to_server` | 登记密钥并注册(可选,方便首次接入) |
**不提供 `request_permission`** —— 见原则一。
`read_inbox` 的渲染与已读策略放在共用的 `lib/inbox-format.js`
它与平台 SDK 无关,各平台必须一致。三条规则各对应一次错误行为:
- **附件必须带 `attachment_id`**:只说「有附件」模型就无从下载
- **抄送人要显示**:不显示的话模型以为这是私信,回信时漏掉其他参与方
- **只标本次列出的那些**,且 `status=all` 时不标
`limit` 之外的还没看过;把历史邮件标成已读会让下一轮的新邮件混在里面认不出来)
### 5. 自动转发最终总结
在平台的「一轮结束」信号上,取最后一条 assistant 消息的**文本块**发回去。
| 平台 | 信号 |
|---|---|
| opencode | `session.idle` 事件 |
| DSH | `agent/status``idle` |
三个必须处理的细节:
- **只取 `type === 'text'` 的块。** reasoning 是思考过程,不该出现在邮件里。
- **让位于模型的主动发信。** 模型自己调过 `send_mail` 回这条线索时不再自动转发,
否则同一件事发两封生产里真实发生过311 字节 + 342 字节各一封,
其中只有一封带附件)。共用实现在 `lib/relay-dedup.js`
- **带 `relay: 'summary'` + `relay_key`** 走免配额通道。`relay_key` 要是一个
平台侧的稳定 id消息 id / 事件序号),模型伪造不出来 —— 它保证同一条消息
不被转两次。
去重靠 `explicitSends`(进程内记录本轮模型主动发过的信)**而不是** `relay_key`
后者保证「同一条消息不转两次」,管不了「模型已经自己发过了」。
### 6. 权限询问转邮件
挂平台的权限钩子,把询问转成一封邮件问人。
| 平台 | 钩子 | 能否等人 |
|---|---|---|
| opencode | `permission.ask(input, output)` | **不能** —— 同步钩子,卡住会挂死整个请求 |
| DSH | `approval/request` waterfall | **能** —— 返回 `Promise<ApprovalOutcome>` |
opencode 那边只能「转出去 + 立即返回 `ask`」,人类决策通过 SSE 回来后再用 SDK
回复那条 permissionDSH 这边可以真的 `await` 到人回答。
两个共同点:
- **转不出去就让位**`return next()` 或保持 `ask`),别让平台挂在那儿等一个
永远不会来的回答 —— 本地 UI 还能接管
- **拆插件时未决询问一律 fail closed**,否则平台侧那些 `await` 永不返回
`relay_key` 用平台的权限 idDSH 不发 id`会话:工具:callId` 拼)。
服务端会随决策事件把它回传,因此插件重启丢了内存映射也能续上。
---
## 三、会话命名回写
```
POST /api/v1/sessions/{id}/sync { alias?, title? }
```
- **`alias`** 是可寻址的短标识,写入 `session_alias`
- **`title`** 是模型生成的摘要,写入 `subject`
| 平台 | alias 来源 |
|---|---|
| opencode | `session.slug`(创建时就有,如 `witty-planet` |
| DSH | 由模型标题派生(`slugFromTitle` |
派生 slug 时**必须去掉寻址分隔符**`.` `@` `/`)—— 留在别名里会让它自己被
解析器切开,填进去的地址指向一个完全不同的目标。中文可以保留:
三维地址按最后一个 `.` 切分,中文不影响解析,而转拼音后既不好读也不好打。
服务端撞名时自动追加 `-2`/`-3`,因此同步永不失败。人工改过的别名
`alias_source = 'manual'`)不会被平台同步覆盖。
---
## 四、平台会话快照上报
写信时想续谈某条会话得先知道那个工作区下有哪些会话可续。Gateway 只看得见
邮件驱动的那部分 —— 人直接在平台界面上开的会话它一无所知。
插件在心跳里带上快照:
```json
{
"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" }
]
}
```
**为什么是插件上报而不是 Gateway 反向拉取**当前架构是单向的Agent 持密钥
主动连 GatewayGateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与
凭证,那是另一套信任模型。代价是插件没运行时同步不了 —— 但插件没运行时邮件
本来也投不进去。
共用实现 `lib/session-snapshot.js`。四条规则:
- **无 `slug` 的会话不报**slug 是填进 session 位的值,没有它这一项在补全里
点下去只能得到一个空的 session 段
- **subagent 子会话不报**:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
实测 DSH 一次列出 49 条子会话,标题就是派活的提示词前缀
(九条都叫 `You are auditing ONE file`slug 全撞名
- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里出现
几个点哪个都不确定的候选
- **按最近活跃排序并截断到 200 条**:上千个候选对人没有意义
**`platform_sessions` 省略与传空数组语义不同。** 拉不到列表时**省略该字段**
(保留服务端现有镜像);传空数组的语义是「平台侧确实一条会话都没有」,
会把镜像抹掉。
---
## 五、模型范围与降级尝试
管理员在配置页为每个平台划定「邮件场景下可用的模型」。插件按顺序逐个尝试,
全部失败才回一封说明失败原因的邮件。
### 上报目录
随心跳带 `models`(与会话快照同一个请求):
```json
{ "models": [
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
] }
```
**为什么随心跳而不是只在注册时报一次**:模型清单会在运行中变(换 provider
配置、上游上下线、换 API key。只在注册时报的话目录会静静变陈管理员在配置页
选中一个平台其实调不到的模型,失败要到真发邮件时才暴露。
`platform_sessions` 同一约定:拉不到目录时**省略该字段**(保留服务端现有目录),
传空数组会把配置页清成空白。
| 平台 | 目录来源 |
|---|---|
| opencode | `client.config.providers()``providers[].models` 是**对象**,键是 model id |
| DSH | `ctx.llm.listProviders()` 再逐个 `listModels(provider)` |
### 读取生效范围
心跳响应回传 `allowed_models`(按优先级)与 `models_unrestricted`
插件存在模块级变量里,下次投递时用 —— 管理员改了范围后最多一个心跳周期
30 秒)生效,不需要重启。
也有 `GET /agent/models/allowed`,但那是给没有心跳循环的第三方客户端与排查用的。
### 尝试顺序
共用实现 `lib/model-scope.js``modelAttemptOrder(allowed, envDefault)`
| 情形 | 返回 |
|---|---|
| 管理员划定了范围 | 按 rank 顺序的路由列表 |
| 没划定,但配了 `AGENTMAIL_REPLY_*` | 环境变量那一个 |
| 都没有 | `[undefined]` —— 交给平台自己选 |
**范围优先于环境变量**:范围是运行时可改的策略,环境变量是部署时的兜底。
反过来的话管理员在配置页改了却不生效,得去改 service 文件重启。
**范围为空时返回 `[undefined]` 而不是 `[]`**:返回空数组会让调用方一次都不试,
而「管理员没配」的正确含义是不限定,不是「一个都不许用」。
### 判定一次尝试是否成功 —— 这里最容易错
**两个平台的模型失败都不是同步抛出的。** 只包一个 try/catch 的话第二个模型
永远不会被试到:
| 平台 | 提交调用 | 失败从哪来 |
|---|---|---|
| opencode | `promptAsync()` 立即返回 | `session.error` 事件 |
| DSH | `ctx.agents.create()` 不校验模型 | `turn/end``reason.kind === 'error'` |
DSH 侧还有一个陷阱:**`assistant/chunk` 本身不能当成功信号**,它的 `finish`
子类型也带错误 ——
```json
{"chunk": {"type": "finish", "reason": {"kind": "error", "failure": {"code": "NO_ADAPTER"}}}}
```
实测踩过一次「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。
判据要落在 chunk 的类型上:`finish` 看 reason其余`block-start`
`text-delta``tool-call-delta`…)才意味着模型真的在产出。
**超时按成功处理**:模型可能只是很慢(首 token 前要装载上下文),
把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。窗口取 60 秒。
DSH 侧换模型要**换会话 id**`<原 id>-r1`)并 `dispose()` 失败那个 agent
复用同一个 id 会让重试接在一条已经出错的会话后面,而不 dispose 的话
`agent/status` 还会为那个死会话触发一次自动转发。
### 全部失败时必须发信
模型一次都没跑起来时会话里没有任何 assistant 消息,自动转发因此什么也不会发
—— 发件人只会看到邮件发出去后再无音讯。
`renderFailureReport(failures, subject)` 生成正文(逐条列出路由与原因,
并指出去哪里调整)。这封信带 `relay: 'summary'` 走免配额通道:
它是插件的故障报告,不是模型的自主发信。
## 六、平台差异对照
| 关注点 | opencode | DeepSeek Harness |
|---|---|---|
| 插件形态 | `export default async function(input)` | Cordis`export const inject` + `apply(ctx, config)` |
| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` |
| 投递消息 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` |
| 工具定义 | zod schema | `defineTool()` + spec 格式参数 |
| 一轮结束 | `session.idle` 事件 | `agent/status``idle` |
| 权限钩子 | `permission.ask`(同步,不能等) | `approval/request`(异步 waterfall能等 |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` |
| 模型目录 | `client.config.providers()` | `ctx.llm.listProviders()` + `listModels()` |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind==='error'` |
| 别名来源 | `session.slug` | 模型标题派生 |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl |
---
## 七、踩过的坑
按「排查成本」降序。新接平台时先扫一遍这一节。
### `followup()` 的参数形状(花了一下午)
DSH 的 `agent.followup(message)` 要完整的 `UserMessage`
```ts
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
```
照抄 opencode 的 parts 数组 `[{ type: 'text', text }]` 不会当场报错 ——
agent-loop 会一路走到 `preStep` 里读 `message.source.kind`,然后抛
`Cannot read properties of undefined (reading 'kind')`。错误落在框架内部,
既不指向调用点也不说是哪个字段turn 一 start 就 end、模型请求根本不发出去。
**教训**:这类「错了不当场报错、只在深处炸一个无关错误」的约定必须用测试钉住。
`lib/message.js` + `test/message.test.mjs` 就是为此存在的。
### opencode 插件入口只能有 `default` 一个导出
opencode 用 `Object.values(mod)` 把**每个导出**都当插件工厂检查
(反编译确认)。入口多导出一个 Map 就报 `Plugin export is not a function`
插件静默失效、邮件全投不进去。
**因此所有可测试的逻辑必须放 `lib/` 子模块**,入口只 `export default`
`test/auto-relay.test.mjs` 里有一条断言钉住这一点。
### Cordis 插件必须导出 `inject`
没有它 `ctx.tools` / `ctx.agents` 根本不存在(报
`cannot get property "tools" without inject`)。
但**不要把可选服务写进 `inject`** —— 那是硬依赖,服务没挂载时整个插件不启动。
DSH 的 `sessionQuery``ctx.get('sessionQuery')` 取:会话上报只是补全体验,
不该能把邮件投递整体拘死。
### DSH 工具必须经 `defineTool`
直接给 `ctx.tools.register` 原始对象会报
`parameters must be lossless JSON before schema projection`
`defineTool`(来自 `@deepseek-ai/dsh-tools`,不在 npm运行时从 DSH 的
node_modules 解析)负责把 spec 格式的 `parameters` 转成 JSON Schema 并在
`execute` 前校验。还必须声明 `output: { schema, render }`
### `ctx.logger` 不进 journalctl
DSH 的 `ctx.logger.info` 在 systemd 下看不到,`console.error` 能看到。
排查阶段用后者。
### 同一时间只能跑一个 DSH 实例
`@linxin666/dsh-client-ui-task-board` 有 ledger 文件锁
`task-board ledger is already owned by process ...`)。因此邮件桥接是
**注入现有 `dsh.service`**,而不是另起一个实例。
### systemd 不注入 `HOME`
插件要读 `~/.agentmail/agent.key``HOME` 缺失时会落到 `/`
service 文件里显式 `Environment=HOME=/root`
opencode 还有个额外问题:**插件是懒加载的**,进程起来了插件还没加载 ——
`ExecStartPost` 发一个空请求预热。
### `dsh --patch` 对 `dsh web` 无效
`--patch` 只在 `dsh --profile <name>` 形式下有效,`dsh web` 不认这个选项。
插件配置要写进 profile 的 `cordis.patch.yml`
---
## 八、新平台适配清单
```
[ ] 1. 认证与连接
[ ] 密钥:环境变量 → ~/.agentmail/agent.key本地生成并打印
[ ] POST /agent/register
[ ] 心跳 30s别漏Gateway 靠 last_seen 判在线)
[ ] SSE 订阅,支持断线重连
[ ] 2. 会话投递
[ ] cwd 取 data.to_workspace复用 lib/workspace.js
[ ] 新建会话不传占位标题
[ ] 维护 mailSessionID ↔ 平台 sessionID 双向映射
[ ] 续谈:映射命中且会话还活着 → followup否则新建
[ ] 3. 工具(复用 lib/inbox-format.js
[ ] send_mail / read_inbox / upload_attachment / download_attachment
[ ] read_inbox 顺便标记已读只标本次列出的status=all 时不标)
[ ] 不提供 request_permission
[ ] 4. 自动转发(复用 lib/relay-dedup.js
[ ] 找到平台的「一轮结束」信号
[ ] 只取 text 块,丢掉 reasoning
[ ] relay: 'summary' + 稳定的 relay_key
[ ] 模型主动发过就让位
[ ] 5. 权限询问
[ ] 挂平台的权限钩子
[ ] 转不出去就让位给本地 UI
[ ] 拆插件时未决询问 fail closed
[ ] 6. 模型范围(复用 lib/model-scope.js
[ ] 心跳带 models拉不到就省略别传空数组
[ ] 心跳响应读回 allowed_models
[ ] 按 modelAttemptOrder 逐个尝试
[ ] **等异步结论**再判成功/失败(失败不是同步抛的!)
[ ] 全部失败 → renderFailureReport + relay:'summary' 发信
[ ] 7. 命名与快照
[ ] alias/title 回写 POST /sessions/{id}/sync
[ ] slug 去掉 . @ / 等寻址分隔符
[ ] 心跳带 platform_sessions复用 lib/session-snapshot.js
[ ] 过滤 subagent、slug 去重
[ ] 8. 工程
[ ] 可测逻辑放 lib/,入口保持最小
[ ] 纯函数测试纳入 deploy/install.sh 的门禁
[ ] 端到端:发一封 → 会话建在正确 cwd → 自动回信 → 别名可续谈
```
---
## 九、共用模块
`lib/` 下的文件在两个插件里**逐字节相同**,接新平台时直接拷。
它们只依赖 node 内置模块,不碰任何平台 SDK。
| 文件 | 职责 |
|---|---|
| `relay-dedup.js` | 自动转发去重:本轮模型是否已亲手回过这条线索 |
| `inbox-format.js` | 收件箱渲染 + 已读策略 |
| `session-snapshot.js` | 平台会话快照整理(含 subagent 过滤、slug 派生) |
| `workspace.js` | 寻址 path 位 → 可用的 cwd |
| `model-scope.js` | 模型目录整理 + 降级顺序 + 失败报告 |
| `message.js` | DSH 的消息构造与会话日志读取DSH 专用) |
`test/` 下对应的测试文件同样逐字节共用。
TypeScript 插件另需 `.d.ts``lib/` 是 JS`tsc` 需要类型声明)。
**改动共用模块时两侧一起改。** 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:
同一封邮件在 opencode 那边标了已读、在 DSH 那边没标,而两处代码看起来都「对」。
`deploy/install.sh` 会跑同源校验,也可以单独执行:
```bash
./deploy/check-shared-libs.sh
```
接新平台时把 `lib/``test/` 整个拷过去,平台专属逻辑写在入口文件里。
共用模块只依赖 node 内置模块,不碰任何平台 SDK —— 这是它们能共用的前提,
新增共用函数时也要守住。