dsh-interconnect 0.11.12 → 0.11.14

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
 
@@ -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.12",
4
+ "version": "0.11.14",
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,47 @@ 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
+ *
1217
+ * That holds once a peer's announcement has been seen. Between this
1218
+ * instance's dialed link opening and the peer's `hello` arriving on its
1219
+ * inbound socket, the inbound socket is not yet attributable, so an event
1220
+ * emitted in that window leaves over both sockets and reaches that peer
1221
+ * twice. The duplicate is transient and never a dropped event.
1222
+ */
1238
1223
  broadcast(notification) {
1239
1224
  if (this.sockets.size === 0) return;
1240
1225
  const frame = { type: "event", notification };
1241
1226
  const encoded = JSON.stringify(frame);
1242
- const sent = /* @__PURE__ */ new Set();
1227
+ const covered = /* @__PURE__ */ new Set();
1243
1228
  for (const socket of this.sockets) {
1244
1229
  if (socket.readyState !== WebSocket.OPEN) {
1245
1230
  this.sockets.delete(socket);
1246
1231
  continue;
1247
1232
  }
1248
- const peer = this.peerOf.get(socket);
1249
- if (peer !== void 0 && sent.has(peer)) continue;
1250
- if (peer !== void 0) sent.add(peer);
1233
+ const dialedPeer = this.dialedPeerOf.get(socket);
1234
+ if (dialedPeer !== void 0) covered.add(dialedPeer);
1235
+ }
1236
+ for (const socket of this.sockets) {
1237
+ if (this.dialedPeerOf.get(socket) === void 0) {
1238
+ const announced = this.peerOf.get(socket);
1239
+ if (announced !== void 0 && covered.has(announced)) continue;
1240
+ }
1251
1241
  socket.send(encoded);
1252
1242
  }
1253
1243
  }
