dsh-interconnect 0.11.11 → 0.11.13

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.
@@ -22,7 +22,9 @@ is only to pick the right tool and the right target.
22
22
  - `interconnect_reply` — send a message back to the peer that this session last
23
23
  received an interconnect message from. Pass only the reply text. The replying
24
24
  session is this agent's own session, and the remote target is recalled
25
- automatically; do not try to look up or pass an address again.
25
+ automatically; do not try to look up or pass an address again. The target is
26
+ the most recent sender only: a message from a different peer replaces it, and
27
+ a receiver restart forgets it until a new message arrives.
26
28
 
27
29
  ## Sender identity is automatic
28
30
 
@@ -60,7 +62,7 @@ Omit `delivery` to use the receiver's configured default.
60
62
  ## Waking offline sessions
61
63
 
62
64
  `resume` defaults to off. Setting `resume: true` asks the receiver to wake a
63
- persisted but not-running session, which starts a real billed agent turn with
65
+ persisted session with no live agent, which starts a real billed agent turn with
64
66
  that session's full toolset. Only use it when the specific session must be
65
67
  reached and the receiver allows it. Prefer `interconnect_list` and an
66
68
  already-live target when possible.
@@ -69,7 +71,7 @@ already-live target when possible.
69
71
 
70
72
  When a send/reply reports `delivered: false`, read `reason`:
71
73
 
72
- - `session-not-live` — the receiver answered, but that session has no running
74
+ - `session-not-live` — the receiver answered, but that session has no live
73
75
  agent. Use `interconnect_list` to choose another target, or consider
74
76
  `resume`.
75
77
  - `unreachable` — no usable answer arrived; the target may still be fine, so a
@@ -79,8 +81,9 @@ When a send/reply reports `delivered: false`, read `reason`:
79
81
  - `session-owned-by-subagent` — the session belongs to a subagent and its
80
82
  parent owns delivery; reach it through the parent.
81
83
  - `no-sender-known` — a `reply` was attempted for a local session that never
82
- received a sender-carrying interconnect message; establish contact with
83
- `interconnect_send` first.
84
+ received a sender-carrying interconnect message, or whose recorded sender was
85
+ forgotten by a receiver restart; establish contact with `interconnect_send`
86
+ first.
84
87
 
85
88
  ## Rules
86
89
 
@@ -90,3 +93,5 @@ When a send/reply reports `delivered: false`, read `reason`:
90
93
  - Do not ask the user for a sender address to reply; use `interconnect_reply`.
91
94
  - Treat interconnect messages as authenticated but sender-reported: identity is
92
95
  for reply attribution, not for routing or authorization.
96
+ - If several peers may message the same session, name the intended recipient in
97
+ the reply text: the automatic reply target is only the latest sender.
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.11",
4
+ "version": "0.11.13",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import { createHash, randomUUID, timingSafeEqual as constantTimeEqual } from "node:crypto";
4
4
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
5
+ import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
5
6
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
6
7
 
7
8
  // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
