dsh-interconnect 0.11.23 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,26 +3,30 @@
3
3
  跨实例消息互通与事件通知插件,用于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH)。
4
4
  让一个 DSH 实例能向同一个实例、另一台机器、或另一台机器上的别的 DSH 实例发送消息、探测活性,并在实例之间双向推送事件。
5
5
 
6
+ > **项目状态(2026-09-16)**:本仓库是该插件的**树外(out-of-tree)主源**。并入 DSH monorepo 的计划(PR #3243)已按「保持树外」的评审意见关闭;插件继续经 npm 分发(`dsh-interconnect`),并由团队内部的插件集合仓以 git submodule 引用本仓库。
7
+
6
8
  ## 包含三个插件
7
9
 
8
10
  **`interconnect`** —— host 服务(`ctx.interconnect`):
9
11
 
10
12
  - 全走持久 WebSocket 链接:跨实例、跨机器投递消息、枚举 live session、探测活性(`send`/`reply`/`ping`/`list` 经 `/interconnect/link` 的 `msg`/`query` 帧)
11
13
  - `/interconnect/link` WebSocket 端点:双向实时事件推流,含心跳与指数退避重连;也承载
12
- `send`/`reply` 消息(`msg`/`msg-result` 帧,WS 优先 + HTTP 回退)
13
- - 事件 fan-out(HTTP + WebSocket),入站事件以 `interconnect/event` 发出
14
+ `send`/`reply` 消息(`msg`/`msg-result` 帧)。0.9 起没有 HTTP 端点,也没有 HTTP 回退
15
+ - 事件 fan-out 只在 WebSocket 链路上;入站事件以 `interconnect/event` 发出
14
16
  - 共享密钥鉴权(`DSH_INTERCONNECT_TOKEN`,bearer,fail-closed,timing-safe 比较)
15
17
 
16
18
  **`tool-interconnect`** —— 模型可见工具:
17
19
 
18
- - `interconnect_send`:向对端实例的指定 session 投递消息;可选 `delivery` 选投递模式、`resume` 唤醒离线 session
20
+ - `interconnect_peers`:列出本实例**已配置**的对端及其拨号链路状态(`up`/`down`),用于在不知道
21
+ peer id 时先看有哪些可用目标,也是区分「没配这个 peer」与「配了但链路不通」的唯一入口
22
+ - `interconnect_send`:向对端实例的指定 session 投递消息;默认唤醒没有 live agent 的持久 session(`resume: false` 关闭),可选 `delivery` 选投递模式
19
23
  - `interconnect_list`:列出对端实例的 live session(id + 标题 + 状态),用于在不预先知道 session id 时寻址
20
24
  - `interconnect_ping`:探测对端实例活性与身份
21
25
  - `interconnect_reply`:向记录过的发送方回传消息,只需文本;回信 session 是本 agent 自己的,无需再次寻址
22
26
 
23
27
  **`skill-interconnect`** —— 配套 skill:
24
28
 
25
- - 向模型注册 `dsh-interconnect` skill,说明 `list`/`ping`/`send`/`reply` 的完整用法、
29
+ - 向模型注册 `dsh-interconnect` skill,说明 `peers`/`list`/`ping`/`send`/`reply` 的完整用法、
26
30
  投递模式、`resume` 唤醒语义与失败处理。
27
31
  - 明确告知模型:`interconnect_send` 会自动注入发送方的 `instanceId` 和 `sessionId`,
28
32
  接收方凭记录的 sender 即可用 `interconnect_reply` 回信,不需要手工传地址。
@@ -37,9 +41,31 @@
37
41
  - `interconnect_send(instanceId="peer", sessionId=..., text=...)`
38
42
  - `interconnect_ping(instanceId="peer")`
39
43
  - `interconnect_list(instanceId="peer")`
44
+ - `interconnect_peers()`(不接受参数;返回本实例配置的全部对端及其链路状态)
40
45
  - `interconnect_reply(text=...)`(只需文本;回信 session 是本 agent 自己的,目标从记录的 sender 解析)
41
46
 
42
- `instanceId` 是 `interconnect` 行 `peers` 映射里的键;真正用来拨号的 origin 由该映射的值给出(例如隧道端点 `http://127.0.0.1:13080`),**instanceId 本身从不出现在线上**,也不参与路由——origin 才是唯一的拨号依据。到**未配置 / 未联通**的对端 `send`/`ping`/`list` 返回 `unreachable`(无 HTTP 回退)。
47
+ `instanceId` 是 `interconnect` 行 `peers` 映射里的键;真正用来拨号的 origin 由该映射的值给出(例如隧道端点 `http://127.0.0.1:13080`),**instanceId 本身从不出现在线上**,也不参与路由——origin 才是唯一的拨号依据。
48
+
49
+ 对端没配在这里时,`send`/`reply` 返回 `peer-not-configured`,`ping`/`list` 则在返回
50
+ `reachable: false` 的同时带上 `configured: false`——这一支**重试永远不会成功**,只能去补
51
+ `peers` 映射。配了但链路不通是另一支:链路断或两端 token 不一致时返回 `unreachable`
52
+ (`configured: true`),重连成功后就可能通(无 HTTP 回退)。
53
+
54
+ `interconnect_peers` 就是这两支的判据来源,输出每行一个对端:
55
+
56
+ ```
57
+ peer-a http://127.0.0.1:13080 [up]
58
+ peer-b http://127.0.0.1:13081 [down]
59
+ ```
60
+
61
+ `up`/`down` 是**拨号链路**当前是否可用;`down` 不代表对端没开——重连按指数退避自己跑,改完
62
+ 网络或对端重启后无需重启本实例,链路会自己回来。
63
+
64
+ 增删或改点 `peers` 同样不需要重启:profile patch 层的改动会重建本插件的 fiber,构造器按新映射
65
+ 重新建链。这条已实测(双向对照):patch 层加入 `peers` 后出现拨号、删掉后拨号消失;改
66
+ `instanceId` 后新链接自报的就是新名字。生效的前提是这一层被真正应用——由 `dsh` 启动、组合里带
67
+ `hmr` 行的实例会自己应用(见「安装」一节),其它启动方式只能重启。构造器只在激活时读一次该
68
+ 映射,所以改了文件但没被应用时,行为仍是旧的。
43
69
 
44
70
  `interconnect_list` 返回对端**当前 live** 的 session,每一行的 `sessionId` 在调用时刻都是合法的投递目标:
45
71
 
@@ -115,15 +141,20 @@ interconnect_reply(text="reply")
115
141
 
116
142
  | `reason` | 含义 | 应对 |
117
143
  |---|---|---|
118
- | `session-not-live` | 对端**答复了**,但那个 session 没有运行中的 agent | 重试同一个 id 无用;用 `interconnect_list` 换目标,或带 `resume` |
119
- | `unreachable` | 没拿到可用答复(传输失败,或鉴权被拒) | 目标 session 可能完好,重试可能成功 |
120
- | `resume-refused` | 请求了唤醒,但对端不允许(`allowResume: false`) | 再带 `resume` 也没用 |
121
- | `resume-failed` | 允许唤醒且尝试了,但没得到 live agent(无此持久化 session,或被别的 owner 持有) | 换目标 |
144
+ | `session-not-live` | 对端**答复了**,但那个 session 没有 live agent,且唤醒被关闭(`resume: false`)或对端无法唤醒 | 不要改投其它 session;报告失败,或移除 `resume: false` 后重试 |
145
+ | `unreachable` | 配了该对端,但没拿到可用答复(链路断,或鉴权被拒) | 目标 session 可能完好,链路重连后重试可能成功 |
146
+ | `peer-not-configured` | 本实例的 `peers` 里没有这个 `instanceId`,链路根本不存在 | 重试永远不会成功;补 `peers` 映射(patch 层改完即生效,见「寻址」一节),或改用 `interconnect_peers` 列出的 id |
147
+ | `resume-refused` | 请求了唤醒,但对端不允许(`allowResume: false`) | 再请求也没用;不要改投其它 session,报告失败 |
148
+ | `resume-failed` | 允许唤醒且尝试了,但没得到 live agent(无此持久化 session,或被别的 owner 持有) | 不要改投其它 session;报告失败 |
122
149
  | `session-owned-by-subagent` | 该 session 属于 subagent 路由,投递权在它的父 agent | 通过父 agent 触达,别直接投 |
150
+ | `message-too-large` | 编好的帧超过链路帧上限(1 MiB),什么都没写、链路也没断 | 缩短文本,重发原文没用 |
123
151
  | `no-sender-known` | `reply` 指向的本地 session 从未记录过发送方(它没收到过带 `sender` 的消息,或对端版本过旧没带 `sender`) | 先用 `interconnect_send` 主动建立联系 |
124
152
 
125
153
  `reason` 恰好在 `delivered` 为 false 时出现。
126
154
 
155
+ 后两种加上 `peer-not-configured` 是**本机产生**的原因,不会从线缆上来:`msgResultSchema` 只接受
156
+ 接收方能回答的原因,所以这三条只可能出自本实例自己的服务(对端无法借它们伪造一个本地判定)。
157
+
127
158
  只有真的「尝试唤醒但失败」才是 `resume-failed`。没装 api-proxy 的部署里,`agent` lookup
128
159
  退化成一次 registry 查询、根本没有唤醒能力,这时报 `session-not-live`——否则会让调用方去重试
129
160
  一个永远不可能成功的操作。
@@ -141,18 +172,22 @@ Host 在 0.6 之后把这个谓词从 `@deepseek-ai/dsh-api-remotes` 移走,
141
172
  已实测:起一个真实 subagent 后,`interconnect_list` 不包含它;直接 `send` 到它的 id 返回
142
173
  `session-owned-by-subagent`,消息**没有**进入 inbox。
143
174
 
144
- ### 唤醒离线 session(`resume`,默认关)
175
+ ### 唤醒离线 session(`resume`,默认开)
145
176
 
146
- `SendPayload.resume: true` 让对端唤醒一个已持久化但没有运行 agent 的 session。
177
+ 投递目标是一个「已持久化但没有运行 agent」的 session 时默认会唤醒它:不带 `resume` 时
178
+ `interconnect_send` 与 `interconnect_reply` 都按 `resume: true` 处理;传 `resume: false`
179
+ 才保持原样并返回 `session-not-live`。
147
180
 
148
- **默认关闭是有意的。** 实测确认:消息投递到 session 后会触发一次**完整的 agent 回合**——
149
- `wakeDriver()` → `kick()` → `turn()` → `llm.stream()`,即一次计费的模型调用,且 assembly
150
- 里带着该 session 的完整工具集。在一个用户没打开、看不到、也无法中断的会话里启动这些,和
151
- 「推一下已经开着的会话」不是一个量级,所以必须由发送方显式请求。
181
+ **默认开启是 2026-10-23 的决定。** 实测确认:消息投递到 session 后会触发一次**完整的 agent
182
+ 回合**——`wakeDriver()` → `kick()` → `turn()` → `llm.stream()`,即一次计费的模型调用,且
183
+ assembly 里带着该 session 的完整工具集。默认关闭时,最常见的结果不是省下这一次调用,而是
184
+ 投递失败后发送方改投另一个正在运行的 session:原会话永远收不到,别的会话收到一段不属于它的
185
+ 内容。目标通常是用户关心的那个会话,只是它此刻没在跑;所以默认唤醒它,把「不唤醒」留给明确
186
+ 这样要求的调用方。
152
187
 
153
- 两侧都有控制权:
188
+ 两侧的控制权不变:
154
189
 
155
- - **发送方**按消息决定 `resume`(默认不唤醒)
190
+ - **发送方**可逐条用 `resume: false` 关闭唤醒
156
191
  - **接收方**用 `Config.allowResume`(默认 `true`)一票否决——因为花钱和跑工具的是它那台机器;
157
192
  拒绝时在跑 lookup 之前就短路,回 `resume-refused`
158
193
 
@@ -168,15 +203,19 @@ context** 拥有,实测确认插件 fiber 被 dispose 时会把 resume 出来
168
203
  唤醒**只把消息放进 inbox**,是否真的开始处理取决于 `delivery`:
169
204
 
170
205
  ```text
171
- # 唤醒并让对方实际处理(会起一个计费回合)
172
- interconnect_send(instanceId="peer", sessionId, text, resume=true, delivery="followup")
206
+ # 默认:唤醒并让对方实际处理(会起一个计费回合)
207
+ interconnect_send(instanceId="peer", sessionId, text, delivery="followup")
173
208
 
174
209
  # 唤醒但不起回合:只写入上下文,等对方下次被唤醒时一起读
175
- interconnect_send(instanceId="peer", sessionId, text, resume=true, delivery="inject")
210
+ interconnect_send(instanceId="peer", sessionId, text, delivery="inject")
211
+
212
+ # 不唤醒:目标保持原样,返回 session-not-live
213
+ interconnect_send(instanceId="peer", sessionId, text, resume=false)
176
214
  ```
177
215
 
178
- 已实测:`resume=true` + `inject` 之后目标从非 live 变 live(`interconnect_list` 计数 +1),
179
- 且该 session 日志里只多一条 `agent/inbox/spliced`、**后面没有 `turn/start`**。
216
+ 已实测:唤醒(默认开启)+ `delivery="inject"` 之后目标从非 live 变 live
217
+ (`interconnect_list` 计数 +1),且该 session 日志里只多一条 `agent/inbox/spliced`、
218
+ **后面没有 `turn/start`**。
180
219
 
181
220
  **已知限制**:磁盘格式过旧的 session 无法唤醒,返回 `resume-failed`。这不是本插件的限制——
182
221
  Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedError`,同样失败。
@@ -203,7 +242,7 @@ Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedEr
203
242
  |---|---|---|
204
243
  | `instanceId` | `'dsh'` | 本实例自报的 id,出现在 `ping`/`send`/`list` 的回包里。也作为「对端如何寻址到我」的身份 |
205
244
  | `requestTimeoutMs` | `10000` | 出站请求超时,上限 60000 |
206
- | `peers` | `{}` | **对端路由映射**:`{ [对端 instanceId]: 我拨向它的 origin }`,例如隧道端点 `http://127.0.0.1:13080`。激活时对每个对端自动建持久 WS 链;`send`/`reply`/`ping`/`list` 都按 instanceId 走对应链接 |
245
+ | `peers` | `{}` | **对端路由映射**:`{ [对端 instanceId]: 我拨向它的 origin }`,例如隧道端点 `http://127.0.0.1:13080`。激活时对每个对端自动建持久 WS 链;`send`/`reply`/`ping`/`list` 都按 instanceId 走对应链接。改这一行不需要重启:profile patch 层的改动会重建 fiber 并按新映射建链 |
207
246
  | `delivery` | `'followup'` | 入站消息未带 `delivery` 时的默认投递模式 |
208
247
  | `allowResume` | `true` | 是否允许发送方用 `resume` 唤醒本机的离线 session |
209
248
 
@@ -237,9 +276,107 @@ dsh plugin --profile <name> add file:/path/to/dsh-interconnect
237
276
 
238
277
  registry 上的 tarball 自带 `lib/*.js` 与 `lib/types/**/*.d.ts`,安装时不跑构建。
239
278
 
240
- `dsh plugin add` 会把仓库识别为 bundle 并追加进 profile 的 `dsh.profile.bundles`。重启
241
- web 服务使 host 侧生效。两端实例的 `.credentials.yaml`(或等价凭据源)设置相同的
242
- `DSH_INTERCONNECT_TOKEN` 作为共享密钥。
279
+ `dsh plugin add` 会把仓库识别为 bundle 并追加进 profile 的 `dsh.profile.bundles`。这次改动
280
+ 落在**组合层**:`@deepseek-ai/dsh-base` 插入的 `hmr` 行监听 profile 的 `package.json`,
281
+ manifest 一变就重读 `dsh.profile.bundles` 并重算 patch 层,所以新增的行会在几秒内插进
282
+ 运行中的树并激活,**不需要重启**。已实测:安装前 `GET /interconnect/link` 返回 404,安装后
283
+ 6 秒内同一请求返回 403(插件 fail-closed 的应答)。
284
+
285
+ 插件**代码**的更新属于另一层,必须重启该实例:hmr 的模块监听根在 base 里是空的
286
+ (`root: []`)且跳过 `node_modules`,已经导入的模块不会重载。升级已装插件、或在 `file:`
287
+ 安装下重建 `lib/`,都落在这一类。没有 `profileContext` 的启动方式(`hmr` 行被禁用)也只能靠
288
+ 重启。
289
+
290
+ `file:` 安装还有一个**文件粒度**的差异,来自 pnpm 用硬链接装本地目录依赖(实测所装副本的
291
+ link 数:`lib/**` 为 2,其余为 1):`lib/**` 是硬链接,仓库里重建后 profile 里那份跟着变,
292
+ 重启即可生效;`assets/`、`dsh.plugin.json`、`package.json`、`cordis.patch.yml` 是**安装时刻
293
+ 的副本**,改仓库不会传播。skill 正文来自 `assets/`,所以「只重建 + 重启」会得到新代码配旧
294
+ skill 正文,profile 的 `package.json` 里还可能写着很久以前的版本号。要整体刷新就用重装:
295
+
296
+ ```bash
297
+ dsh plugin --profile <name> remove dsh-interconnect
298
+ dsh plugin --profile <name> add file:/path/to/dsh-interconnect
299
+ ```
300
+
301
+ 两端实例用相同的 `DSH_INTERCONNECT_TOKEN` 作为共享密钥,任选一种来源:继承的进程环境变量、
302
+ `$DSH_HOME/.env`、或 `$DSH_HOME/.credentials.yaml` 的 `refs` 段(该 provider 由
303
+ `@deepseek-ai/dsh-base` 挂载;优先级为 继承的环境 > credentials 文档 > 调用目录的 `.env` >
304
+ `$DSH_HOME/.env`):
305
+
306
+ ```yaml
307
+ version: 1
308
+ refs:
309
+ DSH_INTERCONNECT_TOKEN: <两端相同的共享密钥>
310
+ ```
311
+
312
+ ## 两台机器最小可用
313
+
314
+ 下面用 A、B 两台机器说明最短路径:A 要主动发消息给 B,B 也要能回信。五步里前四步两端都要做,
315
+ 漏掉哪一步就会停在对应的失败原因上。
316
+
317
+ ### 1. 两端各装一次
318
+
319
+ ```bash
320
+ dsh plugin --profile <name> add dsh-interconnect
321
+ ```
322
+
323
+ 由 `dsh` 启动的实例几秒内自己热加载(见上一节),不用重启。
324
+
325
+ ### 2. 两端设同一个共享密钥
326
+
327
+ 按上一节任选一种来源,两侧的值必须逐字节相同。任一端没配 token 时端点 fail-closed,直接 403,
328
+ 另一侧只会看到链路 `down`。
329
+
330
+ ### 3. 打通网络:把 B 的端口映射到 A 的本机端口
331
+
332
+ 链接路由 `/interconnect/link` 挂在 B 的**宿主 webserver** 上,也就是 B 那个 DSH 实例监听的端口
333
+ (`dsh web` 就是 GUI 的端口)。同一台机器上就是回环地址;跨机器时先建立隧道:
334
+
335
+ ```bash
336
+ # 在 A 上执行:B 的 3080 变成 A 本机的 13080;-N 不执行远程命令
337
+ ssh -N -L 13080:127.0.0.1:3080 <B 的用户@主机>
338
+ ```
339
+
340
+ 只用 A→B 单向发消息时,这一条就够了。要让 B 也能主动发给 A,在 B 上反向再建一条(把 A 的端口
341
+ 映射成 B 本机的 13081)。
342
+
343
+ ### 4. 两端各配一遍 peer
344
+
345
+ 改 profile 的 patch 层(不是插件包内的文件;`interconnect` 行由 bundle 插入,这里按键名覆盖它的
346
+ config):
347
+
348
+ ```yaml
349
+ # A: $DSH_HOME/profiles/<name>/cordis.patch.yml
350
+ - id: interconnect
351
+ config:
352
+ instanceId: a
353
+ peers:
354
+ b: http://127.0.0.1:13080 # key = B 自报的 instanceId;value = A 拨向 B 的 origin
355
+ ```
356
+
357
+ ```yaml
358
+ # B: $DSH_HOME/profiles/<name>/cordis.patch.yml
359
+ - id: interconnect
360
+ config:
361
+ instanceId: b
362
+ peers:
363
+ a: http://127.0.0.1:13081 # 没有这一条,B 能收消息但回信会得到 peer-not-configured
364
+ ```
365
+
366
+ 改完不用重启:patch 层的改动会重建 fiber 并按新映射建链。key 建议与对端自报的 `instanceId`
367
+ 一致,否则 B 拨入的那条链路在 A 侧认不出是同一个 peer,事件会重复一份(服务会 warn 一次)。
368
+
369
+ ### 5. 按顺序验证
370
+
371
+ 1. `interconnect_peers` —— 应看到 `b` 且链路 `up`。
372
+ 2. `interconnect_ping(instanceId="b")` —— 应答里是 B 的实例 id。
373
+ 3. `interconnect_list(instanceId="b")` —— 列出 B 上 live 的 session。
374
+ 4. `interconnect_send(instanceId="b", sessionId=..., text=...)` —— 投递成功。
375
+ 5. B 侧 `interconnect_reply(text=...)` 回信,A 侧收到续文。
376
+
377
+ 停在哪一步就回查哪一步:`unreachable`(`configured: true`)回查第 2、3 步(token、隧道);
378
+ `configured: false` / `peer-not-configured` 回查第 4 步(映射里没有这个 key);链路一直 `down` 时,
379
+ 对端日志里对应的是 401/403,回查第 2 步(两侧 token 是否都有值且逐字节相同)。
243
380
 
244
381
  ## 开发
245
382
 
@@ -268,7 +405,7 @@ pnpm run build # esbuild → lib/
268
405
 
269
406
  ## 验证
270
407
 
271
- - 164/164 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧按 kind 绑定与投影、list 的行数与字节双上限及满页提示等回归用例);
408
+ - 179/179 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧按 kind 绑定与投影、list 的行数与字节双上限及满页提示、peer 状态与「未配置」分支、本机原因不得从线缆上来的负例等回归用例);
272
409
  类型检查、构建均干净。
