dsh-interconnect 0.11.24 → 0.15.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
@@ -11,20 +11,22 @@
11
11
 
12
12
  - 全走持久 WebSocket 链接:跨实例、跨机器投递消息、枚举 live session、探测活性(`send`/`reply`/`ping`/`list` 经 `/interconnect/link` 的 `msg`/`query` 帧)
13
13
  - `/interconnect/link` WebSocket 端点:双向实时事件推流,含心跳与指数退避重连;也承载
14
- `send`/`reply` 消息(`msg`/`msg-result` 帧,WS 优先 + HTTP 回退)
15
- - 事件 fan-out(HTTP + WebSocket),入站事件以 `interconnect/event` 发出
14
+ `send`/`reply` 消息(`msg`/`msg-result` 帧)。0.9 起没有 HTTP 端点,也没有 HTTP 回退
15
+ - 事件 fan-out 只在 WebSocket 链路上;入站事件以 `interconnect/event` 发出
16
16
  - 共享密钥鉴权(`DSH_INTERCONNECT_TOKEN`,bearer,fail-closed,timing-safe 比较)
17
17
 
18
18
  **`tool-interconnect`** —— 模型可见工具:
19
19
 
20
- - `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` 选投递模式
21
23
  - `interconnect_list`:列出对端实例的 live session(id + 标题 + 状态),用于在不预先知道 session id 时寻址
22
24
  - `interconnect_ping`:探测对端实例活性与身份
23
25
  - `interconnect_reply`:向记录过的发送方回传消息,只需文本;回信 session 是本 agent 自己的,无需再次寻址
24
26
 
25
27
  **`skill-interconnect`** —— 配套 skill:
26
28
 
27
- - 向模型注册 `dsh-interconnect` skill,说明 `list`/`ping`/`send`/`reply` 的完整用法、
29
+ - 向模型注册 `dsh-interconnect` skill,说明 `peers`/`list`/`ping`/`send`/`reply` 的完整用法、
28
30
  投递模式、`resume` 唤醒语义与失败处理。
29
31
  - 明确告知模型:`interconnect_send` 会自动注入发送方的 `instanceId` 和 `sessionId`,
30
32
  接收方凭记录的 sender 即可用 `interconnect_reply` 回信,不需要手工传地址。
@@ -39,9 +41,31 @@
39
41
  - `interconnect_send(instanceId="peer", sessionId=..., text=...)`
40
42
  - `interconnect_ping(instanceId="peer")`
41
43
  - `interconnect_list(instanceId="peer")`
44
+ - `interconnect_peers()`(不接受参数;返回本实例配置的全部对端及其链路状态)
42
45
  - `interconnect_reply(text=...)`(只需文本;回信 session 是本 agent 自己的,目标从记录的 sender 解析)
43
46
 
