dsh-interconnect 0.13.0 → 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
@@ -44,7 +44,7 @@
44
44
  - `interconnect_peers()`(不接受参数;返回本实例配置的全部对端及其链路状态)
45
45
  - `interconnect_reply(text=...)`(只需文本;回信 session 是本 agent 自己的,目标从记录的 sender 解析)
46
46
 
47
- `instanceId` 是 `interconnect` 行 `peers` 映射里的键;真正用来拨号的 origin 由该映射的值给出(例如隧道端点 `http://127.0.0.1:13080`),**instanceId 本身从不出现在线上**,也不参与路由——origin 才是唯一的拨号依据。
47
+ `instanceId` 是 `interconnect` 行 `peers` 映射里的键;真正用来拨号的 origin 由该映射的值给出(例如隧道端点 `http://127.0.0.1:13080`),**instanceId 本身从不出现在线上**,也不参与路由——origin 才是唯一的拨号依据。值也可以是字面量 `self`,表示拨向本实例自己的宿主 webserver(见「同一实例内两个会话互通」)。
48
48
 
49
49
  对端没配在这里时,`send`/`reply` 返回 `peer-not-configured`,`ping`/`list` 则在返回
50
50
  `reachable: false` 的同时带上 `configured: false`——这一支**重试永远不会成功**,只能去补
@@ -88,6 +88,32 @@ session-b07326da-… [running]
88
88
  (`100 of possibly more live sessions`)——满页**可能**被截断,缺失的目标仍可能 live。
89
89
  ping 与 list 的答复还会按调用方问的 kind 校验形状,形状不符按「无答复」处理(对应 `unreachable`)。
90
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
+
91
117
  ### 回复(`reply`)
92
118
 
93
119
  `send` 的线负载带一个 `sender` 身份(**无地址**:`instanceId` + `sessionId`),收到消息的
@@ -164,10 +190,11 @@ interconnect_reply(text="reply")
164
190
  `interconnect_send` 不会往 subagent 拥有的 session 里投递,`interconnect_list` 也不会把它们
165
191
  列出来。那类 session 的投递权属于它的父 agent,从这里 splice 进 inbox 会和父 agent 抢。判定
166
192
  逻辑镜像 Host 的 `hasApiSessionSubagentOwner`(`@deepseek-ai/dsh-api-session-controller`):
167
- Host 在 0.6 之后把这个谓词从 `@deepseek-ai/dsh-api-remotes` 移走,且没有公开导出——桌面端和
168
- `npx @deepseek-ai/dsh web` 运行时里没有任何可 import 的 Host 绑定,所以这里逐字复制一份
169
- (`isSessionOwnedBySubagent`,见 `src/interconnect/index.ts`)。这是安全规则,Host 改动该规则
170
- 时必须同步此副本;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 可投递两条分支。
171
198
 
172
199
  已实测:起一个真实 subagent 后,`interconnect_list` 不包含它;直接 `send` 到它的 id 返回
173
200
  `session-owned-by-subagent`,消息**没有**进入 inbox。
@@ -242,7 +269,7 @@ Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedEr
242
269
  |---|---|---|
243
270
  | `instanceId` | `'dsh'` | 本实例自报的 id,出现在 `ping`/`send`/`list` 的回包里。也作为「对端如何寻址到我」的身份 |
244
271
  | `requestTimeoutMs` | `10000` | 出站请求超时,上限 60000 |
245
- | `peers` | `{}` | **对端路由映射**:`{ [对端 instanceId]: 我拨向它的 origin }`,例如隧道端点 `http://127.0.0.1:13080`。激活时对每个对端自动建持久 WS 链;`send`/`reply`/`ping`/`list` 都按 instanceId 走对应链接。改这一行不需要重启:profile patch 层的改动会重建 fiber 并按新映射建链 |
272
+ | `peers` | `{}` | **对端路由映射**:`{ [对端 instanceId]: 我拨向它的 origin }`,例如隧道端点 `http://127.0.0.1:13080`;值也可以是字面量 `self`,表示拨向本实例自己的宿主 webserver,端口在拨号时从 `webServer.port` 读取(键必须等于 `instanceId`,见「同一实例内两个会话互通」)。激活时对每个对端自动建持久 WS 链;`send`/`reply`/`ping`/`list` 都按 instanceId 走对应链接。改这一行不需要重启:profile patch 层的改动会重建 fiber 并按新映射建链 |
246
273
  | `delivery` | `'followup'` | 入站消息未带 `delivery` 时的默认投递模式 |