273
410
  - 已在两台机器之间实测双向互通:消息投递、WebSocket 事件推流、以及 agent 经
274
411
  `interconnect_send` 工具反向回发,均验证通过。
@@ -5,20 +5,27 @@ another DSH session, another DSH instance, or another machine: hand off a task,
5
5
  ask a peer agent for information, notify a remote session, or reply to an
6
6
  incoming interconnect message.
7
7
 
8
- The connection to each peer is already established and authenticated. Your job
9
- is only to pick the right tool and the right target.
8
+ The connections this instance can dial are configured and authenticated already.
9
+ Your job is to pick the right peer, the right tool, and the right target.
10
10
 
11
11
  ## Tools
12
12
 
13
+ - `interconnect_peers` — list the peer instances this instance is configured to
14
+ reach, with each peer's dialed link state. Call this first when you do not
15
+ already know which peer ids exist here: every listed `instanceId` is a valid
16
+ `instanceId` argument for the tools below. A peer shown with link `down` is
17
+ configured but currently cannot carry frames, so it answers nothing until it
18
+ reconnects.
13
19
  - `interconnect_list` — discover which sessions are live on a peer instance.
14
20
  Every returned `sessionId` is a valid `interconnect_send` target at that
15
21
  moment. Use this first when you do not already know the target session id.