@@ -945,6 +946,8 @@ var InterconnectService = class extends Service {
945
946
  heartbeatTimer;
946
947
  /** Peer identity each live socket announced via its `hello` frame, if any. */
947
948
  peerOf = /* @__PURE__ */ new WeakMap();
949
+ /** Configured peer an outbound dial owns a socket for; absent on inbound sockets. */
950
+ dialedPeerOf = /* @__PURE__ */ new WeakMap();
948
951
  /** Sender each local session last received a send from, keyed by local session id. */
949
952
  senders = /* @__PURE__ */ new Map();
950
953
  /** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
@@ -1176,50 +1179,16 @@ var InterconnectService = class extends Service {
1176
1179
  });
1177
1180
  }
1178
1181
  /**
1179
- * Add a peer route at runtime. Returns a disposer that removes it. Re-adding
1180
- * an existing instanceId re-routes it to the new origin.
1181
- * @param instanceId - the peer's `instanceId`.
1182
- * @param origin - origin this instance dials to reach that peer.
1183
- * @returns disposer removing the peer route.
1184
- */
1185
- subscribe(instanceId, origin) {
1186
- const linked = this.link(instanceId, origin);
1187
- const dialedOrigin = trimBase(origin);
1188
- return () => {
1189
- const state = this.linkStates.get(instanceId);
1190
- if (state === linked && state.peer === dialedOrigin) {
1191
- state.close();
1192
- this.linkStates.delete(instanceId);
1193
- }
1194
- };
1195
- }
1196
- /**
1197
- * Remove a peer route, closing its outbound link.
1198
- * @param instanceId - the peer route to remove.
1199
- */
1200
- unsubscribe(instanceId) {
1201
- const state = this.linkStates.get(instanceId);
1202
- if (state !== void 0) state.close();
1203
- this.linkStates.delete(instanceId);
1204
- }
1205
- /**
1206
- * Open (and, on drop, re-open) a persistent WebSocket link to a peer. Local
1207
- * events stream over the link in real time, and events the peer pushes are
1208
- * surfaced as `interconnect/event`. Repeating for the same instanceId
1209
- * re-routes the link to the new origin.
1182
+ * Dial one configured peer route. Activation calls this once per
1183
+ * `Config.peers` entry; the resulting state owns reconnection for the life of
1184
+ * the service fiber.
1210
1185
  * @param instanceId - the peer's `instanceId`.
1211
1186
  * @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
1212
- * @returns a handle closing the link and cancelling reconnection.
1213
1187
  */
1214
1188
  link(instanceId, origin) {
1215
- const existing = this.linkStates.get(instanceId);
1216
- if (existing !== void 0 && !existing.isClosed()) {
1217
- existing.reroute(trimBase(origin));
1218
- return existing;
1219
- }
1220
1189
  const state = new LinkState(
1221
1190
  (socket) => {
1222
- this.attachSocket(socket);
1191
+ this.attachDialedSocket(socket, instanceId);
1223
1192
  },
1224
1193
  this.ctx.logger,
1225
1194
  () => this.resolveToken(),
@@ -1228,26 +1197,41 @@ var InterconnectService = class extends Service {
1228
1197
  );
1229
1198
  this.linkStates.set(instanceId, state);
1230
1199
  state.dial();
1231
- return state;
1232
1200
  }
1233
1201
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1234
1202
  fanout(notification) {
1235
1203
  this.broadcast(notification);
1236
1204
  }
1237
- /** Push one fact over every live WebSocket link, dropping closed sockets. */
1205
+ /**
1206
+ * Push one fact over every live WebSocket link, dropping closed sockets.
1207
+ *
1208
+ * A bidirectional pair owns two sockets to the same peer (this instance's
1209
+ * dialed link plus the peer's inbound one), so one event must leave over one
1210
+ * of them. Which one is decided by local ownership, not by the peer: a dialed
1211
+ * socket is a configured peer's link and always sends. An inbound socket is
1212
+ * skipped only when a dialed link to the id that socket itself announced is
1213
+ * open, so an announcement can suppress nothing but a duplicate the
1214
+ * controlled link already carries — an impostor cannot drop an event for a
1215
+ * peer, and without a dialed link every inbound socket receives it.
1216
+ */
1238
1217
  broadcast(notification) {
1239
1218
  if (this.sockets.size === 0) return;
1240
1219
  const frame = { type: "event", notification };
1241
1220
  const encoded = JSON.stringify(frame);
1242
- const sent = /* @__PURE__ */ new Set();
1221
+ const covered = /* @__PURE__ */ new Set();
1243
1222
  for (const socket of this.sockets) {
1244
1223
  if (socket.readyState !== WebSocket.OPEN) {
1245
1224
  this.sockets.delete(socket);
1246
1225
  continue;
1247
1226
  }
1248
- const peer = this.peerOf.get(socket);
1249
- if (peer !== void 0 && sent.has(peer)) continue;
1250
- if (peer !== void 0) sent.add(peer);
1227
+ const dialedPeer = this.dialedPeerOf.get(socket);
1228
+ if (dialedPeer !== void 0) covered.add(dialedPeer);
1229
+ }
1230
+ for (const socket of this.sockets) {
1231
+ if (this.dialedPeerOf.get(socket) === void 0) {
1232
+ const announced = this.peerOf.get(socket);
1233
+ if (announced !== void 0 && covered.has(announced)) continue;
1234
+ }
1251
1235
  socket.send(encoded);
1252
1236
  }
1253
1237
  }
@@ -1378,6 +1362,9 @@ var InterconnectService = class extends Service {
1378
1362
  this.ctx.logger.info(
1379
1363
  `interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
1380
1364
  );
1365
+ if (remoteErrorOf(error)?.code === "session/agent-busy") {
1366
+ return { reason: "session-owned-by-subagent" };
1367
+ }
1381
1368
  return { reason: "resume-failed" };
1382
1369
  }
1383
1370
  }
@@ -1484,6 +1471,17 @@ var InterconnectService = class extends Service {
1484
1471
  this.attachSocket(websocket);
1485
1472
  });
1486
1473
  }
1474
+ /**
1475
+ * Install frame + liveness handling on one outbound socket the dial owns for
1476
+ * a configured peer, recording that local ownership before anything else can
1477
+ * observe the socket.
1478
+ * @param websocket - the dialed socket.
1479
+ * @param peerInstanceId - the configured peer this instance dials.
1480
+ */
1481
+ attachDialedSocket(websocket, peerInstanceId) {
1482
+ this.dialedPeerOf.set(websocket, peerInstanceId);
1483
+ this.attachSocket(websocket);
1484
+ }
1487
1485
  /**
1488
1486
  * Install frame + liveness handling on one socket and add it to the live
1489
1487
  * pool. Used by both the server half (accepted upgrade) and the client half
@@ -1590,40 +1588,17 @@ var LinkState = class {
1590
1588
  this.instanceId = instanceId;
1591
1589
  this.peer = origin;
1592
1590
  }
1593
- socket;
1594
- dialEpoch = 0;
1591
+ dialed;
1595
1592
  closed = false;
1596
1593
  retry = 0;
1597
1594
  reconnectTimer;
1598
1595
  warnedNoToken = false;
1599
- /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1600
- dialListeners;
1601
- // `peer` is mutable so `reroute` can point the link at a new origin.
1596
+ /** Receiver origin this link dials. */
1602
1597
  peer;
1603
1598
  instanceId;
1604
- /** Point this link at a different origin; re-dials immediately. */
1605
- reroute(origin) {
1606
- if (origin === this.peer) return;
1607
- this.peer = origin;
1608
- this.dialEpoch += 1;
1609
- if (this.reconnectTimer !== void 0) {
1610
- clearTimeout(this.reconnectTimer);
1611
- this.reconnectTimer = void 0;
1612
- }
1613
- const socket = this.socket;
1614
- if (socket !== void 0) {
1615
- this.removeDialListeners(socket);
1616
- socket.on("error", () => {
1617
- });
1618
- socket.terminate();
1619
- this.socket = void 0;
1620
- }
1621
- this.dial();
1622
- }
1623
1599
  /** Open the socket; reconnect is scheduled by the close handler. */
1624
1600
  dial() {
1625
1601
  if (this.closed) return;
1626
- const epoch = this.dialEpoch;
1627
1602
  let url;
1628
1603
  try {
1629
1604
  url = linkUrl(this.peer);
@@ -1637,11 +1612,11 @@ var LinkState = class {
1637
1612
  this.warnedNoToken = true;
1638
1613
  this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
1639
1614
  }
1640
- if (this.closed || epoch !== this.dialEpoch) return;
1615
+ if (this.closed) return;
1641
1616
  this.scheduleReconnect();
1642
1617
  return;
1643
1618
  }
1644
- if (this.closed || epoch !== this.dialEpoch) return;
1619
+ if (this.closed) return;
1645
1620
  const socket = new WebSocket(url, {
1646
1621
  headers: { authorization: `Bearer ${token}` },
1647
1622
  maxPayload: MAX_LINK_FRAME_BYTES,
@@ -1652,7 +1627,6 @@ var LinkState = class {
1652
1627
  // a reconnect — and ws owns the timer, so nothing leaks on teardown.
1653
1628
  handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
1654
1629
  });
1655
- this.socket = socket;
1656
1630
  const onOpen = () => {
1657
1631
  this.retry = 0;
1658
1632
  this.attachSocket(socket);
@@ -1663,49 +1637,38 @@ var LinkState = class {
1663
1637
  };
1664
1638
  const onError = () => {
1665
1639
  };
1666
- this.dialListeners = { open: onOpen, close: onClose, error: onError };
1640
+ this.dialed = { socket, open: onOpen, close: onClose, error: onError };
1667
1641
  socket.once("open", onOpen);
1668
1642
  socket.once("close", onClose);
1669
1643
  socket.on("error", onError);
1670
1644
  }).catch(() => {
1671
- if (this.closed || epoch !== this.dialEpoch) return;
1645
+ if (this.closed) return;
1672
1646
  this.scheduleReconnect();
1673
1647
  });
1674
1648
  }
1675
- /** Whether this link has been closed; a closed state is never reused by `link()`. */
1676
- isClosed() {
1677
- return this.closed;
1678
- }
1679
1649
  /** Whether this link currently holds an open socket that can carry frames. */
1680
1650
  writable() {
1681
- return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
1651
+ const dialed = this.dialed;
1652
+ return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
1682
1653
  }
1683
1654
  /** Write one frame over this link's outbound socket; false when not open. */
1684
1655
  sendFrame(frame) {
1685
- const socket = this.socket;
1686
- if (socket === void 0 || socket.readyState !== WebSocket.OPEN) return false;
1687
- socket.send(JSON.stringify(frame));
1656
+ const dialed = this.dialed;
1657
+ if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
1658
+ dialed.socket.send(JSON.stringify(frame));
1688
1659
  return true;
1689
1660
  }
1690
1661
  close() {
1691
1662
  this.closed = true;
1692
1663
  if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
1693
- const socket = this.socket;
1694
- if (socket !== void 0) {
1695
- this.removeDialListeners(socket);
1696
- socket.on("error", () => {
1697
- });
1698
- socket.terminate();
1699
- }
1700
- }
1701
- /** Detach the dial-registered handlers from a socket before terminating it. */
1702
- removeDialListeners(socket) {
1703
- const listeners = this.dialListeners;
1704
- if (listeners === void 0) return;
1705
- socket.removeListener("open", listeners.open);
1706
- socket.removeListener("close", listeners.close);
1707
- socket.removeListener("error", listeners.error);
1708
- this.dialListeners = void 0;
1664
+ const dialed = this.dialed;
1665
+ if (dialed === void 0) return;
1666
+ dialed.socket.removeListener("open", dialed.open);
1667
+ dialed.socket.removeListener("close", dialed.close);
1668
+ dialed.socket.removeListener("error", dialed.error);
1669
+ dialed.socket.on("error", () => {
1670
+ });
1671
+ dialed.socket.terminate();
1709
1672
  }
1710
1673
  scheduleReconnect() {
1711
1674
  const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
@@ -2,6 +2,7 @@
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import { createHash, randomUUID, timingSafeEqual as constantTimeEqual } from "node:crypto";
4
4
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
5
+ import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
5
6
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
6
7
 
7
8
  // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
@@ -945,6 +946,8 @@ var InterconnectService = class extends Service {
945
946
  heartbeatTimer;
946
947
  /** Peer identity each live socket announced via its `hello` frame, if any. */
947
948
  peerOf = /* @__PURE__ */ new WeakMap();
949
+ /** Configured peer an outbound dial owns a socket for; absent on inbound sockets. */
950
+ dialedPeerOf = /* @__PURE__ */ new WeakMap();
948
951
  /** Sender each local session last received a send from, keyed by local session id. */
949
952
  senders = /* @__PURE__ */ new Map();
950
953
  /** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
@@ -1176,50 +1179,16 @@ var InterconnectService = class extends Service {
1176
1179
  });
1177
1180
  }
1178
1181
  /**
1179
- * Add a peer route at runtime. Returns a disposer that removes it. Re-adding
1180
- * an existing instanceId re-routes it to the new origin.
1181
- * @param instanceId - the peer's `instanceId`.
1182
- * @param origin - origin this instance dials to reach that peer.
1183
- * @returns disposer removing the peer route.
1184
- */
1185
- subscribe(instanceId, origin) {
1186
- const linked = this.link(instanceId, origin);
1187
- const dialedOrigin = trimBase(origin);
1188
- return () => {
1189
- const state = this.linkStates.get(instanceId);
1190
- if (state === linked && state.peer === dialedOrigin) {
1191
- state.close();
1192
- this.linkStates.delete(instanceId);
1193
- }
1194
- };
1195
- }
1196
- /**
1197
- * Remove a peer route, closing its outbound link.
1198
- * @param instanceId - the peer route to remove.
1199
- */
1200
- unsubscribe(instanceId) {
1201
- const state = this.linkStates.get(instanceId);
1202
- if (state !== void 0) state.close();
1203
- this.linkStates.delete(instanceId);
1204
- }
1205
- /**
1206
- * Open (and, on drop, re-open) a persistent WebSocket link to a peer. Local
1207
- * events stream over the link in real time, and events the peer pushes are
1208
- * surfaced as `interconnect/event`. Repeating for the same instanceId
1209
- * re-routes the link to the new origin.
1182
+ * Dial one configured peer route. Activation calls this once per
1183
+ * `Config.peers` entry; the resulting state owns reconnection for the life of
1184
+ * the service fiber.
1210
1185
  * @param instanceId - the peer's `instanceId`.
1211
1186
  * @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
1212
- * @returns a handle closing the link and cancelling reconnection.
1213
1187
  */
1214
1188
  link(instanceId, origin) {
1215
- const existing = this.linkStates.get(instanceId);
1216
- if (existing !== void 0 && !existing.isClosed()) {
1217
- existing.reroute(trimBase(origin));
1218
- return existing;
1219
- }
1220
1189
  const state = new LinkState(
1221
1190
  (socket) => {
1222
- this.attachSocket(socket);
1191
+ this.attachDialedSocket(socket, instanceId);
1223
1192
  },
1224
1193
  this.ctx.logger,
1225
1194
  () => this.resolveToken(),
@@ -1228,26 +1197,41 @@ var InterconnectService = class extends Service {
1228
1197
  );
1229
1198
  this.linkStates.set(instanceId, state);
1230
1199
  state.dial();
1231
- return state;
1232
1200
  }
1233
1201
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1234
1202
  fanout(notification) {
1235
1203
  this.broadcast(notification);
1236
1204
  }
1237
- /** Push one fact over every live WebSocket link, dropping closed sockets. */
1205
+ /**
1206
+ * Push one fact over every live WebSocket link, dropping closed sockets.
1207
+ *
1208
+ * A bidirectional pair owns two sockets to the same peer (this instance's
1209
+ * dialed link plus the peer's inbound one), so one event must leave over one
1210
+ * of them. Which one is decided by local ownership, not by the peer: a dialed
1211
+ * socket is a configured peer's link and always sends. An inbound socket is
1212
+ * skipped only when a dialed link to the id that socket itself announced is
1213
+ * open, so an announcement can suppress nothing but a duplicate the
1214
+ * controlled link already carries — an impostor cannot drop an event for a
1215
+ * peer, and without a dialed link every inbound socket receives it.
1216
+ */
1238
1217
  broadcast(notification) {
1239
1218
  if (this.sockets.size === 0) return;
1240
1219
  const frame = { type: "event", notification };
1241
1220
  const encoded = JSON.stringify(frame);
1242
- const sent = /* @__PURE__ */ new Set();
1221
+ const covered = /* @__PURE__ */ new Set();
1243
1222
  for (const socket of this.sockets) {
1244
1223
  if (socket.readyState !== WebSocket.OPEN) {
1245
1224
  this.sockets.delete(socket);
1246
1225
  continue;
1247
1226
  }
1248
- const peer = this.peerOf.get(socket);
1249
- if (peer !== void 0 && sent.has(peer)) continue;
1250
- if (peer !== void 0) sent.add(peer);
1227
+ const dialedPeer = this.dialedPeerOf.get(socket);
1228
+ if (dialedPeer !== void 0) covered.add(dialedPeer);
1229
+ }
1230
+ for (const socket of this.sockets) {
1231
+ if (this.dialedPeerOf.get(socket) === void 0) {
1232
+ const announced = this.peerOf.get(socket);
1233
+ if (announced !== void 0 && covered.has(announced)) continue;
1234
+ }
1251
1235
  socket.send(encoded);
1252
1236
  }
1253
1237
  }
@@ -1378,6 +1362,9 @@ var InterconnectService = class extends Service {
1378
1362
  this.ctx.logger.info(
1379
1363
  `interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
1380
1364
  );
1365
+ if (remoteErrorOf(error)?.code === "session/agent-busy") {
1366
+ return { reason: "session-owned-by-subagent" };
1367
+ }
1381
1368
  return { reason: "resume-failed" };
1382
1369
  }
1383
1370
  }
@@ -1484,6 +1471,17 @@ var InterconnectService = class extends Service {
1484
1471
  this.attachSocket(websocket);
1485
1472
  });
