7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。
## 为什么不做注册中心
它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。
同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。
## 验证方式:一个纯标准库脚本
`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。
「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。
## 缺陷一:SSE 只推连上之后的事件,没人补拉积压
写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。
新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:
- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文
端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。
## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段
脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。
新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:
{"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
{"error": "JSON 语法错误(第 8 字节处)"}
{"error": "请求体为空"}
刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。
## 验证
- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
23 KiB
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>。密钥来源按优先级:
- 环境变量(systemd 部署走这条)
~/.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:
{
"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 就是「希望它在哪个工作目录干活」。
// 正确
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
回复那条 permission;DSH 这边可以真的 await 到人回答。
两个共同点:
- 转不出去就让位(
return next()或保持ask),别让平台挂在那儿等一个 永远不会来的回答 —— 本地 UI 还能接管 - 拆插件时未决询问一律 fail closed,否则平台侧那些
await永不返回
relay_key 用平台的权限 id(DSH 不发 id,用 会话:工具:callId 拼)。
服务端会随决策事件把它回传,因此插件重启丢了内存映射也能续上。
三、会话命名回写
POST /api/v1/sessions/{id}/sync { alias?, title? }
alias是可寻址的短标识,写入session_aliastitle是模型生成的摘要,写入subject
| 平台 | alias 来源 |
|---|---|
| opencode | session.slug(创建时就有,如 witty-planet) |
| DSH | 由模型标题派生(slugFromTitle) |
派生 slug 时必须去掉寻址分隔符(. @ /)—— 留在别名里会让它自己被
解析器切开,填进去的地址指向一个完全不同的目标。中文可以保留:
三维地址按最后一个 . 切分,中文不影响解析,而转拼音后既不好读也不好打。
服务端撞名时自动追加 -2/-3,因此同步永不失败。人工改过的别名
(alias_source = 'manual')不会被平台同步覆盖。
四、平台会话快照上报
写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。Gateway 只看得见 邮件驱动的那部分 —— 人直接在平台界面上开的会话它一无所知。
插件在心跳里带上快照:
{
"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 持密钥 主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与 凭证,那是另一套信任模型。代价是插件没运行时同步不了 —— 但插件没运行时邮件 本来也投不进去。
共用实现 lib/session-snapshot.js。四条规则:
- 无
slug的会话不报:slug 是填进 session 位的值,没有它这一项在补全里 点下去只能得到一个空的 session 段 - subagent 子会话不报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
实测 DSH 一次列出 49 条子会话,标题就是派活的提示词前缀
(九条都叫
You are auditing ONE file),slug 全撞名 - slug 撞名只留最近那条:服务端只能取其中一条,上报同名项只会让补全里出现 几个点哪个都不确定的候选
- 按最近活跃排序并截断到 200 条:上千个候选对人没有意义
platform_sessions 省略与传空数组语义不同。 拉不到列表时省略该字段
(保留服务端现有镜像);传空数组的语义是「平台侧确实一条会话都没有」,
会把镜像抹掉。
五、模型范围与降级尝试
管理员在配置页为每个平台划定「邮件场景下可用的模型」。插件按顺序逐个尝试, 全部失败才回一封说明失败原因的邮件。
上报目录
随心跳带 models(与会话快照同一个请求):
{ "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
子类型也带错误 ——
{"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:
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。
workspaces 是对象数组
注册体的 workspaces 要 [{name, path}]。传字符串数组会 400。
两个官方插件都传 [](工作目录由每封邮件的 to_workspace 决定),
所以照抄它们不会踩;自己从 API 文档写起就会。
启动时要主动拉一次收件箱
SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一次 ——
心跳响应的 pending_mails 是唯一线索。不补拉的后果:
那封邮件永远躺在收件箱里,发件人以为 Agent 收到了。
共用实现 lib/catchup.js 的 selectCatchup(mails, seen, max)。四条约束:
只在首个心跳后补(每轮都补会重复投递正在处理中的邮件)、串行且一次最多 5 封
(每封都要起一轮模型)、与 SSE 共用一个 deliveredMails 集合去重
(建连窗口期的邮件两条路都会到)、按时间正序投(收件箱是倒序返回的)。
八、新平台适配清单
[ ] 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' 发信
[ ] 6.5 启动补拉:心跳 pending_mails > 0 时先处理存量未读
[ ] 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 |
模型目录整理 + 降级顺序 + 失败报告 |
catchup.js |
离线期间积压邮件的补投选择 |
message.js |
DSH 的消息构造与会话日志读取(DSH 专用) |
test/ 下对应的测试文件同样逐字节共用。
TypeScript 插件另需 .d.ts(lib/ 是 JS,tsc 需要类型声明)。
改动共用模块时两侧一起改。 一侧改了另一侧没改,两个平台的行为就会悄悄分叉: 同一封邮件在 opencode 那边标了已读、在 DSH 那边没标,而两处代码看起来都「对」。
deploy/install.sh 会跑同源校验,也可以单独执行:
./deploy/check-shared-libs.sh
接新平台时把 lib/ 与 test/ 整个拷过去,平台专属逻辑写在入口文件里。
共用模块只依赖 node 内置模块,不碰任何平台 SDK —— 这是它们能共用的前提,
新增共用函数时也要守住。