@@ -1378,6 +1368,9 @@ var InterconnectService = class extends Service {
1378
1368
  this.ctx.logger.info(
1379
1369
  `interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
1380
1370
  );
1371
+ if (remoteErrorOf(error)?.code === "session/agent-busy") {
1372
+ return { reason: "session-owned-by-subagent" };
1373
+ }
1381
1374
  return { reason: "resume-failed" };
1382
1375
  }
1383
1376
  }
@@ -1484,6 +1477,17 @@ var InterconnectService = class extends Service {
1484
1477
  this.attachSocket(websocket);
1485
1478
  });
1486
1479
  }
1480
+ /**
1481
+ * Install frame + liveness handling on one outbound socket the dial owns for
1482
+ * a configured peer, recording that local ownership before anything else can
1483
+ * observe the socket.
1484
+ * @param websocket - the dialed socket.
1485
+ * @param peerInstanceId - the configured peer this instance dials.
1486
+ */
1487
+ attachDialedSocket(websocket, peerInstanceId) {
1488
+ this.dialedPeerOf.set(websocket, peerInstanceId);
1489
+ this.attachSocket(websocket);
1490
+ }
1487
1491
  /**
1488
1492
  * Install frame + liveness handling on one socket and add it to the live
1489
1493
  * pool. Used by both the server half (accepted upgrade) and the client half
@@ -1590,40 +1594,17 @@ var LinkState = class {
1590
1594
  this.instanceId = instanceId;
1591
1595
  this.peer = origin;
1592
1596
  }
1593
- socket;
1594
- dialEpoch = 0;
1597
+ dialed;
1595
1598
  closed = false;
1596
1599
  retry = 0;
1597
1600
  reconnectTimer;
1598
1601
  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.
1602
+ /** Receiver origin this link dials. */
1602
1603
  peer;
1603
1604
  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
1605
  /** Open the socket; reconnect is scheduled by the close handler. */
1624
1606
  dial() {
1625
1607
  if (this.closed) return;
1626
- const epoch = this.dialEpoch;
1627
1608
  let url;
1628
1609
  try {
1629
1610
  url = linkUrl(this.peer);
@@ -1637,11 +1618,11 @@ var LinkState = class {
1637
1618
  this.warnedNoToken = true;
1638
1619
  this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
1639
1620
  }
1640
- if (this.closed || epoch !== this.dialEpoch) return;
1621
+ if (this.closed) return;
1641
1622
  this.scheduleReconnect();
1642
1623
  return;
1643
1624
  }
1644
- if (this.closed || epoch !== this.dialEpoch) return;
1625
+ if (this.closed) return;
1645
1626
  const socket = new WebSocket(url, {
1646
1627
  headers: { authorization: `Bearer ${token}` },
1647
1628
  maxPayload: MAX_LINK_FRAME_BYTES,
@@ -1652,7 +1633,6 @@ var LinkState = class {
1652
1633
  // a reconnect — and ws owns the timer, so nothing leaks on teardown.
1653
1634
  handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
1654
1635
  });
1655
- this.socket = socket;
1656
1636
  const onOpen = () => {
1657
1637
  this.retry = 0;
1658
1638
  this.attachSocket(socket);
@@ -1663,49 +1643,38 @@ var LinkState = class {
1663
1643
  };
1664
1644
  const onError = () => {
1665
1645
  };
1666
- this.dialListeners = { open: onOpen, close: onClose, error: onError };
1646
+ this.dialed = { socket, open: onOpen, close: onClose, error: onError };
1667
1647
  socket.once("open", onOpen);
1668
1648
  socket.once("close", onClose);
1669
1649
  socket.on("error", onError);
1670
1650
  }).catch(() => {
1671
- if (this.closed || epoch !== this.dialEpoch) return;
1651
+ if (this.closed) return;
1672
1652
  this.scheduleReconnect();
1673
1653
  });
1674
1654
  }
1675
- /** Whether this link has been closed; a closed state is never reused by `link()`. */
1676
- isClosed() {
1677
- return this.closed;
1678
- }
1679
1655
  /** Whether this link currently holds an open socket that can carry frames. */
1680
1656
  writable() {
1681
- return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
1657
+ const dialed = this.dialed;
1658
+ return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
1682
1659
  }
1683
1660
  /** Write one frame over this link's outbound socket; false when not open. */
1684
1661
  sendFrame(frame) {
1685
- const socket = this.socket;
1686
- if (socket === void 0 || socket.readyState !== WebSocket.OPEN) return false;
1687
- socket.send(JSON.stringify(frame));
1662
+ const dialed = this.dialed;
1663
+ if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
1664
+ dialed.socket.send(JSON.stringify(frame));
1688
1665
  return true;
1689
1666
  }
1690
1667
  close() {
1691
1668
  this.closed = true;
1692
1669
  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;
1670
+ const dialed = this.dialed;
1671
+ if (dialed === void 0) return;
1672
+ dialed.socket.removeListener("open", dialed.open);
1673
+ dialed.socket.removeListener("close", dialed.close);
1674
+ dialed.socket.removeListener("error", dialed.error);
1675
+ dialed.socket.on("error", () => {
1676
+ });
1677
+ dialed.socket.terminate();
1709
1678
  }
1710
1679
  scheduleReconnect() {
1711
1680
  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,47 @@ 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
+ *
1217
+ * That holds once a peer's announcement has been seen. Between this
1218
+ * instance's dialed link opening and the peer's `hello` arriving on its
1219
+ * inbound socket, the inbound socket is not yet attributable, so an event
1220
+ * emitted in that window leaves over both sockets and reaches that peer
1221
+ * twice. The duplicate is transient and never a dropped event.
1222
+ */
1238
1223
  broadcast(notification) {
1239
1224
  if (this.sockets.size === 0) return;
1240
1225
  const frame = { type: "event", notification };
1241
1226
  const encoded = JSON.stringify(frame);
1242
- const sent = /* @__PURE__ */ new Set();
1227
+ const covered = /* @__PURE__ */ new Set();
1243
1228
  for (const socket of this.sockets) {
1244
1229
  if (socket.readyState !== WebSocket.OPEN) {
1245
1230
  this.sockets.delete(socket);
1246
1231
  continue;
1247
1232
  }
1248
- const peer = this.peerOf.get(socket);
1249
- if (peer !== void 0 && sent.has(peer)) continue;
1250
- if (peer !== void 0) sent.add(peer);
1233
+ const dialedPeer = this.dialedPeerOf.get(socket);
1234
+ if (dialedPeer !== void 0) covered.add(dialedPeer);
1235
+ }
1236
+ for (const socket of this.sockets) {
1237
+ if (this.dialedPeerOf.get(socket) === void 0) {
1238
+ const announced = this.peerOf.get(socket);
1239
+ if (announced !== void 0 && covered.has(announced)) continue;
1240
+ }
1251
1241
  socket.send(encoded);
1252
1242
  }
1253
1243
  }
@@ -1378,6 +1368,9 @@ var InterconnectService = class extends Service {
1378
1368
  this.ctx.logger.info(
1379
1369
  `interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
1380
1370
  );
1371
+ if (remoteErrorOf(error)?.code === "session/agent-busy") {
1372
+ return { reason: "session-owned-by-subagent" };
1373
+ }
1381
1374
  return { reason: "resume-failed" };
1382
1375
  }
1383
1376
  }
@@ -1484,6 +1477,17 @@ var InterconnectService = class extends Service {
1484
1477
  this.attachSocket(websocket);
1485
1478
  });
1486
1479
  }