44
- `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 才是唯一的拨号依据。值也可以是字面量 `self`,表示拨向本实例自己的宿主 webserver(见「同一实例内两个会话互通」)。
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
+ 映射,所以改了文件但没被应用时,行为仍是旧的。
45
69
 
46
70
  `interconnect_list` 返回对端**当前 live** 的 session,每一行的 `sessionId` 在调用时刻都是合法的投递目标:
47
71
 
@@ -64,6 +88,32 @@ session-b07326da-… [running]
64
88
  (`100 of possibly more live sessions`)——满页**可能**被截断,缺失的目标仍可能 live。
65
89
  ping 与 list 的答复还会按调用方问的 kind 校验形状,形状不符按「无答复」处理(对应 `unreachable`)。
66
90
 
91
+ ### 同一实例内两个会话互通(`self`)
92
+
93
+ 同一台 DSH 上两个 session 互相投递时,把 peer 的值写成字面量 `self`,不要写死端口:
94
+
95
+ ```yaml
96
+ - id: interconnect
97
+ config:
98
+ instanceId: desktop
99
+ peers:
100
+ desktop: self # 键必须等于本实例的 instanceId
101
+ ```
102
+
103
+ `self` 不是 origin。拨号时插件用宿主 webserver **当前监听**的端口拼出
104
+ `http://127.0.0.1:<port>`(绑定 `0.0.0.0` 时回环同样可达),所以宿主以 `--port 0`
105
+ 启动、每次重启换端口(DSH Desktop 的默认行为)时不需要改配置,也不需要重启后手工同步。
106
+
107
+ - **键必须等于 `instanceId`**:自指链路拨到本实例自己的入站 socket,对端在 `hello` 里自报的
108
+ 就是这个 id;键不同会被当成另一个 peer,回信寻址和事件去重都对不上。配置加载时直接报错,
109
+ 而不是留下一条半可用的链路。
110
+ - **端口在每次拨号时重新读取**:webserver 比本插件晚激活、或还没开始监听时,链路保持 `down`
111
+ 并按退避重试,端口就绪后自动连上。`interconnect_peers` 在解析前把 origin 列显示为 `self`,
112
+ 解析后显示实际的 `http://127.0.0.1:<port>`。
113
+ - **没有 webserver 的部署**(headless profile)里这条链路保持 `down`,日志说明它是 self 路由
114
+ 且当前没有监听中的 webserver;`ping`/`send` 按「已配置但不可达」处理,不会得到
115
+ `peer-not-configured`。
116
+
67
117
  ### 回复(`reply`)
68
118
 
69
119
  `send` 的线负载带一个 `sender` 身份(**无地址**:`instanceId` + `sessionId`),收到消息的
@@ -117,15 +167,20 @@ interconnect_reply(text="reply")
117
167
 
118
168
  | `reason` | 含义 | 应对 |
119
169
  |---|---|---|
120
- | `session-not-live` | 对端**答复了**,但那个 session 没有运行中的 agent | 重试同一个 id 无用;用 `interconnect_list` 换目标,或带 `resume` |
121
- | `unreachable` | 没拿到可用答复(传输失败,或鉴权被拒) | 目标 session 可能完好,重试可能成功 |
122
- | `resume-refused` | 请求了唤醒,但对端不允许(`allowResume: false`) | 再带 `resume` 也没用 |
123
- | `resume-failed` | 允许唤醒且尝试了,但没得到 live agent(无此持久化 session,或被别的 owner 持有) | 换目标 |
170
+ | `session-not-live` | 对端**答复了**,但那个 session 没有 live agent,且唤醒被关闭(`resume: false`)或对端无法唤醒 | 不要改投其它 session;报告失败,或移除 `resume: false` 后重试 |
171
+ | `unreachable` | 配了该对端,但没拿到可用答复(链路断,或鉴权被拒) | 目标 session 可能完好,链路重连后重试可能成功 |
172
+ | `peer-not-configured` | 本实例的 `peers` 里没有这个 `instanceId`,链路根本不存在 | 重试永远不会成功;补 `peers` 映射(patch 层改完即生效,见「寻址」一节),或改用 `interconnect_peers` 列出的 id |
173
+ | `resume-refused` | 请求了唤醒,但对端不允许(`allowResume: false`) | 再请求也没用;不要改投其它 session,报告失败 |
174
+ | `resume-failed` | 允许唤醒且尝试了,但没得到 live agent(无此持久化 session,或被别的 owner 持有) | 不要改投其它 session;报告失败 |
124
175
  | `session-owned-by-subagent` | 该 session 属于 subagent 路由,投递权在它的父 agent | 通过父 agent 触达,别直接投 |
176
+ | `message-too-large` | 编好的帧超过链路帧上限(1 MiB),什么都没写、链路也没断 | 缩短文本,重发原文没用 |
125
177
  | `no-sender-known` | `reply` 指向的本地 session 从未记录过发送方(它没收到过带 `sender` 的消息,或对端版本过旧没带 `sender`) | 先用 `interconnect_send` 主动建立联系 |
126
178
 
127
179
  `reason` 恰好在 `delivered` 为 false 时出现。
128
180
 