247
274
  | `allowResume` | `true` | 是否允许发送方用 `resume` 唤醒本机的离线 session |
248
275
 
@@ -365,6 +392,7 @@ config):
365
392
 
366
393
  改完不用重启:patch 层的改动会重建 fiber 并按新映射建链。key 建议与对端自报的 `instanceId`
367
394
  一致,否则 B 拨入的那条链路在 A 侧认不出是同一个 peer,事件会重复一份(服务会 warn 一次)。
395
+ 同一台机器上的自指场景不走这一节:把值写成 `self`,见「同一实例内两个会话互通」,不需要隧道。
368
396
 
369
397
  ### 5. 按顺序验证
370
398
 
@@ -399,14 +427,15 @@ pnpm run build # esbuild → lib/
399
427
  的 isolate realm 会导致工具/技能行无法 inject 到该服务。
400
428
  - 服务只 inject `agents` 与 `credentials`;**宿主 webserver 是可选的**。有 webserver 时入站
401
429
  升级路由注册在它上面(`webServer` 经等待式 inject fiber 接入,与组合顺序无关);没有
402
- webserver 的 profile(例如 headless)里服务仍会拨号已配置对端并经这些出站链接投递,即
403
- 仅出站模式。`dsh.plugin.json` 的 `inject` 与之一致,只有 `agents`、`credentials`。
430
+ webserver 的 profile(例如 headless)里服务仍会拨号已配置的固定 origin 对端并经这些出站链接
431
+ 投递,即仅出站模式;`self` 路由没有 origin 可拨,会保持 `down` 并记录原因。
432
+ `dsh.plugin.json` 的 `inject` 与之一致,只有 `agents`、`credentials`。
404
433
  - `ws` 是运行依赖,由宿主的 node_modules 提供(构建时 external)。
405
434
 
406
435
  ## 验证
407
436
 
408
- - 179/179 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧按 kind 绑定与投影、list 的行数与字节双上限及满页提示、peer 状态与「未配置」分支、本机原因不得从线缆上来的负例等回归用例);
409
- 类型检查、构建均干净。
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)验证。
410
439
  - 已在两台机器之间实测双向互通:消息投递、WebSocket 事件推流、以及 agent 经
411
440
  `interconnect_send` 工具反向回发,均验证通过。