16
22
  - `interconnect_ping` — probe whether a peer instance is reachable and learn
17
23
  its self-reported instance id.
18
- - `interconnect_send` — deliver one text message to a live session on a peer
19
- instance. Pass `instanceId`, `sessionId`, and `text`. The sending
20
- instance/session identity is attached automatically by the tool, so you must
21
- NOT pass a sender parameter.
24
+ - `interconnect_send` — deliver one text message to a session on a peer
25
+ instance. Pass `instanceId`, `sessionId`, and `text`. A live session receives
26
+ directly; a persisted session with no live agent is woken by default. The
27
+ sending instance/session identity is attached automatically by the tool, so
28
+ you must NOT pass a sender parameter.
22
29
  - `interconnect_reply` — send a message back to the peer that this session last
23
30
  received an interconnect message from. Pass only the reply text. The replying
24
31
  session is this agent's own session, and the remote target is recalled
@@ -39,12 +46,29 @@ addresses.
39
46
 
40
47
  ## Recommended workflow
41
48
 
42
- 1. If you know the peer instance but not the target session, call
49
+ 1. If you do not already know the peer ids available here, call
50
+ `interconnect_peers` and pick one whose link is `up`.
51
+ 2. If you know the peer instance but not the target session, call
43
52
  `interconnect_list(instanceId=...)` and choose a live session.