181
+ 后两种加上 `peer-not-configured` 是**本机产生**的原因,不会从线缆上来:`msgResultSchema` 只接受
182
+ 接收方能回答的原因,所以这三条只可能出自本实例自己的服务(对端无法借它们伪造一个本地判定)。
183
+
129
184
  只有真的「尝试唤醒但失败」才是 `resume-failed`。没装 api-proxy 的部署里,`agent` lookup
130
185
  退化成一次 registry 查询、根本没有唤醒能力,这时报 `session-not-live`——否则会让调用方去重试
131
186
  一个永远不可能成功的操作。
@@ -135,26 +190,31 @@ interconnect_reply(text="reply")
135
190
  `interconnect_send` 不会往 subagent 拥有的 session 里投递,`interconnect_list` 也不会把它们
136
191
  列出来。那类 session 的投递权属于它的父 agent,从这里 splice 进 inbox 会和父 agent 抢。判定
137
192
  逻辑镜像 Host 的 `hasApiSessionSubagentOwner`(`@deepseek-ai/dsh-api-session-controller`):
138
- Host 在 0.6 之后把这个谓词从 `@deepseek-ai/dsh-api-remotes` 移走,且没有公开导出——桌面端和
139
- `npx @deepseek-ai/dsh web` 运行时里没有任何可 import 的 Host 绑定,所以这里逐字复制一份
140
- (`isSessionOwnedBySubagent`,见 `src/interconnect/index.ts`)。这是安全规则,Host 改动该规则
141
- 时必须同步此副本;tests 覆盖 origin=subagent 与 parent-owned 两条封栏分支。
193
+ 0.2.1-rc.3 起 Host 把 live runtime ownership 移出了 agent registry,通用 Session 路由只保留
194
+ header 里 `origin: 'subagent'` 的身份;普通 fork 即使记了 `parentSession` 也按普通 Session
195
+ 处理。谓词在插件内保留一份镜像(`isSessionOwnedBySubagent`,见 `src/interconnect/index.ts`),
196
+ 因为已安装的 host 可能解析到早于该导出的 root。这是安全规则,Host 改动时必须同步此副本;
197
+ tests 覆盖 origin=subagent 封栏与 parent-owned 普通 fork 可投递两条分支。
142
198
 
143
199
  已实测:起一个真实 subagent 后,`interconnect_list` 不包含它;直接 `send` 到它的 id 返回
144
200
  `session-owned-by-subagent`,消息**没有**进入 inbox。
145
201
 
146
- ### 唤醒离线 session(`resume`,默认关)
202
+ ### 唤醒离线 session(`resume`,默认开)
147
203
 
148
- `SendPayload.resume: true` 让对端唤醒一个已持久化但没有运行 agent 的 session。
204
+ 投递目标是一个「已持久化但没有运行 agent」的 session 时默认会唤醒它:不带 `resume` 时
205
+ `interconnect_send` 与 `interconnect_reply` 都按 `resume: true` 处理;传 `resume: false`
206
+ 才保持原样并返回 `session-not-live`。
149
207
 
150
- **默认关闭是有意的。** 实测确认:消息投递到 session 后会触发一次**完整的 agent 回合**——
151
- `wakeDriver()` → `kick()` → `turn()` → `llm.stream()`,即一次计费的模型调用,且 assembly
152
- 里带着该 session 的完整工具集。在一个用户没打开、看不到、也无法中断的会话里启动这些,和
153
- 「推一下已经开着的会话」不是一个量级,所以必须由发送方显式请求。
208
+ **默认开启是 2026-10-23 的决定。** 实测确认:消息投递到 session 后会触发一次**完整的 agent
209
+ 回合**——`wakeDriver()` → `kick()` → `turn()` → `llm.stream()`,即一次计费的模型调用,且
210
+ assembly 里带着该 session 的完整工具集。默认关闭时,最常见的结果不是省下这一次调用,而是
211
+ 投递失败后发送方改投另一个正在运行的 session:原会话永远收不到,别的会话收到一段不属于它的
212
+ 内容。目标通常是用户关心的那个会话,只是它此刻没在跑;所以默认唤醒它,把「不唤醒」留给明确
213
+ 这样要求的调用方。
154
214
 