1480
+ /**
1481
+ * Install frame + liveness handling on one outbound socket the dial owns for
1482
+ * a configured peer, recording that local ownership before anything else can
1483
+ * observe the socket.
1484
+ * @param websocket - the dialed socket.
1485
+ * @param peerInstanceId - the configured peer this instance dials.
1486
+ */
1487
+ attachDialedSocket(websocket, peerInstanceId) {
1488
+ this.dialedPeerOf.set(websocket, peerInstanceId);
1489
+ this.attachSocket(websocket);
1490
+ }
1487
1491
  /**
1488
1492
  * Install frame + liveness handling on one socket and add it to the live
1489
1493
  * pool. Used by both the server half (accepted upgrade) and the client half
@@ -1590,40 +1594,17 @@ var LinkState = class {
1590
1594
  this.instanceId = instanceId;
1591
1595
  this.peer = origin;
1592
1596
  }
1593
- socket;
1594
- dialEpoch = 0;
1597
+ dialed;
1595
1598
  closed = false;
1596
1599
  retry = 0;
1597
1600
  reconnectTimer;
1598
1601
  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.
1602
+ /** Receiver origin this link dials. */
1602
1603
  peer;
1603
1604
  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
1605
  /** Open the socket; reconnect is scheduled by the close handler. */
1624
1606
  dial() {
1625
1607
  if (this.closed) return;
1626
- const epoch = this.dialEpoch;
1627
1608
  let url;
1628
1609
  try {
1629
1610
  url = linkUrl(this.peer);
@@ -1637,11 +1618,11 @@ var LinkState = class {
1637
1618
  this.warnedNoToken = true;
1638
1619
  this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
1639
1620
  }
1640
- if (this.closed || epoch !== this.dialEpoch) return;
1621
+ if (this.closed) return;
1641
1622
  this.scheduleReconnect();
1642
1623
  return;
1643
1624
  }
1644
- if (this.closed || epoch !== this.dialEpoch) return;
1625
+ if (this.closed) return;
1645
1626
  const socket = new WebSocket(url, {
1646
1627
  headers: { authorization: `Bearer ${token}` },
1647
1628
  maxPayload: MAX_LINK_FRAME_BYTES,
@@ -1652,7 +1633,6 @@ var LinkState = class {
1652
1633
  // a reconnect — and ws owns the timer, so nothing leaks on teardown.
1653
1634
  handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
1654
1635
  });
1655
- this.socket = socket;
1656
1636
  const onOpen = () => {
1657
1637
  this.retry = 0;
1658
1638
  this.attachSocket(socket);
@@ -1663,49 +1643,38 @@ var LinkState = class {
1663
1643
  };
1664
1644
  const onError = () => {
1665
1645
  };
1666
- this.dialListeners = { open: onOpen, close: onClose, error: onError };
1646
+ this.dialed = { socket, open: onOpen, close: onClose, error: onError };
1667
1647
  socket.once("open", onOpen);
1668
1648
  socket.once("close", onClose);
1669
1649
  socket.on("error", onError);
1670
1650
  }).catch(() => {
1671
- if (this.closed || epoch !== this.dialEpoch) return;
1651
+ if (this.closed) return;
1672
1652
  this.scheduleReconnect();
1673
1653
  });
1674
1654
  }
1675
- /** Whether this link has been closed; a closed state is never reused by `link()`. */
1676
- isClosed() {
1677
- return this.closed;
1678
- }
1679
1655
  /** Whether this link currently holds an open socket that can carry frames. */
1680
1656
  writable() {
1681
- return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
1657
+ const dialed = this.dialed;
1658
+ return dialed !== void 0 && dialed.socket.readyState === WebSocket.OPEN;
1682
1659
  }
1683
1660
  /** Write one frame over this link's outbound socket; false when not open. */
1684
1661
  sendFrame(frame) {
1685
- const socket = this.socket;
1686
- if (socket === void 0 || socket.readyState !== WebSocket.OPEN) return false;
1687
- socket.send(JSON.stringify(frame));
1662
+ const dialed = this.dialed;
1663
+ if (dialed === void 0 || dialed.socket.readyState !== WebSocket.OPEN) return false;
1664
+ dialed.socket.send(JSON.stringify(frame));
1688
1665
  return true;
1689
1666
  }
1690
1667
  close() {
1691
1668
  this.closed = true;
1692
1669
  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;
1670
+ const dialed = this.dialed;
1671
+ if (dialed === void 0) return;
1672
+ dialed.socket.removeListener("open", dialed.open);
1673
+ dialed.socket.removeListener("close", dialed.close);
1674
+ dialed.socket.removeListener("error", dialed.error);
1675
+ dialed.socket.on("error", () => {
1676
+ });
1677
+ dialed.socket.terminate();
1709
1678
  }
1710
1679
  scheduleReconnect() {
1711
1680
  const delay = Math.min(3e4, 1e3 * 2 ** this.retry);