44
- 2. Call `interconnect_send(instanceId=..., sessionId=..., text=...)`.
45
- 3. When a message arrives on your side and you need to respond, call
53
+ 3. Call `interconnect_send(instanceId=..., sessionId=..., text=...)`. If you
54
+ already know the target session id, send to it directly: a persisted session
55
+ with no live agent is woken by default.
56
+ 4. When a message arrives on your side and you need to respond, call
46
57
  `interconnect_reply(text=...)`. The replying session is this agent's own
47
- session, and the recorded sender is used as the destination.
58
+ session, and the recorded sender is used as the destination; that sender is
59
+ woken by default when it is no longer live.
60
+
61
+ ## Addressing rules
62
+
63
+ - Deliver a message only to the session it is meant for. When delivery fails
64
+ because that session is not live and cannot be woken, report the failure; do
65
+ not forward the same text to a different session, and do not pick another
66
+ session that merely looks related. Delivering to the wrong conversation is
67
+ worse than not delivering: the message is read by the wrong owner, and the
68
+ intended recipient never sees it.
69
+ - Prefer the session id you were given or that the peer sent from. Use
70
+ `interconnect_list` to discover targets only when the target is not already
71
+ known, and do not treat similar titles as the same conversation.
48
72
 
49
73
  ## Delivery modes