155
- 两侧都有控制权:
215
+ 两侧的控制权不变:
156
216
 
157
- - **发送方**按消息决定 `resume`(默认不唤醒)
217
+ - **发送方**可逐条用 `resume: false` 关闭唤醒
158
218
  - **接收方**用 `Config.allowResume`(默认 `true`)一票否决——因为花钱和跑工具的是它那台机器;
159
219
  拒绝时在跑 lookup 之前就短路,回 `resume-refused`
160
220
 
@@ -170,15 +230,19 @@ context** 拥有,实测确认插件 fiber 被 dispose 时会把 resume 出来
170
230
  唤醒**只把消息放进 inbox**,是否真的开始处理取决于 `delivery`:
171
231
 
172
232
  ```text
173
- # 唤醒并让对方实际处理(会起一个计费回合)
174
- interconnect_send(instanceId="peer", sessionId, text, resume=true, delivery="followup")
233
+ # 默认:唤醒并让对方实际处理(会起一个计费回合)
234
+ interconnect_send(instanceId="peer", sessionId, text, delivery="followup")
175
235
 
176
236
  # 唤醒但不起回合:只写入上下文,等对方下次被唤醒时一起读
177
- interconnect_send(instanceId="peer", sessionId, text, resume=true, delivery="inject")
237
+ interconnect_send(instanceId="peer", sessionId, text, delivery="inject")
238
+
239
+ # 不唤醒:目标保持原样,返回 session-not-live
240
+ interconnect_send(instanceId="peer", sessionId, text, resume=false)
178
241
  ```
179
242
 
180
- 已实测:`resume=true` + `inject` 之后目标从非 live 变 live(`interconnect_list` 计数 +1),
181
- 且该 session 日志里只多一条 `agent/inbox/spliced`、**后面没有 `turn/start`**。
243
+ 已实测:唤醒(默认开启)+ `delivery="inject"` 之后目标从非 live 变 live
244
+ (`interconnect_list` 计数 +1),且该 session 日志里只多一条 `agent/inbox/spliced`、
245
+ **后面没有 `turn/start`**。
182
246
 
183
247
  **已知限制**:磁盘格式过旧的 session 无法唤醒,返回 `resume-failed`。这不是本插件的限制——
184
248
  Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedError`,同样失败。
@@ -205,7 +269,7 @@ Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedEr
205
269
  |---|---|---|
206
270
  | `instanceId` | `'dsh'` | 本实例自报的 id,出现在 `ping`/`send`/`list` 的回包里。也作为「对端如何寻址到我」的身份 |
207
271
  | `requestTimeoutMs` | `10000` | 出站请求超时,上限 60000 |
208
- | `peers` | `{}` | **对端路由映射**:`{ [对端 instanceId]: 我拨向它的 origin }`,例如隧道端点 `http://127.0.0.1:13080`。激活时对每个对端自动建持久 WS 链;`send`/`reply`/`ping`/`list` 都按 instanceId 走对应链接 |
272
+ | `peers` | `{}` | **对端路由映射**:`{ [对端 instanceId]: 我拨向它的 origin }`,例如隧道端点 `http://127.0.0.1:13080`;值也可以是字面量 `self`,表示拨向本实例自己的宿主 webserver,端口在拨号时从 `webServer.port` 读取(键必须等于 `instanceId`,见「同一实例内两个会话互通」)。激活时对每个对端自动建持久 WS 链;`send`/`reply`/`ping`/`list` 都按 instanceId 走对应链接。改这一行不需要重启:profile patch 层的改动会重建 fiber 并按新映射建链 |
209
273
  | `delivery` | `'followup'` | 入站消息未带 `delivery` 时的默认投递模式 |
210
274
  | `allowResume` | `true` | 是否允许发送方用 `resume` 唤醒本机的离线 session |
211
275
 