1486
1473
  }
1474
+ /**
1475
+ * Install frame + liveness handling on one outbound socket the dial owns for
1476
+ * a configured peer, recording that local ownership before anything else can
1477
+ * observe the socket.
1478
+ * @param websocket - the dialed socket.
1479
+ * @param peerInstanceId - the configured peer this instance dials.
1480
+ */
1481
+ attachDialedSocket(websocket, peerInstanceId) {
1482
+ this.dialedPeerOf.set(websocket, peerInstanceId);
1483
+ this.attachSocket(websocket);
1484
+ }
1487
1485
  /**
1488
1486
  * Install frame + liveness handling on one socket and add it to the live
1489
1487
  * pool. Used by both the server half (accepted upgrade) and the client half
@@ -1590,40 +1588,17 @@ var LinkState = class {
1590
1588
  this.instanceId = instanceId;
1591
1589
  this.peer = origin;
1592
1590
  }
1593
- socket;
1594
- dialEpoch = 0;
1591
+ dialed;
1595
1592
  closed = false;
1596
1593
  retry = 0;
1597
1594
  reconnectTimer;
1598
1595
  warnedNoToken = false;
1599
- /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1600
- dialListeners;
1601
- // `peer` is mutable so `reroute` can point the link at a new origin.
1596
+ /** Receiver origin this link dials. */
1602
1597
  peer;