50
74
 
@@ -61,23 +85,34 @@ Omit `delivery` to use the receiver's configured default.
61
85
 
62
86
  ## Waking offline sessions
63
87
 
64
- `resume` defaults to off. Setting `resume: true` asks the receiver to wake a
65
- persisted session with no live agent, which starts a real billed agent turn with
66
- that session's full toolset. Only use it when the specific session must be
67
- reached and the receiver allows it. Prefer `interconnect_list` and an
68
- already-live target when possible.
88
+ `resume` defaults to on. Unless `resume: false` is passed, delivering to a
89
+ persisted session with no live agent wakes it, which starts a real billed agent
90
+ turn with that session's full toolset. Set `resume: false` to leave such a
91
+ session asleep and get `session-not-live` instead; the receiver can still refuse
92
+ every wake with `allowResume: false` (reason `resume-refused`).
93
+
94
+ Waking is the default because the target of an interconnect message is usually a
95
+ conversation that matters but is not currently running; a reply that cannot wake
96
+ its sender never arrives, and a send that cannot wake an idle session is
97
+ delivered to whatever session happens to be active instead.
69
98
 
70
99
  ## Failure reasons
71
100
 
72
101
  When a send/reply reports `delivered: false`, read `reason`:
73
102
 
74
103
  - `session-not-live` — the receiver answered, but that session has no live
