npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-astrbot-gateway

v0.6.1

Published

DSH cordis plugin: task dispatch, session wake-up and completion reporting through the AstrBot dsh gateway.

Readme

dsh-astrbot-gateway

安装方式见仓库的 docs/install-dsh.md,那份是写给 AI 的,照着做就能挂进 profile。

dsh 侧的桥接插件:把闸门筛选后转达的指令从 cache/inbox 取来执行,结果写进 cache/outbox 交付给闸门 —— 由闸门转述给用户,dsh 不直连用户

对应任务卡:../../docs/task-for-dsh.md;消息规范:../../docs/message-rules.md(中转规则 v2)。

它做什么

| 方向 | 行为 | | --- | --- | | 下行 | 每 pollMs(默认 4s)扫一次 cache/inbox/*.json;发现 pending 指令就自动拉起一个 DSH 会话去处理autoDispatch,默认开),并把「收到 / 已拉起 / 拉起失败」写成 cache/outbox/<id>.notice.json 通知文件,同时刷新队列快照 cache/bridge_status.json。 | | 交付 | 只落盘,不直发用户uplinkMode 默认 off):结果写 cache/outbox/<id>.json,字段按 v2 规范齐全;落盘后闸门侧会被叫醒,由闸门用自己的话讲给用户。 | | 接单 | 提供工具 bridge_inbox / bridge_claim / bridge_complete,由 agent 认领并执行指令,完成后写 outbox 交付。 | | 收尾 | runningTimeoutMs(默认 30 分钟)超时回收:认领后一直不回报的(DSH 重启、会话被停止、agent 被杀)会被标成 failed 并落一条失败结果给闸门,避免永久挂在队列里冒充「在跑」。写 0 可关闭。 | | 上行 | 保留但默认关闭/send 到达的是闸门的 QQ 账号(= 用户的私聊),用它发就等于绕过闸门。只有显式写 uplinkMode: 'on'(救火)才会走;启动时仍会 GET /ping 做自检,结果记在 bridge_status.json.uplink。 |

v2 与 v1 的差别(一句话)

v1:完成任务后用上行接口直接给用户发消息,用户在 QQ 里看到的是 dsh 的话。 v2:完成任务后只写 outbox,用户通过闸门得知结果,且闸门会注明「来自dsh」。 差别落在三处:outbox/<id>.json 的字段(多了 sourceto用户 改成 gateway)、 通知改落 outbox/<id>.notice.json、以及默认零上行。

自动拉起:扫到 pending 就自己干活(autoDispatch

轮询发现新指令后,插件会自己叫醒一个会话去处理,不用用户手动喊:

sessionController.create({})        // 建会话:sessionId / cwd / 预设都可省略,走宿主默认
sessionController.prompt({          // 投一条用户消息(按 requestId 幂等,重复投喂会被去重)
  sessionId, requestId: `bridge-<id>`, mode: 'queue',
  content: [{ type: 'text', text: '【桥接指令 <id>】… 请 bridge_claim → 执行 → bridge_complete' }],
})

机制照抄部署里现成的 @agents-anywhere/dsh-bridge-next(AA 手机端 → DSH 会话,就是 「外部事件拉起会话」的官方实现),两个服务都用 ctx.get 取(可选取)。

容错设计(都很重要):

  • sessionController 缺失 / create 抛错 / prompt 抛错 → 只降级成「只通知」,插件本身照常跑, 并且在通知里如实写明失败原因;最多重试 3 次,两次之间至少隔 dispatchRetryMs (默认 60s,避免轮询 4s 时十几秒就把重试次数烧完),之后放弃并写明「需要你叫我处理」。
  • 已拉起的指令写 cache/.dispatched-<id> 标记,重启后也不会重复拉起; 即使标记写失败,promptrequestId 幂等也能兜住重复投喂。
  • 仍然不擅自把任务标成 running:认领仍由 agent 显式调 bridge_claim 完成, 这样没人干活时任务不会被悄悄吞掉。
  • capabilities.dispatch 四种取值:disabled(关掉了)/ ready / lazy / failedlazy 是正常现象:插件的行可能比 session-controller 那一行先激活,而 cordis 对 尚未运行的服务 ctx.get 会返回 undefined。真正的判定发生在轮询拉起时(会重新 ctx.get), 所以 lazy 不影响自动拉起。

桥接会话跑哪种「模式」(agentPreset

sessionController.create() 不传 agentPreset 时用的是宿主默认预设,而本机 $DSH_HOME/settings.yamlagent-presets.defaultteyvat-hoi4(提瓦特黎明 HOI4 项目专用 persona,每回合强制先读 E:\teyvatdaybreak\PROJECT_RULES.md + SKILL 索引)。 不管它的话,每条桥接指令拉起的会话都被 HOI4 模式接管 —— 跟桥接任务毫无关系,白烧几轮 读规则。实测三条桥接会话(group-182442rule-v2-…selfcheck-v2-1)的 header 全是 "agentPreset":"teyvat-hoi4"

规矩(用户定的):桥接默认走「标准模式」(standard);确实要 HOI4 模式的单条指令由闸门点名。

优先级(高 → 低):

| 来源 | 写法 | 说明 | | --- | --- | --- | | 单条指令 | inbox JSON 里 "preset": "teyvat-hoi4"(也认 "agentPreset") | 闸门按这条指令的性质选模式 | | 插件配置 | 行配置 dispatchPreset: standard | 本机 desktop profile 的补丁层已这么设 | | 宿主默认 | 不传 agentPreset | 兜底,等于 settings.yamlagent-presets.default |

点名一个不存在的预设不会让指令失败:记一行日志(…点名的预设 "x" 不在花名册(可用: …),回退到 …) 后按「插件配置 → 宿主默认」回退。花名册(ctx.agentPresets)读不到时也不拦,照传, 交给宿主 create()agent-preset/not-found 说话 —— 「读不到花名册」和「预设不存在」是两回事。

拉起后的通知里会写明这次用的模式:已自动拉起会话 <sessionId> 处理(模式 standard)。

部署侧那个默认值写在 $DSH_HOME/profiles/desktop/cordis.patch.yml(desktop profile 是 patchReload: live改完不重启就会重新组合)。改插件代码则必须重启 dsh: base 里 hmr 那一行是 disabled,只有配置会被热重载。

为什么轮询不自动认领

autoClaim 默认 false:把 status 改成 runningbridge_claim 显式完成。 这样重复轮询、以及用户不在场/没人干活时,指令都不会被悄悄标成 running 而永久卡住。 需要自动认领就把 autoClaim 打开。

已通知过的指令会在 cache/.notified-<id> 留标记,所以插件重启后不会重复落通知文件。

目录约定

cache/
  bridge_access.json     # 闸门侧签发(含 token),本插件只读
  inbox/<id>.json        # 闸门筛选后转达的指令,本插件读 + 回写 status
  outbox/<id>.json       # 本插件写的结果(v2:to=gateway,带 source)
  outbox/<id>.notice.json# 本插件写的巡检通知(收到指令 / 已拉起 / 拉起失败)
  bridge_status.json     # 本插件写的自检/队列快照,便于排查(含 dispatch.cwd/preset、accessError、hint)
  .notified-<id>         # 本插件的「已通知」标记
  .dispatched-<id>       # 本插件的「已自动拉起」标记(含 session id)

bridge_status.json 里的 hint 是给人看的:上行自检通过时它是 null;不通时它会直接写明 「AstrBot 侧还没装好,去面板装哪个插件、填哪一项、然后重启」。桥断掉的时候先看这一行, bridge_inbox 的返回里也会带上同一句。

inbox/<id>.json 结构(闸门落盘,from 应为 gateway —— v2 起 dsh 不直收用户原文):

{"id":"...","from":"gateway","to":"dsh","time":"ISO8601","type":"task","content":"文本","status":"pending"}

可选字段 preset(或 agentPreset):这条指令用哪个 agent 预设跑。不写就用插件配置的 dispatchPreset(本机 = standard),再不写才落到宿主默认。见上面「桥接会话跑哪种模式」。

outbox/<id>.json 由本插件写(v2 规范,docs/message-rules.md 第二节):

{
  "id": "任务ID", "from": "dsh", "to": "gateway", "source": "dsh",
  "time": "ISO8601", "type": "result", "ref": "对应的任务ID",
  "status": "done", "summary": "一句话摘要", "content": "完整内容"
}

字段规矩:source(信息来源)与 status(当前状态)缺一不可 —— 少了闸门侧就无法 判断这条该不该转、转的是谁的话;content 必须是完整结果(不能拿摘要顶替), 超长时截断处会留一行显式说明。statusdonefailed

outbox/<id>.notice.json 是本插件的巡检通知(type: "notice"notice: true,字段与上面同构)。 它存在的理由:v2 下 dsh 连「我收到了,正在干」这种即时告知也不能直发用户, 只能落盘,等闸门被叫醒时一并转述。

写 outbox 一律用「临时文件 + rename」,读者不会看到半截文件。

配置

插件按 cordis 配置项读取,全部可选(默认值即可跑):

| 键 | 默认 | 说明 | | --- | --- | --- | | root | <项目目录>\cache | 桥接缓存根目录 | | pollMs | 4000 | 轮询间隔;0 关闭轮询(只用工具手动处理) | | autoDispatch | true | 扫到 pending 就自动拉起会话处理;设 false 退回「只通知」 | | dispatchPreset | 空 | 自动拉起用哪个 agent 预设(= 会话以哪种「模式」跑);空 = 宿主默认预设。本机 desktop profile 的补丁层设成 standard,免得每条桥接指令都继承 settings.yaml 里那个 HOI4 项目预设;单条指令可用 inbox JSON 的 preset 覆盖 | | dispatchCwd | 桥接目录的上一级 | 自动拉起的会话归属哪个工作区组别;目录不存在则落「未分组」 | | autoClaim | false | 轮询发现新指令时是否自动标 running | | uplinkMode | off | off=只落盘交付闸门(v2 默认,不直发用户);on=恢复 v1 直发(仅救火用) | | defaultTarget | 空 | 上行目标;空则用 bridge_access.jsondefault_target(只对 uplinkMode: 'on' 有意义) | | targetType | PrivateMessage | AstrBot 侧只接受 PrivateMessage / GroupMessage |

安装(已执行)

本插件是 profile bundle 插件(宿主组合里的一行),装好后长期生效、对所有会话可用, 但需要重启 dsh 才会加载。已经用 tools/install.ps1 -Apply 装好,重启即生效。

若要在别的机器/环境重装,脚本做两件事:

  1. 在 profile 里把本包登记为本地依赖,并把 "dsh-astrbot-gateway" 追加进 profile package.jsondsh.profile.bundles。本包自带 cordis.patch.ymlinsert 一行 dsh-astrbot-gateway),所以只要它在 bundles 列表里就会自动挂载。
  2. package.json 前自动备份,可重复执行(幂等)。
# 只看会改什么
powershell -ExecutionPolicy Bypass -File tools\install.ps1
# 真正执行
powershell -ExecutionPolicy Bypass -File tools\install.ps1 -Apply

重启后确认:

  • cache/bridge_status.json 出现且 "uplink": true
  • capabilities 字段里 tools / timer / interval 都是 true
  • 工具列表里能看到 bridge_inbox / bridge_claim / bridge_complete

两个已经踩过的坑(改这个插件前务必先看)

1. pnpm 对 file: 依赖是「复制」,不是软链

改完 lib/index.js 后直接把插件加进 profile,pnpm 会在 node_modules 里留一份 旧副本,并且重跑 pnpm add 只会回一句 Already up to date —— dsh 加载的 始终是那份旧代码。表现是「改了没反应」,非常容易误判成 inject 或加载问题。

tools/install.ps1 现在会对比 node_modules/<pkg>/lib/index.js 与源码的哈希, 不一致就删掉副本再 pnpm install,装完还会再验一次哈希。改完插件请用它重装。

2. cordis 是严格注入,且 ESM 有缓存

  • ctx.<name> 属性访问用到的服务必须inject 里声明(数组形式), 否则 apply 开头就抛 cannot get property "..." without inject,整个插件不注册。 本插件声明的是 ['tools', 'timer']
  • ctx.get(name) 不需要 inject:它只是查表,取不到返回 undefined、不会抛 (get(name, strict) 的 strict 只影响「存在但未启动」的服务)。 所以可选能力一律用 ctx.get + 判空,绝不能写进 inject —— 否则宿主没这个服务时连插件都加载不起来。本插件的 sessionController 就是可选取服务(自动拉起用,见上面「自动拉起」一节)。
  • 改完源码后 disable/enable、重装依赖都不会生效:ESM 模块按 URL 永久缓存, 只能重启 web。
  • 反过来,纯 cordis 插件(不声明 dsh.bundle)走 profile 的 cordis.patch.yml insert 行是配置 HMR、可零重启挂载;bundle 插件必须重启。

tests/selftest.mjs 里的假 ctx 复刻了这两条语义:属性访问未声明服务就抛错并计入 undeclared,而 get('未知服务') 返回 undefined,所以这两类错误在离线自测阶段 就会暴露,不用等重启。

3. 同一个 id 只能有一层:进了 bundles 就别再写 profile patch

DSH Desktop 启动时这样组 profile 组合:

  1. dsh.profile.bundles每个 bundle 各一层:读它的 package.jsondsh.bundle.patch 指向的 patch 文件;
  2. 再叠加 profile 自己的 cordis.patch.yml(又一层);
  3. 把各层 insert 行拼成 loader 行,最后统一检查 id 唯一性。

所以本包已经在 bundles 里时,profile 的 cordis.patch.yml再手写一行 - insert: { id: dsh-astrbot-gateway },同一个 id 就被插了两次,桌面端启动直接抛:

dsh-plugin-desktop: duplicate loader entry id "dsh-astrbot-gateway" in the composed profile
    at assertUniqueEntryIds (.../lib/profile-*.js)
    at prepareDesktopProfile (.../lib/main.js)

随后进恢复模式:内部执行 dsh plugin --profile desktop remove dsh-astrbot-gateway。 而 remove 要跑 pnpm,profile 里只要有一个拉不动的私有 git 依赖(例如 gal-view 连不上 GitHub),连恢复都会失败,只剩人工回滚 profile —— 桌面端起不来。这个坑真踩过。

本包是 bundle 插件package.json 里有 dsh.bundle),挂载方式就是「进 bundles」, 不需要、也不要再动 profile 的 cordis.patch.yml

重启前先离线预检(复刻上面的分层组合 + id 唯一性检查,不起 DSH 也不会崩):

node ..\..\tools\check-profile.mjs --profile desktop

退出码 0 = 无重复 id;1 = 有冲突(会打印是哪两层撞了)。install.ps1 也加了同样的 守卫:发现 profile patch 里已有同名 insert 行就直接拒绝执行。

另外:dsh plugin add/remove 会跑 pnpm。profile 里有 file: 依赖时,直接改 profile 的 package.json + 保证 node_modules\<pkg> 是最新副本,比走 dsh plugin 更省事, 也不会因为网络问题把恢复流程拖失败。

4. 不要导出 Config(除非用 schemastery)

cordis 解析插件配置走 Standard Schema 协议,实现是:

entry.plugin.Config['~standard'].validate(rawConfig)   // @deepseek-ai/cordis 的 resolveConfig

所以 Config 必须是 schemastery 的 z.object({...}) 实例,不是描述字段的普通对象。 写成普通对象时 Config['~standard']undefined,读 .validate 立刻抛:

dsh-plugin-desktop: plugin tree failed to load: failed to apply loader entry dsh-astrbot-gateway
  (dsh-astrbot-gateway): Cannot read properties of undefined (reading 'validate')
    at resolveConfig (…/@deepseek-ai/cordis/lib/index.js:957:45)

整个插件树加载失败(不是只有本插件不生效),桌面端同样进恢复模式回滚 profile —— 和坑 3 是同一套恢复流程,也一样会因为 gal-view 连不上 GitHub 而恢复失败。这个坑也真踩过。

本插件刻意零依赖(只 import node: 内置模块),而 desktop profile 的 node_modules 里没有 @deepseek-ai/schemastery(装它要走 pnpm / 联网),所以干脆不导出 Config: 配置全靠 apply 里的默认值;需要覆盖时在组合的行里写 configapply 照常读到(无校验)。 同一个 profile 里能跑的 gal-view 也是这个形态——只导出 name / inject / apply

两道回归守卫:

  • tests/selftest.mjsConfig 要么不存在,要么必须带 '~standard'.validate
  • tools/check-profile.mjs(仓库根 tools\):重启前 import 每个 bundle 的入口, 把 name / inject / apply / Config 契约一起查。

5. ctx.tools.register() 不编译任何东西——传进去的必须是编译后的原始 JSON Schema

这是最绕、踩得最久的一条。dsh-tools 有两个层级:

| 层 | 角色 | 对 parameters / output.schema 做什么 | | --- | --- | --- | | defineTool({...}) | 作者向包装器(内置工具都走它) | 把描述符 DSL / 作者值 schema 编译成原始 JSON Schema,再交给 register | | ctx.tools.register(definition) | 注册表本身 | 什么都不编译:只要求 output.render 是函数、assertSupportedJsonSchema(output.schema),然后把 definition 原样收下 |

也就是说 register 认为你给的就是编译后的成品: definition.parameters 会被原样发给模型output.schema 会被当成已经是原始 JSON Schema 来断言。 本插件刻意零依赖(拿不到 @deepseek-ai/dsh-toolsdefineTool),所以直接手写编译后的形态。 写错就会连踩两个坑,两个都真踩过:

坑 5a —— 把作者 spec 直接当原始 schema(逐字段 required: true):

dsh-plugin-desktop: plugin tree failed to load: failed to apply loader entry dsh-astrbot-gateway
  (dsh-astrbot-gateway): unsupported JSON schema: schema.properties.summary.properties.total.required
  is not supported on type "number"; schema.properties.tasks.required is not supported on type "array"; …
    at assertSupportedJsonSchema (…/@deepseek-ai/dsh-tools/lib/index.js)

整个插件树加载失败,桌面端回滚 profile。

坑 5b —— parameters 用了描述符 DSL(根节点没有 type):

本轮运行失败 Invalid schema for function 'bridge_claim':
  schema must be a JSON Schema of 'type: "object"', got 'type: null'.

→ 插件挂得上、日志一切正常(上行自检: OK轮询已启动),但每一轮对话都失败,而且同样触发回滚。这个坑最难查,因为它伪装成"运行期偶发"。

所以本项目里的规矩是:

  • parameters 手写成 { type:'object', properties:{...}, required:[...] } (没有必填参数时省略 required 键,与 defineTool 产物一致);
  • output.schema 手写成 object 根、落在受支持子集内的原始 schema;
  • 两者的写法必须逐字节等于 defineTool 会编译出来的东西 (cache/ 下跑一次保真度对比即可确认)。

受支持子集(取自 dsh-tools 源码):关键字只有 type / oneOf / properties / required / additionalProperties / items / enum / const 加注解(descriptiontitle 等),并且:

  • properties / required / additionalProperties 只能挂在 type: "object" 上;
  • items 只能挂在 type: "array" 上;enum / const 只能挂在标量上;
  • type 必须是单个字符串(不支持 type 数组);同一节点不能同时写 typeoneOf
  • additionalProperties 必须是 boolean;required 必须是字符串数组且只能挂在 object 上;
  • parameters 必须是 object 根(否则模型 API 直接拒收)。

两道离线门禁(都不用重启):

node tests\selftest.mjs                     # 自测:内置同规则校验 parameters(object 根) + output.schema
node ..\..\tools\validate-tool-schemas.mjs  # 真门禁:抽出 app.asar 里的真 dsh-tools 来断言

validate-tool-schemas.mjs 不是"复刻规则",而是把 asar 里的 node_modules/@deepseek-ai 抽到 cache/_dsh-asar/直接调用官方函数assertObjectJsonSchema(parameters) + assertSupportedJsonSchema(output.schema),正是 register 与模型 API 的要求), 所以结论和真启动一致。tools\install.ps1 -Apply 也会先跑这两道门禁,不通过就拒绝写 profile。

6. 工具返回值要过两道输出校验(都是活测才暴露的)

dsh-tools 对每个工具的返回值做两件事,任何一条不过,整个工具调用就报 tool "<name>" returned invalid output: …

6a. 必须是 lossless JSONisJsonValue)——带 undefined 的对象不是合法 JSON 值 (JSON.stringify 会静默丢掉它):

tool "bridge_inbox" returned invalid output: value is not lossless JSON

真实触发条件:任务 JSON 里没有 ref 字段时 t.refundefined队列为空时恰好没有 undefined,所以只在「真有任务」时才现形。 修法是返回前统一过一遍 jsonSafe()(递归丢 undefined)。

6b. 必须严格符合自己的 output.schema(含 additionalProperties: false):

tool "bridge_complete" returned invalid output:
  "value.uplink.result" is not a declared property (additionalProperties: false)

真实触发条件:sendUplink 成功时返回 { ok, status, result },而 schema 里 uplink 只声明了 ok / status / message。这是只有成功路径才有的字段,失败路径测不出来。 修法是把返回值归一化成 schema 里声明过的形状。

6a / 6b 都发生在 execute() 之后:报错时副作用(写 outbox、回写 inbox、 上行回报 QQ)其实都已经做完了,只是这一轮的「工具结果」被判非法。

这两条现在由 tools\validate-tool-schemas.mjs行为门禁覆盖:它会在沙箱里 (stub 掉 fetch,绝不发真请求)真跑一遍 bridge_inbox → bridge_claim → bridge_complete, 再用官方的 validateJsonSchemaValue 校验每个返回值。

7. 自动拉起的三处宿主契约(都真踩过)

自动拉起用的宿主 API(照抄 @agents-anywhere/dsh-bridge-next):

await sessionController.create({ cwd })                        // 1) cwd 决定会话归到哪个工作区
await workspaceRegistry.create(cwd).attachSession(sessionId)  // 2) 挂进该工作区组别
await sessionController.prompt({ ... }, signal)                // 3) signal 是必需的第二参数

7a. prompt(request, signal)signal 是必需的

同一个类里两个方法签名不一样,prompt 第一行就是 signal.throwIfAborted()create 只收 1 个参数):

create(request)           // 只收 1 个参数
prompt(request, signal) { // ← 第二参数必需
  signal.throwIfAborted();
  return this.commands.prompt(request);
}

漏传会得到:

自动拉起失败(Cannot read properties of undefined (reading 'throwIfAborted')),稍后重试。

宿主不关心"谁取消",但会先检查它存在,所以传 new AbortController().signal 即可。

7b. 不传 cwd 会拿到宿主的 process.cwd()(= DSH 安装目录)

那个路径不属于任何工作区,会话就落到侧边栏的**「未分组」**里(能看到,但不在你的项目组下)。

7c. 光有 cwd 还不够,必须 attachSession()

GUI 侧边栏读的是工作区的 sessionIds,而 attachSession 会校验会话 header 的 cwd 必须等于工作区路径 —— 所以 createcwdattachSession 的工作区必须一致。

本项目的行为(用户定的规矩):

  • 能挂上工作区就带组别dispatchCwd 默认取桥接目录的上一级(<项目目录>), 那是个真实存在、你也在用的工作区;
  • 挂不上就落「未分组」,但绝不因此失败:目录不存在 → 不传 cwdattachSession 抛错 → 只记日志。会话照样建、照样干活,只是显示在未分组里。

tests/selftest.mjs 里的假 sessionController / workspaceRegistry 刻意与真 API 同签名prompt(request, signal) 第一行 throwIfAborted()attachSession 记录调用), 所以漏 signal、漏 cwd、漏挂载这三类问题都会在离线自测里暴露。

自测

node tests/selftest.mjs            # 默认:不发消息,只报告「本来会发什么」
node tests/selftest.mjs --live     # 真发到用户的 QQ(会打扰人,慎用)

以假的 cordis ctx 直接驱动插件本体,覆盖:读访问文件、上行 ping、轮询发现、去重通知、 认领(含拒绝路径穿越)、完成、outbox 结构与 v2 字段清单、状态快照、严格注入合规、 全程零上行(v2 守门断言)、以及救火开关 uplinkMode:'on' 仍可用。 使用沙箱目录 cache/_selftest,不碰真实的 inbox/outbox

默认模式会拦截 /send(ping 仍放行),只把请求内容记下来做断言,所以反复跑也不会 打扰用户——这一点是踩过坑之后改的:早先版本每跑一轮就真发两条到 QQ,连跑几轮把用户 的消息列表刷了一串测试消息。v2 之后更强的保证是:默认一次 /send 都不该发生, 自测里那条「全程零上行」就是拦这件事的;真发生会打印每一条越权上行的内容。

已知边界

  • 自动拉起会新建一个会话(不是往当前会话注入消息——dsh 没有那种公开接口)。 所以每条桥接指令对应会话列表里的一个新会话,你能在 GUI 里看到它跑了什么。 工作目录/预设用 dispatchCwd / dispatchPreset 控制:本机 dispatchCwd 已指向 <项目目录>dispatchPreset 已设成 standard(见上面「桥接会话跑哪种模式」)。
  • 被拉起的会话仍受宿主的审批与权限策略约束。如果它的动作触发了审批,会停在 等审批那一步——这不是桥接的问题,按需在宿主侧调权限预设即可。
  • 新会话需要有可用的模型路由:宿主没给这个会话配到能用的 provider 时, prompt 会抛 session/model-unavailable,此时插件降级成「只通知」,通知里会写明原因。
  • bridge_complete 里「写 outbox / 回写 inbox」与「交付」是分开报告的:交付失败 不会让「结果已落盘」变成失败,返回值里 delivery.ok 会如实为 false。 v1 的 uplink 字段仍在返回值里(兼容老读者),它现在反映的是交付结果。
  • token 只在本机使用,不下发到任何外部端点;插件只连 bridge_access.json 里的 127.0.0.1 地址。
  • 改完本插件必须重启 dsh(bundle 插件 + ESM 缓存,见上面第 2 条)。 重启前跑的仍是旧代码 —— 包括「还在直发用户」的 v1 行为。
  • 改 profile 补丁层(cordis.patch.yml 里的配置覆盖)也要重启:实测 2026-09-12 改完 $DSH_HOME/profiles/desktop/cordis.patch.ymldispatchPreset: standard)后, 34 秒后自动拉起的会话仍是 "agentPreset":"teyvat-hoi4"bridge_status.json 也没变成 新结构 —— 即补丁没有被热重载。原因在宿主侧:热重载那条路 watchUserPatches() 要求 ctx.get('hmr') 存在,而 base 里 hmr 那一行是 disabled: truedsh-app-boot 的 注释说 CLI launcher 有自己的 watch-only 回退,Desktop 这条路径没有)。所以 desktop profile 上的 patchReload: live 实际等于「下次启动才生效」。 判定方法:重启后看 cache/bridge_status.json 有没有新的 dispatch 块、 dispatch.preset 是不是 standard —— 有就是新代码+新配置都生效了。