1603
1598
  instanceId;
1604
- /** Point this link at a different origin; re-dials immediately. */
1605
- reroute(origin) {
1606
- if (origin === this.peer) return;
1607
- this.peer = origin;
1608
- this.dialEpoch += 1;
1609
- if (this.reconnectTimer !== void 0) {
1610
- clearTimeout(this.reconnectTimer);
1611
- this.reconnectTimer = void 0;
1612
- }
1613
- const socket = this.socket;
1614
- if (socket !== void 0) {
1615
- this.removeDialListeners(socket);
1616
- socket.on("error", () => {
1617
- });
1618
- socket.terminate();
1619
- this.socket = void 0;
1620
- }
1621
- this.dial();
1622
- }
1623
1599
  /** Open the socket; reconnect is scheduled by the close handler. */
1624
1600
  dial() {
1625
1601
  if (this.closed) return;
1626
- const epoch = this.dialEpoch;
1627
1602
  let url;
1628
1603
  try {
1629
1604
  url = linkUrl(this.peer);
@@ -1637,11 +1612,11 @@ var LinkState = class {
1637
1612
  this.warnedNoToken = true;
1638
1613
  this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
1639
1614
  }
1640
- if (this.closed || epoch !== this.dialEpoch) return;
1615
+ if (this.closed) return;
1641
1616
  this.scheduleReconnect();
1642
1617
  return;
1643
1618
  }
1644
- if (this.closed || epoch !== this.dialEpoch) return;
1619
+ if (this.closed) return;
1645
1620
  const socket = new WebSocket(url, {
1646
1621
  headers: { authorization: `Bearer ${token}` },
1647
1622
  maxPayload: MAX_LINK_FRAME_BYTES,
@@ -1652,7 +1627,6 @@ var LinkState = class {
1652
1627
  // a reconnect — and ws owns the timer, so nothing leaks on teardown.
1653
1628
  handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
1654
1629
  });
1655
- this.socket = socket;
1656
1630
  const onOpen = () => {
1657
1631
  this.retry = 0;
1658
1632
  this.attachSocket(socket);
@@ -1663,49 +1637,38 @@ var LinkState = class {
1663
1637
  };
1664
1638
  const onError = () => {
1665
1639
  };
1666
- this.dialListeners = { open: onOpen, close: onClose, error: onError };
1640
+ this.dialed = { socket, open: onOpen, close: onClose, error: onError };
1667
1641
  socket.once("open", onOpen);
1668
1642
  socket.once("close", onClose);
1669
1643
  socket.on("error", onError);
1670
1644
  }).catch(() => {
1671
- if (this.closed || epoch !== this.dialEpoch) return;
1645
+ if (this.closed) return;
1672
1646
  this.scheduleReconnect();
1673
1647
  });
1674
1648
  }