@@ -250,6 +314,17 @@ manifest 一变就重读 `dsh.profile.bundles` 并重算 patch 层,所以新
250
314
  安装下重建 `lib/`,都落在这一类。没有 `profileContext` 的启动方式(`hmr` 行被禁用)也只能靠
251
315
  重启。
252
316
 
317
+ `file:` 安装还有一个**文件粒度**的差异,来自 pnpm 用硬链接装本地目录依赖(实测所装副本的
318
+ link 数:`lib/**` 为 2,其余为 1):`lib/**` 是硬链接,仓库里重建后 profile 里那份跟着变,
319
+ 重启即可生效;`assets/`、`dsh.plugin.json`、`package.json`、`cordis.patch.yml` 是**安装时刻
320
+ 的副本**,改仓库不会传播。skill 正文来自 `assets/`,所以「只重建 + 重启」会得到新代码配旧
321
+ skill 正文,profile 的 `package.json` 里还可能写着很久以前的版本号。要整体刷新就用重装:
322
+
323
+ ```bash
324
+ dsh plugin --profile <name> remove dsh-interconnect
325
+ dsh plugin --profile <name> add file:/path/to/dsh-interconnect
326
+ ```
327
+
253
328
  两端实例用相同的 `DSH_INTERCONNECT_TOKEN` 作为共享密钥,任选一种来源:继承的进程环境变量、