412
441
  - CI(GitHub Actions):clone 公开 DSH 仓库作为 sibling,跑 `pnpm run check`。
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.13.0",
4
+ "version": "0.15.0",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]
package/lib/index.js CHANGED
@@ -820,12 +820,8 @@ defineMethod("transform", [
820
820
 
821
821
  // src/interconnect/index.ts
822
822
  import WebSocket, { WebSocketServer } from "ws";
823
- function isSessionOwnedBySubagent(ctx, session, agent) {
824
- if (session.header.origin === "subagent") return true;
825
- const parentId = session.header.parentSession;
826
- if (parentId === void 0 || agent === void 0) return false;
827
- const parent = ctx.agents.get(parentId);
828
- return parent !== void 0 && ctx.agents.isOwnedBy(agent.id, parent);
823
+ function isSessionOwnedBySubagent(session) {
824
+ return session.header.origin === "subagent";
829
825
  }
830
826
  function assertNever(value) {
831
827
  throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
@@ -836,6 +832,7 @@ var DEFAULT_INSTANCE_ID = "dsh";
836
832
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
837
833
  var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
838
834
  var LINK_CHANNEL = "/interconnect/link";
835
+ var SELF_PEER_ORIGIN = "self";
839
836
  var MAX_INSTANCE_ID_CHARS = 256;
840
837
  var MAX_REQUEST_ID_CHARS = 256;
841
838
  var MAX_LISTED_SESSIONS = 100;
@@ -963,11 +960,19 @@ var InterconnectService = class extends Service {
963
960
  this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
964
961
  this.delivery = config.delivery ?? "followup";
965
962
  this.allowResume = config.allowResume ?? true;
966
- for (const origin of Object.values(config.peers ?? {})) {
963
+ for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
964
+ if (origin === SELF_PEER_ORIGIN) {
965
+ if (peerInstanceId !== this.instanceId) {
966
+ throw new Error(
967
+ `interconnect: peer "${peerInstanceId}" is configured as self, but this instance announces "${this.instanceId}"; key a self peer by the instanceId`
968
+ );
969
+ }
970
+ continue;
971
+ }
967
972
  linkUrl(trimBase(origin));
968
973
  }
969
974
  for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
970
- this.link(peerInstanceId, origin);
975
+ this.link(peerInstanceId, origin === SELF_PEER_ORIGIN ? () => this.selfOrigin() : origin);
971
976
  }
972
977
  const upgrade = {
973
978
  path: LINK_CHANNEL,
@@ -1210,7 +1215,8 @@ var InterconnectService = class extends Service {
1210
1215
  * `Config.peers` entry; the resulting state owns reconnection for the life of
1211
1216
  * the service fiber.
1212
1217
  * @param instanceId - the peer's `instanceId`.
1213
- * @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
1218
+ * @param origin - receiver origin this instance dials (e.g.
1219
+ * `http://127.0.0.1:13080`), or a resolver for a `self` route.
1214
1220
  */
1215
1221
  link(instanceId, origin) {
1216
1222
  const state = new LinkState(
@@ -1220,11 +1226,28 @@ var InterconnectService = class extends Service {
1220
1226
  this.ctx.logger,
1221
1227
  () => this.resolveToken(),
1222
1228
  instanceId,
1223
- trimBase(origin)
1229
+ typeof origin === "string" ? trimBase(origin) : origin
1224
1230
  );
1225
1231
  this.linkStates.set(instanceId, state);
1226
1232
  state.dial();
1227
1233
  }
1234
+ /**
1235
+ * The origin of this instance's own host webserver, or undefined while no
1236
+ * listening port exists. Read on every dial rather than cached: the
1237
+ * webserver may activate after this service, its port is assigned only in
1238
+ * its listen callback (`--port 0` picks a fresh one per start), and a
1239
+ * replacement webserver would move it again. Loopback is used regardless of
1240
+ * the bind host: a server bound to `0.0.0.0` also accepts `127.0.0.1`, while
1241
+ * `0.0.0.0` itself is a bind address, not a dialable origin.
1242
+ * @returns `http://127.0.0.1:<port>`, or undefined without a listening server.
1243
+ */
1244
+ selfOrigin() {
1245
+ const server = this.ctx.get("webServer");
1246
+ if (server === void 0) return void 0;
1247
+ const port = server.port;
1248
+ if (typeof port !== "number" || port <= 0) return void 0;
1249
+ return `http://127.0.0.1:${String(port)}`;
1250
+ }
1228
1251
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1229
1252
  fanout(notification) {
1230
1253
  this.broadcast(notification);
@@ -1359,7 +1382,7 @@ var InterconnectService = class extends Service {
1359
1382
  * @returns the bounded listing.
1360
1383
  */
1361
1384
  listSessions(rowBudgetBytes) {
1362
- const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1385
+ const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(agent.session));
1363
1386
  const sessions = [];
1364
1387
  let used = 0;
1365
1388
  for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
@@ -1386,7 +1409,8 @@ var InterconnectService = class extends Service {
1386
1409
  let title;
1387
1410
  try {
1388
1411
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
1389
- const value = snapshot?.values.title;
1412
+ const values = snapshot?.values;
1413
+ const value = values?.title;
1390
1414
  if (typeof value === "string" && value !== "") title = value;
1391
1415
  } catch {
1392
1416
  title = void 0;
@@ -1444,7 +1468,7 @@ var InterconnectService = class extends Service {
1444
1468
  }
1445
1469
  agent = woken.agent;
1446
1470
  }
1447
- if (isSessionOwnedBySubagent(this.ctx, agent.session, agent)) {
1471
+ if (isSessionOwnedBySubagent(agent.session)) {
1448
1472
  return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1449
1473
  }
1450
1474
  const sender = (() => {
@@ -1653,24 +1677,40 @@ var LinkState = class {
1653
1677
  this.logger = logger;
1654
1678
  this.resolveToken = resolveToken;
1655
1679
  this.instanceId = instanceId;
1656
- this.peer = origin;
1680
+ this.origin = origin;
1657
1681
  }
1658
1682
  dialed;
1659
1683
  closed = false;
1660
1684
  retry = 0;
1661
1685
  reconnectTimer;
1662
1686
  warnedNoToken = false;
1663
- /** Receiver origin this link dials. */
1664
- peer;
1687
+ warnedNoOrigin = false;
1665
1688
  instanceId;
1689
+ origin;
1690
+ /** The origin this link dials now; for an unresolved `self` route, the sentinel is shown. */
1691
+ get peer() {
1692
+ return typeof this.origin === "string" ? this.origin : this.origin() ?? SELF_PEER_ORIGIN;
1693
+ }
1666
1694
  /** Open the socket; reconnect is scheduled by the close handler. */
1667
1695
  dial() {
1668
1696
  if (this.closed) return;
1697
+ const origin = typeof this.origin === "string" ? this.origin : this.origin();
1698
+ if (origin === void 0) {
1699
+ if (this.retry >= 1 && !this.warnedNoOrigin) {
1700
+ this.warnedNoOrigin = true;
1701
+ this.logger.warn(
1702
+ `interconnect: peer ${this.instanceId} is configured as self but this instance has no listening webServer port yet; the link stays down and retries`
1703
+ );
1704
+ }
1705
+ this.scheduleReconnect();
1706
+ return;
1707
+ }
1708
+ this.warnedNoOrigin = false;
1669
1709
  let url;
1670
1710
  try {
1671
- url = linkUrl(this.peer);
1711
+ url = linkUrl(origin);
1672
1712
  } catch {
1673
- this.logger.warn(`interconnect: invalid peer origin ${this.peer}; link to ${this.instanceId} stays down`);
1713
+ this.logger.warn(`interconnect: invalid peer origin ${origin}; link to ${this.instanceId} stays down`);
1674
1714
  return;
1675
1715
  }
1676
1716
  void this.resolveToken().then((token) => {
@@ -820,12 +820,8 @@ defineMethod("transform", [
820
820
 
821
821
  // src/interconnect/index.ts
822
822
  import WebSocket, { WebSocketServer } from "ws";
823
- function isSessionOwnedBySubagent(ctx, session, agent) {
824
- if (session.header.origin === "subagent") return true;
825
- const parentId = session.header.parentSession;
826
- if (parentId === void 0 || agent === void 0) return false;
827
- const parent = ctx.agents.get(parentId);
828
- return parent !== void 0 && ctx.agents.isOwnedBy(agent.id, parent);
823
+ function isSessionOwnedBySubagent(session) {
824
+ return session.header.origin === "subagent";
829
825
  }
830
826
  function assertNever(value) {
831
827
  throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
@@ -836,6 +832,7 @@ var DEFAULT_INSTANCE_ID = "dsh";
836
832
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
837
833
  var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
838
834
  var LINK_CHANNEL = "/interconnect/link";
835
+ var SELF_PEER_ORIGIN = "self";
839
836
  var MAX_INSTANCE_ID_CHARS = 256;
840
837
  var MAX_REQUEST_ID_CHARS = 256;
841
838
  var MAX_LISTED_SESSIONS = 100;
@@ -963,11 +960,19 @@ var InterconnectService = class extends Service {
963
960
  this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
964
961
  this.delivery = config.delivery ?? "followup";
965
962
  this.allowResume = config.allowResume ?? true;
966
- for (const origin of Object.values(config.peers ?? {})) {
963
+ for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
964
+ if (origin === SELF_PEER_ORIGIN) {
965
+ if (peerInstanceId !== this.instanceId) {
966
+ throw new Error(
967
+ `interconnect: peer "${peerInstanceId}" is configured as self, but this instance announces "${this.instanceId}"; key a self peer by the instanceId`
968
+ );
969
+ }
970
+ continue;
971
+ }
967
972
  linkUrl(trimBase(origin));
968
973
  }
969
974
  for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
970
- this.link(peerInstanceId, origin);
975
+ this.link(peerInstanceId, origin === SELF_PEER_ORIGIN ? () => this.selfOrigin() : origin);
971
976
  }
972
977
  const upgrade = {
973
978
  path: LINK_CHANNEL,
@@ -1210,7 +1215,8 @@ var InterconnectService = class extends Service {
1210
1215
  * `Config.peers` entry; the resulting state owns reconnection for the life of
1211
1216
  * the service fiber.
1212
1217
  * @param instanceId - the peer's `instanceId`.
1213
- * @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
1218
+ * @param origin - receiver origin this instance dials (e.g.
1219
+ * `http://127.0.0.1:13080`), or a resolver for a `self` route.
1214
1220
  */
1215
1221
  link(instanceId, origin) {
1216
1222
  const state = new LinkState(
@@ -1220,11 +1226,28 @@ var InterconnectService = class extends Service {
1220
1226
  this.ctx.logger,
1221
1227
  () => this.resolveToken(),
1222
1228
  instanceId,
1223
- trimBase(origin)
1229
+ typeof origin === "string" ? trimBase(origin) : origin
1224
1230
  );
1225
1231
  this.linkStates.set(instanceId, state);
1226
1232
  state.dial();
1227
1233
  }
1234
+ /**
1235
+ * The origin of this instance's own host webserver, or undefined while no
1236
+ * listening port exists. Read on every dial rather than cached: the
1237
+ * webserver may activate after this service, its port is assigned only in
1238
+ * its listen callback (`--port 0` picks a fresh one per start), and a
1239
+ * replacement webserver would move it again. Loopback is used regardless of
1240
+ * the bind host: a server bound to `0.0.0.0` also accepts `127.0.0.1`, while
1241
+ * `0.0.0.0` itself is a bind address, not a dialable origin.
1242
+ * @returns `http://127.0.0.1:<port>`, or undefined without a listening server.
1243
+ */
1244
+ selfOrigin() {
1245
+ const server = this.ctx.get("webServer");
1246
+ if (server === void 0) return void 0;
1247
+ const port = server.port;
1248
+ if (typeof port !== "number" || port <= 0) return void 0;
1249
+ return `http://127.0.0.1:${String(port)}`;
1250
+ }
1228
1251
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1229
1252
  fanout(notification) {
1230
1253
  this.broadcast(notification);
@@ -1359,7 +1382,7 @@ var InterconnectService = class extends Service {
1359
1382
  * @returns the bounded listing.
1360
1383
  */
1361
1384
  listSessions(rowBudgetBytes) {
1362
- const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1385
+ const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(agent.session));
1363
1386
  const sessions = [];
1364
1387
  let used = 0;
1365
1388
  for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
@@ -1386,7 +1409,8 @@ var InterconnectService = class extends Service {
1386
1409
  let title;
1387
1410
  try {
1388
1411
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
1389
- const value = snapshot?.values.title;
1412
+ const values = snapshot?.values;
1413
+ const value = values?.title;
1390
1414
  if (typeof value === "string" && value !== "") title = value;
1391
1415
  } catch {
1392
1416
  title = void 0;
@@ -1444,7 +1468,7 @@ var InterconnectService = class extends Service {
1444
1468
  }
1445
1469
  agent = woken.agent;
1446
1470
  }
1447
- if (isSessionOwnedBySubagent(this.ctx, agent.session, agent)) {
1471
+ if (isSessionOwnedBySubagent(agent.session)) {
1448
1472
  return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1449
1473
  }
1450
1474
  const sender = (() => {
@@ -1653,24 +1677,40 @@ var LinkState = class {
1653
1677
  this.logger = logger;
1654
1678
  this.resolveToken = resolveToken;
1655
1679
  this.instanceId = instanceId;
1656
- this.peer = origin;
1680
+ this.origin = origin;
1657
1681
  }
1658
1682
  dialed;
1659
1683
  closed = false;
1660
1684
  retry = 0;
1661
1685
  reconnectTimer;
1662
1686
  warnedNoToken = false;
1663
- /** Receiver origin this link dials. */
1664
- peer;
1687
+ warnedNoOrigin = false;
1665
1688
  instanceId;
1689
+ origin;
1690
+ /** The origin this link dials now; for an unresolved `self` route, the sentinel is shown. */
1691
+ get peer() {
1692
+ return typeof this.origin === "string" ? this.origin : this.origin() ?? SELF_PEER_ORIGIN;
1693
+ }
1666
1694
  /** Open the socket; reconnect is scheduled by the close handler. */
1667
1695
  dial() {
1668
1696
  if (this.closed) return;
1697
+ const origin = typeof this.origin === "string" ? this.origin : this.origin();
1698
+ if (origin === void 0) {
1699
+ if (this.retry >= 1 && !this.warnedNoOrigin) {
1700
+ this.warnedNoOrigin = true;
1701
+ this.logger.warn(
1702
+ `interconnect: peer ${this.instanceId} is configured as self but this instance has no listening webServer port yet; the link stays down and retries`
1703
+ );
1704
+ }
1705
+ this.scheduleReconnect();
1706
+ return;
1707
+ }
1708
+ this.warnedNoOrigin = false;
1669
1709
  let url;
1670
1710
  try {
1671
- url = linkUrl(this.peer);
1711
+ url = linkUrl(origin);
1672
1712
  } catch {
1673
- this.logger.warn(`interconnect: invalid peer origin ${this.peer}; link to ${this.instanceId} stays down`);
1713
+ this.logger.warn(`interconnect: invalid peer origin ${origin}; link to ${this.instanceId} stays down`);
1674
1714
  return;
1675
1715
  }
1676
1716
  void this.resolveToken().then((token) => {