1675
- /** Whether this link has been closed; a closed state is never reused by `link()`. */
1676
- isClosed() {
1677
- return this.closed;
1678
- }
1679
1649
  /** Whether this link currently holds an open socket that can carry frames. */
1680
1650
  writable() {
1681
- return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
1651
+ const dialed = this.dialed;
1652
+ return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
1682
1653
  }
1683
1654
  /** Write one frame over this link's outbound socket; false when not open. */
1684
1655
  sendFrame(frame) {
1685
- const socket = this.socket;
1686
- if (socket === void 0 || socket.readyState !== WebSocket.OPEN) return false;
1687
- socket.send(JSON.stringify(frame));
1656
+ const dialed = this.dialed;
1657
+ if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
1658
+ dialed.socket.send(JSON.stringify(frame));
1688
1659
  return true;
1689
1660
  }
1690
1661
  close() {
1691
1662
  this.closed = true;
1692
1663
  if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
1693
- const socket = this.socket;
1694
- if (socket !== void 0) {
1695
- this.removeDialListeners(socket);
1696
- socket.on("error", () => {
1697
- });
1698
- socket.terminate();
1699
- }
1700
- }
1701
- /** Detach the dial-registered handlers from a socket before terminating it. */
1702
- removeDialListeners(socket) {
1703
- const listeners = this.dialListeners;
1704
- if (listeners === void 0) return;
1705
- socket.removeListener("open", listeners.open);
1706
- socket.removeListener("close", listeners.close);
1707
- socket.removeListener("error", listeners.error);
1708
- this.dialListeners = void 0;
1664
+ const dialed = this.dialed;
1665
+ if (dialed === void 0) return;
1666
+ dialed.socket.removeListener("open", dialed.open);
1667
+ dialed.socket.removeListener("close", dialed.close);
1668
+ dialed.socket.removeListener("error", dialed.error);
1669
+ dialed.socket.on("error", () => {
1670
+ });
1671
+ dialed.socket.terminate();
1709
1672
  }
1710
1673
  scheduleReconnect() {
1711
1674
  const delay = Math.min(3e4, 1e3 * 2 ** this.retry);