254
329
  `$DSH_HOME/.env`、或 `$DSH_HOME/.credentials.yaml` 的 `refs` 段(该 provider 由
255
330
  `@deepseek-ai/dsh-base` 挂载;优先级为 继承的环境 > credentials 文档 > 调用目录的 `.env` >
@@ -261,6 +336,76 @@ refs:
261
336
  DSH_INTERCONNECT_TOKEN: <两端相同的共享密钥>
262
337
  ```
263
338
 
339
+ ## 两台机器最小可用
340
+
341
+ 下面用 A、B 两台机器说明最短路径:A 要主动发消息给 B,B 也要能回信。五步里前四步两端都要做,
342
+ 漏掉哪一步就会停在对应的失败原因上。
343
+
344
+ ### 1. 两端各装一次
345
+
346
+ ```bash
347
+ dsh plugin --profile <name> add dsh-interconnect
348
+ ```
349
+
350
+ 由 `dsh` 启动的实例几秒内自己热加载(见上一节),不用重启。
351
+
352
+ ### 2. 两端设同一个共享密钥
353
+
354
+ 按上一节任选一种来源,两侧的值必须逐字节相同。任一端没配 token 时端点 fail-closed,直接 403,
355
+ 另一侧只会看到链路 `down`。
356
+
357
+ ### 3. 打通网络:把 B 的端口映射到 A 的本机端口
358
+
359
+ 链接路由 `/interconnect/link` 挂在 B 的**宿主 webserver** 上,也就是 B 那个 DSH 实例监听的端口
360
+ (`dsh web` 就是 GUI 的端口)。同一台机器上就是回环地址;跨机器时先建立隧道:
361
+
362
+ ```bash
363
+ # 在 A 上执行:B 的 3080 变成 A 本机的 13080;-N 不执行远程命令
364
+ ssh -N -L 13080:127.0.0.1:3080 <B 的用户@主机>
365
+ ```
366
+
367
+ 只用 A→B 单向发消息时,这一条就够了。要让 B 也能主动发给 A,在 B 上反向再建一条(把 A 的端口
368
+ 映射成 B 本机的 13081)。
369
+
370
+ ### 4. 两端各配一遍 peer
371
+
372
+ 改 profile 的 patch 层(不是插件包内的文件;`interconnect` 行由 bundle 插入,这里按键名覆盖它的
373
+ config):
374
+
375
+ ```yaml
376
+ # A: $DSH_HOME/profiles/<name>/cordis.patch.yml
377
+ - id: interconnect
378
+ config:
379
+ instanceId: a
380
+ peers:
381
+ b: http://127.0.0.1:13080 # key = B 自报的 instanceId;value = A 拨向 B 的 origin
382
+ ```
383
+
384
+ ```yaml
385
+ # B: $DSH_HOME/profiles/<name>/cordis.patch.yml
386
+ - id: interconnect
387
+ config:
388
+ instanceId: b
389
+ peers:
390
+ a: http://127.0.0.1:13081 # 没有这一条,B 能收消息但回信会得到 peer-not-configured
391
+ ```
392
+
393
+ 改完不用重启:patch 层的改动会重建 fiber 并按新映射建链。key 建议与对端自报的 `instanceId`
394
+ 一致,否则 B 拨入的那条链路在 A 侧认不出是同一个 peer,事件会重复一份(服务会 warn 一次)。
395
+ 同一台机器上的自指场景不走这一节:把值写成 `self`,见「同一实例内两个会话互通」,不需要隧道。
396
+
397
+ ### 5. 按顺序验证
398
+
399
+ 1. `interconnect_peers` —— 应看到 `b` 且链路 `up`。
400
+ 2. `interconnect_ping(instanceId="b")` —— 应答里是 B 的实例 id。
401
+ 3. `interconnect_list(instanceId="b")` —— 列出 B 上 live 的 session。
402
+ 4. `interconnect_send(instanceId="b", sessionId=..., text=...)` —— 投递成功。
403
+ 5. B 侧 `interconnect_reply(text=...)` 回信,A 侧收到续文。
404
+
405
+ 停在哪一步就回查哪一步:`unreachable`(`configured: true`)回查第 2、3 步(token、隧道);
406
+ `configured: false` / `peer-not-configured` 回查第 4 步(映射里没有这个 key);链路一直 `down` 时,
407
+ 对端日志里对应的是 401/403,回查第 2 步(两侧 token 是否都有值且逐字节相同)。
408
+
264
409
  ## 开发
265
410
 
266
411
  依赖 [公开的 DeepSeek Harness monorepo](https://github.com/deepseek-ai/deepseek-harness)
@@ -282,14 +427,15 @@ pnpm run build # esbuild → lib/
282
427
  的 isolate realm 会导致工具/技能行无法 inject 到该服务。
283
428
  - 服务只 inject `agents` 与 `credentials`;**宿主 webserver 是可选的**。有 webserver 时入站
284
429
  升级路由注册在它上面(`webServer` 经等待式 inject fiber 接入,与组合顺序无关);没有
285
- webserver 的 profile(例如 headless)里服务仍会拨号已配置对端并经这些出站链接投递,即
286
- 仅出站模式。`dsh.plugin.json` 的 `inject` 与之一致,只有 `agents`、`credentials`。
430
+ webserver 的 profile(例如 headless)里服务仍会拨号已配置的固定 origin 对端并经这些出站链接
431
+ 投递,即仅出站模式;`self` 路由没有 origin 可拨,会保持 `down` 并记录原因。
432
+ `dsh.plugin.json` 的 `inject` 与之一致,只有 `agents`、`credentials`。
287
433
  - `ws` 是运行依赖,由宿主的 node_modules 提供(构建时 external)。
288
434
 
289
435
  ## 验证
290
436
 
291
- - 164/164 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧按 kind 绑定与投影、list 的行数与字节双上限及满页提示等回归用例);
292
- 类型检查、构建均干净。
437
+ - 185/185 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧按 kind 绑定与投影、list 的行数与字节双上限及满页提示、peer 状态与「未配置」分支、subagent 封栏与 parent-owned 普通 fork 可投递、`self` 路由的端口延迟解析/真实 `--port 0` webserver 回环/headless 降级/键约束、本机原因不得从线缆上来的负例等回归用例);
438
+ 类型检查、构建均干净;对公开 DSH master(0.2.2-rc.1)验证。
293
439
  - 已在两台机器之间实测双向互通:消息投递、WebSocket 事件推流、以及 agent 经
294
440
  `interconnect_send` 工具反向回发,均验证通过。
295
441
  - CI(GitHub Actions):clone 公开 DSH 仓库作为 sibling,跑 `pnpm run check`。
@@ -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.24",
4
+ "version": "0.15.0",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]