75
- agent. Use `interconnect_list` to choose another target, or consider
76
- `resume`.
77
- - `unreachable` — no usable answer arrived; the target may still be fine, so a
78
- retry can succeed.
79
- - `resume-refused` / `resume-failed` — waking is not allowed or did not work;
80
- choose a live target instead.
104
+ agent, and waking was disabled (`resume: false`) or is impossible on that
105
+ receiver (no resuming agent lookup). Do not deliver the same message to a
106
+ different session; report the failure, or retry with waking enabled.
107
+ - `unreachable` — no usable answer arrived from a peer that IS configured; its
108
+ link may be down, or the two instances may disagree on the shared token, so a
109
+ later retry can succeed.
110
+ - `peer-not-configured` — this instance configures no peer under that
111
+ `instanceId`, so no link exists and none can be dialed. Retrying cannot help:
112
+ report the id you used and let the user add it to the peers mapping, or pick an
113
+ id from `interconnect_peers`.
114
+ - `resume-refused` / `resume-failed` — waking is not allowed or did not work.
115
+ Do not reroute the message to a different session; report the failure.
81
116
  - `session-owned-by-subagent` — the session belongs to a subagent and its
82
117
  parent owns delivery; reach it through the parent.
83
118
  - `message-too-large` — the message is larger than the peer link can carry
@@ -90,9 +125,10 @@ When a send/reply reports `delivered: false`, read `reason`:
90
125
  ## Rules
91
126
 
92
127
  - Do not fabricate an `instanceId`, `sessionId`, or sender identity. A peer's
93
- `instanceId` comes from the task you were given or from this instance's
94
- configured peers; a `sessionId` comes from `interconnect_list` on that peer;
95
- the sender is carried automatically by the message you received.
128
+ `instanceId` comes from the task you were given or from `interconnect_peers`,
129
+ which lists every id this instance can actually reach; a `sessionId` comes from
130
+ `interconnect_list` on that peer; the sender is carried automatically by the
131
+ message you received.
96
132
  - Do not ask the user for a sender address to reply; use `interconnect_reply`.
97
133
  - Treat interconnect messages as authenticated but sender-reported: identity is
98
134
  for reply attribution, not for routing or authorization.
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-interconnect",
3
3
  "description": "Cross-instance message/event handoff for DeepSeek Harness (DSH): WebSocket link, model-facing tools, and companion skill.",
4
- "version": "0.11.23",
4
+ "version": "0.13.0",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
5
5
  import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
6
6
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
7
7
 
8
- // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
8
+ // ../dsh-wt-ic-merge/vendor/cosmokit/src/misc.ts
9
9
  function isNullable(value) {
10
10
  return value === null || value === void 0;
11
11
  }
@@ -27,7 +27,7 @@ function pick(source, keys, forced) {
27
27
  return result;
28
28
  }
29
29
 
30
- // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/types.ts
30
+ // ../dsh-wt-ic-merge/vendor/cosmokit/src/types.ts
31
31
  function is(type, value) {
32
32
  if (arguments.length === 1) return (value2) => is(type, value2);
33
33
  return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
@@ -136,7 +136,7 @@ function deepEqual(a, b, strict) {
136
136
  }) ?? Object.keys({ ...a, ...b }).every((key) => deepEqual(a[key], b[key], strict));
137
137
  }
138
138
 
139
- // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/time.ts
139
+ // ../dsh-wt-ic-merge/vendor/cosmokit/src/time.ts
140
140
  var Time;
141
141
  ((Time2) => {
142
142
  Time2.millisecond = 1;
@@ -216,7 +216,7 @@ var Time;
216
216
  Time2.template = template;
217
217
  })(Time || (Time = {}));
218
218
 
219
- // ../.dsh/source/staging-20260807T143236Z/vendor/schemastery/lib/index.mjs
219
+ // ../dsh-wt-ic-merge/vendor/schemastery/lib/index.mjs
220
220
  var kSchema = Symbol.for("schemastery");
221
221
  var kValidationError = Symbol.for("ValidationError");
222
222
  globalThis.__schemastery_index__ ??= 0;
@@ -831,7 +831,6 @@ function assertNever(value) {
831
831
  throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
832
832
  }
833
833
  var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
834
- var PLUGIN_SOURCE = "dsh-interconnect";
835
834
  var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
835
  var DEFAULT_INSTANCE_ID = "dsh";
837
836
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
@@ -1109,7 +1108,7 @@ var InterconnectService = class extends Service {
1109
1108
  * Returns the peer identity when reachable, or undefined when the link is
1110
1109
  * not up or no answer arrives.
1111
1110
  * @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
1112
- * @returns the peer's identity, or undefined when the link is not up or no answer arrives.
1111
+ * @returns the peer's identity, or undefined when there is no usable answer.
1113
1112
  */
1114
1113
  async ping(instanceId) {
1115
1114
  return this.queryRequest(instanceId, { kind: "ping" });
@@ -1123,16 +1122,33 @@ var InterconnectService = class extends Service {
1123
1122
  async list(instanceId) {
1124
1123
  return this.queryRequest(instanceId, { kind: "list" });
1125
1124
  }
1125
+ /**
1126
+ * The peers this instance configures, each with the current state of the link
1127
+ * it dials, in configuration order. This is how a caller tells an id this
1128
+ * instance cannot reach at all (`peer-not-configured`) from one whose link is
1129
+ * merely down (`unreachable`), which the bare `undefined` of `ping`/`list`
1130
+ * cannot express. A configured peer whose link is `down` is still a valid
1131
+ * target: its reconnect runs on its own backoff, so it may answer later.
1132
+ * @returns one row per configured peer.
1133
+ */
1134
+ peers() {
1135
+ return [...this.linkStates].map(([instanceId, state]) => ({
1136
+ instanceId,
1137
+ origin: state.peer,
1138
+ link: state.writable() ? "up" : "down"
1139
+ }));
1140
+ }
1126
1141
  /**
1127
1142
  * Send a `msg` frame over the live link to a peer and resolve with its
1128
- * correlated `msg-result`. Undefined when the peer has no live link here
1129
- * (not configured, or the link is down) — sends can only address configured+
1130
- * connected peers by design — and a `message-too-large` result when the
1131
- * encoded frame would not fit the link.
1143
+ * correlated `msg-result`. A `peer-not-configured` result when this instance
1144
+ * configures no route for that id, undefined when the route exists but its
1145
+ * link is down, and a `message-too-large` result when the encoded frame would
1146
+ * not fit the link.
1132
1147
  */
1133
1148
  async msgRequest(instanceId, kind, payload) {
1134
1149
  const state = this.linkStates.get(instanceId);
1135
- if (state === void 0 || !state.writable()) return void 0;
1150
+ if (state === void 0) return { delivered: false, instance: instanceId, reason: "peer-not-configured" };
1151
+ if (!state.writable()) return void 0;
1136
1152
  const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1137
1153
  const frame = {
1138
1154
  type: "msg",
@@ -1440,8 +1456,7 @@ var InterconnectService = class extends Service {
1440
1456
  })();
1441
1457
  const message = createUserMessage({
1442
1458
  source: {
1443
- kind: "plugin",
1444
- plugin: PLUGIN_SOURCE,
1459
+ kind: "plugin:dsh-interconnect",
1445
1460
  form: "notice",
1446
1461
  summary: boundContextSummary(
1447
1462
  sender === void 0 ? `interconnect handoff delivered on instance ${this.instanceId}` : `interconnect handoff from ${sender.instanceId} (session ${sender.sessionId}) delivered on instance ${this.instanceId}`
@@ -5,7 +5,7 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
5
5
  import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
6
6
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
7
7
 
8
- // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
8
+ // ../dsh-wt-ic-merge/vendor/cosmokit/src/misc.ts
9
9
  function isNullable(value) {
10
10
  return value === null || value === void 0;
11
11
  }
@@ -27,7 +27,7 @@ function pick(source, keys, forced) {
27
27
  return result;
28
28
  }
29
29
 
30
- // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/types.ts
30
+ // ../dsh-wt-ic-merge/vendor/cosmokit/src/types.ts
31
31
  function is(type, value) {
32
32
  if (arguments.length === 1) return (value2) => is(type, value2);
33
33
  return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
@@ -136,7 +136,7 @@ function deepEqual(a, b, strict) {
136
136
  }) ?? Object.keys({ ...a, ...b }).every((key) => deepEqual(a[key], b[key], strict));
137
137
  }
138
138
 
139
- // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/time.ts
139
+ // ../dsh-wt-ic-merge/vendor/cosmokit/src/time.ts
140
140
  var Time;
141
141
  ((Time2) => {
142
142
  Time2.millisecond = 1;
@@ -216,7 +216,7 @@ var Time;
216
216
  Time2.template = template;
217
217
  })(Time || (Time = {}));
218
218
 
219
- // ../.dsh/source/staging-20260807T143236Z/vendor/schemastery/lib/index.mjs
219
+ // ../dsh-wt-ic-merge/vendor/schemastery/lib/index.mjs
220
220
  var kSchema = Symbol.for("schemastery");
221
221
  var kValidationError = Symbol.for("ValidationError");
222
222
  globalThis.__schemastery_index__ ??= 0;
@@ -831,7 +831,6 @@ function assertNever(value) {
831
831
  throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
832
832
  }
833
833
  var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
834
- var PLUGIN_SOURCE = "dsh-interconnect";
835
834
  var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
835
  var DEFAULT_INSTANCE_ID = "dsh";
837
836
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
@@ -1109,7 +1108,7 @@ var InterconnectService = class extends Service {
1109
1108
  * Returns the peer identity when reachable, or undefined when the link is
1110
1109
  * not up or no answer arrives.
1111
1110
  * @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
1112
- * @returns the peer's identity, or undefined when the link is not up or no answer arrives.
1111
+ * @returns the peer's identity, or undefined when there is no usable answer.
1113
1112
  */
1114
1113
  async ping(instanceId) {
1115
1114
  return this.queryRequest(instanceId, { kind: "ping" });
@@ -1123,16 +1122,33 @@ var InterconnectService = class extends Service {
1123
1122
  async list(instanceId) {
1124
1123
  return this.queryRequest(instanceId, { kind: "list" });
1125
1124
  }
1125
+ /**
1126
+ * The peers this instance configures, each with the current state of the link
1127
+ * it dials, in configuration order. This is how a caller tells an id this
1128
+ * instance cannot reach at all (`peer-not-configured`) from one whose link is
1129
+ * merely down (`unreachable`), which the bare `undefined` of `ping`/`list`
1130
+ * cannot express. A configured peer whose link is `down` is still a valid
1131
+ * target: its reconnect runs on its own backoff, so it may answer later.
1132
+ * @returns one row per configured peer.
1133
+ */
1134
+ peers() {
1135
+ return [...this.linkStates].map(([instanceId, state]) => ({
1136
+ instanceId,
1137
+ origin: state.peer,
1138
+ link: state.writable() ? "up" : "down"
1139
+ }));
1140
+ }
1126
1141
  /**
1127
1142
  * Send a `msg` frame over the live link to a peer and resolve with its
1128
- * correlated `msg-result`. Undefined when the peer has no live link here
1129
- * (not configured, or the link is down) — sends can only address configured+
1130
- * connected peers by design — and a `message-too-large` result when the
1131
- * encoded frame would not fit the link.
1143
+ * correlated `msg-result`. A `peer-not-configured` result when this instance
1144
+ * configures no route for that id, undefined when the route exists but its
1145
+ * link is down, and a `message-too-large` result when the encoded frame would
1146
+ * not fit the link.
1132
1147
  */
1133
1148
  async msgRequest(instanceId, kind, payload) {
1134
1149
  const state = this.linkStates.get(instanceId);
1135
- if (state === void 0 || !state.writable()) return void 0;
1150
+ if (state === void 0) return { delivered: false, instance: instanceId, reason: "peer-not-configured" };
1151
+ if (!state.writable()) return void 0;
1136
1152
  const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1137
1153
  const frame = {
1138
1154
  type: "msg",
@@ -1440,8 +1456,7 @@ var InterconnectService = class extends Service {
1440
1456
  })();
1441
1457
  const message = createUserMessage({
1442
1458
  source: {
1443
- kind: "plugin",
1444
- plugin: PLUGIN_SOURCE,
1459
+ kind: "plugin:dsh-interconnect",
1445
1460
  form: "notice",
1446
1461
  summary: boundContextSummary(
1447
1462
  sender === void 0 ? `interconnect handoff delivered on instance ${this.instanceId}` : `interconnect handoff from ${sender.instanceId} (session ${sender.sessionId}) delivered on instance ${this.instanceId}`