dsh-interconnect 0.10.1 → 0.11.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/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/interconnect/index.ts
2
2
  import { Service } from "@deepseek-ai/cordis";
3
+ import { createHash, randomUUID, timingSafeEqual as constantTimeEqual } from "node:crypto";
3
4
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
5
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
5
6
 
@@ -818,11 +819,6 @@ defineMethod("transform", [
818
819
 
819
820
  // src/interconnect/index.ts
820
821
  import WebSocket, { WebSocketServer } from "ws";
821
-
822
- // src/interconnect/types.ts
823
- var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
824
-
825
- // src/interconnect/index.ts
826
822
  function isSessionOwnedBySubagent(ctx, session, agent) {
827
823
  if (session.header.origin === "subagent") return true;
828
824
  const parentId = session.header.parentSession;
@@ -830,59 +826,74 @@ function isSessionOwnedBySubagent(ctx, session, agent) {
830
826
  const parent = ctx.agents.get(parentId);
831
827
  return parent !== void 0 && ctx.agents.isOwnedBy(agent.id, parent);
832
828
  }
829
+ function assertNever(value) {
830
+ throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
831
+ }
832
+ var INTERCONNECT_CHANNEL = "/interconnect";
833
+ var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
833
834
  var PLUGIN_SOURCE = "dsh-interconnect";
835
+ var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
+ var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
834
837
  var LINK_CHANNEL = "/interconnect/link";
835
838
  var notificationSchema = Schema.union([
836
- Schema.object({ kind: Schema.const("agent/created"), sessionId: Schema.string() }),
837
- Schema.object({ kind: Schema.const("agent/disposed"), sessionId: Schema.string() }),
838
- Schema.object({ kind: Schema.const("agent/status"), sessionId: Schema.string(), status: Schema.union([Schema.const("idle"), Schema.const("running")]) }),
839
- Schema.object({ kind: Schema.const("session/created"), sessionId: Schema.string(), parentSessionId: Schema.string() }),
840
- Schema.object({ kind: Schema.const("session/disposed"), sessionId: Schema.string() }),
841
- Schema.object({ kind: Schema.const("subagent/end"), provider: Schema.string(), childSessionId: Schema.string(), stopReason: Schema.string() })
839
+ Schema.object({ kind: Schema.const("agent/created").required(), sessionId: Schema.string().required() }),
840
+ Schema.object({ kind: Schema.const("agent/disposed").required(), sessionId: Schema.string().required() }),
841
+ Schema.object({ kind: Schema.const("agent/status").required(), sessionId: Schema.string().required(), status: Schema.union([Schema.const("idle"), Schema.const("running")]).required() }),
842
+ Schema.object({ kind: Schema.const("session/created").required(), sessionId: Schema.string().required(), parentSessionId: Schema.string() }),
843
+ Schema.object({ kind: Schema.const("session/disposed").required(), sessionId: Schema.string().required() }),
844
+ Schema.object({ kind: Schema.const("subagent/end").required(), provider: Schema.string().required(), childSessionId: Schema.string().required(), stopReason: Schema.string().required() })
842
845
  ]);
843
846
  var senderSchema = Schema.object({ instanceId: Schema.string(), sessionId: Schema.string() });
844
- var messageSchema = Schema.union([
847
+ var queryResultSchema = Schema.union([
848
+ Schema.object({ pong: Schema.const(true).required(), instance: Schema.string().required() }),
845
849
  Schema.object({
846
- kind: Schema.const("send"),
847
- sessionId: Schema.string(),
848
- text: Schema.string(),
849
- sender: senderSchema,
850
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
851
- resume: Schema.boolean()
850
+ instance: Schema.string().required(),
851
+ sessions: Schema.array(Schema.object({
852
+ sessionId: Schema.string().required(),
853
+ title: Schema.string().required(false),
854
+ status: Schema.string().required(false)
855
+ })).required()
852
856
  }),
853
- Schema.object({
854
- kind: Schema.const("reply"),
855
- sessionId: Schema.string(),
856
- text: Schema.string(),
857
- sender: senderSchema,
858
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
859
- resume: Schema.boolean()
860
- })
857
+ Schema.object({ accepted: Schema.const(true).required() })
861
858
  ]);
859
+ var messageSchema = Schema.object({
860
+ kind: Schema.const("send").required(),
861
+ sessionId: Schema.string().required(),
862
+ text: Schema.string().required(),
863
+ sender: senderSchema,
864
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
865
+ resume: Schema.boolean()
866
+ });
862
867
  var querySchema = Schema.union([
863
- Schema.object({ kind: Schema.const("ping") }),
864
- Schema.object({ kind: Schema.const("list") }),
865
- Schema.object({ kind: Schema.const("event"), notification: notificationSchema })
868
+ Schema.object({ kind: Schema.const("ping").required() }),
869
+ Schema.object({ kind: Schema.const("list").required() }),
870
+ Schema.object({ kind: Schema.const("event").required(), notification: notificationSchema.required() })
871
+ ]);
872
+ var msgResultSchema = Schema.union([
873
+ Schema.object({ delivered: Schema.const(true).required(), instance: Schema.string().required(), delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]) }),
874
+ Schema.object({ delivered: Schema.const(false).required(), instance: Schema.string().required(), reason: Schema.union([
875
+ Schema.const("session-not-live"),
876
+ Schema.const("unreachable"),
877
+ Schema.const("resume-refused"),
878
+ Schema.const("resume-failed"),
879
+ Schema.const("session-owned-by-subagent"),
880
+ Schema.const("no-sender-known")
881
+ ]).required() })
866
882
  ]);
867
883
  var linkFrameSchema = Schema.union([
868
- Schema.object({ type: Schema.const("hello"), sender: Schema.string() }),
869
- Schema.object({ type: Schema.const("event"), notification: notificationSchema }),
870
- Schema.object({ type: Schema.const("msg"), reqId: Schema.string(), message: messageSchema }),
871
- Schema.object({ type: Schema.const("msg-result"), reqId: Schema.string(), result: Schema.object({
872
- delivered: Schema.boolean(),
873
- instance: Schema.string(),
874
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
875
- reason: Schema.string()
876
- }) }),
877
- Schema.object({ type: Schema.const("query"), reqId: Schema.string(), query: querySchema }),
878
- Schema.object({ type: Schema.const("query-result"), reqId: Schema.string(), result: Schema.any() })
884
+ Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
885
+ Schema.object({ type: Schema.const("event").required(), notification: notificationSchema.required() }),
886
+ Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().required(), message: messageSchema.required() }),
887
+ Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().required(), result: msgResultSchema.required() }),
888
+ Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().required(), query: querySchema.required() }),
889
+ Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().required(), result: queryResultSchema.required() })
879
890
  ]);
880
891
  var InterconnectService = class extends Service {
881
- static inject = ["webServer", "agents", "credentials"];
892
+ static inject = ["agents", "credentials"];
882
893
  static Config = Schema.object({
883
894
  instanceId: Schema.string().default("dsh"),
884
895
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
885
- peers: Schema.object({}).default({}),
896
+ peers: Schema.dict(Schema.string()).default({}),
886
897
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
887
898
  allowResume: Schema.boolean().default(true)
888
899
  });
@@ -891,7 +902,7 @@ var InterconnectService = class extends Service {
891
902
  delivery;
892
903
  allowResume;
893
904
  subscriptions = [];
894
- server = new WebSocketServer({ noServer: true });
905
+ server = new WebSocketServer({ noServer: true, maxPayload: MAX_LINK_FRAME_BYTES });
895
906
  sockets = /* @__PURE__ */ new Set();
896
907
  /** Outbound peer links keyed by the peer's `instanceId`. */
897
908
  linkStates = /* @__PURE__ */ new Map();
@@ -903,12 +914,17 @@ var InterconnectService = class extends Service {
903
914
  /** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
904
915
  pendingMessages = /* @__PURE__ */ new Map();
905
916
  reqIdCounter = 0;
917
+ /** Set when the service fiber unwinds; guards late inbound deliveries against driving disposed agents. */
918
+ disposed = false;
906
919
  constructor(ctx, config) {
907
920
  super(ctx, "interconnect");
908
921
  this.instanceId = config.instanceId;
909
922
  this.requestTimeoutMs = config.requestTimeoutMs;
910
923
  this.delivery = config.delivery ?? "followup";
911
924
  this.allowResume = config.allowResume ?? true;
925
+ for (const origin of Object.values(config.peers ?? {})) {
926
+ linkUrl(trimBase(origin));
927
+ }
912
928
  for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
913
929
  this.link(peerInstanceId, origin);
914
930
  }
@@ -918,9 +934,15 @@ var InterconnectService = class extends Service {
918
934
  void this.handleUpgrade(req, socket, head);
919
935
  }
920
936
  };
921
- ctx.effect(() => ctx.webServer.registerUpgrade(upgrade), "interconnect: /interconnect/link websocket");
937
+ ctx.inject(["webServer"], (webCtx) => {
938
+ webCtx.effect(() => webCtx.webServer.registerUpgrade(upgrade), "interconnect: /interconnect/link websocket");
939
+ });
922
940
  this.heartbeatTimer = setInterval(() => {
923
941
  for (const socket of this.sockets) {
942
+ if (socket.readyState !== WebSocket.OPEN) {
943
+ this.sockets.delete(socket);
944
+ continue;
945
+ }
924
946
  if (socket.isAlive === false) {
925
947
  socket.terminate();
926
948
  this.sockets.delete(socket);
@@ -949,6 +971,7 @@ var InterconnectService = class extends Service {
949
971
  });
950
972
  }));
951
973
  this.subscriptions.push(ctx.on("session/disposed", (session) => {
974
+ this.senders.delete(String(session.id));
952
975
  this.fanout({ kind: "session/disposed", sessionId: String(session.id) });
953
976
  }));
954
977
  this.subscriptions.push(ctx.on("subagent/end", (info) => {
@@ -970,16 +993,28 @@ var InterconnectService = class extends Service {
970
993
  for (const socket of this.sockets) socket.terminate();
971
994
  this.sockets.clear();
972
995
  this.server.close();
996
+ for (const pending of this.pendingMessages.values()) {
997
+ clearTimeout(pending.timer);
998
+ pending.resolve?.(void 0);
999
+ }
1000
+ this.pendingMessages.clear();
1001
+ this.disposed = true;
973
1002
  }, "interconnect: websocket teardown");
974
1003
  }
975
1004
  /**
976
1005
  * This instance's identity to attach to outbound messages. Always present:
977
1006
  * it needs no address, only this instance's id and the calling session.
978
1007
  * @param sessionId - the local session that is sending, used as the reply target.
1008
+ * @returns this instance's address-free identity for the calling session.
979
1009
  */
980
1010
  selfSender(sessionId) {
981
1011
  return { instanceId: this.instanceId, sessionId };
982
1012
  }
1013
+ /**
1014
+ * Deliver one text message to a live session on a peer instance.
1015
+ * @param request - the peer instance id, target session, text, and optional delivery/resume overrides.
1016
+ * @returns the peer's answer, or an unreachable result when no link answers.
1017
+ */
983
1018
  async send(request) {
984
1019
  const payload = {
985
1020
  sessionId: request.sessionId,
@@ -988,14 +1023,20 @@ var InterconnectService = class extends Service {
988
1023
  ...request.delivery === void 0 ? {} : { delivery: request.delivery },
989
1024
  ...request.resume === void 0 ? {} : { resume: request.resume }
990
1025
  };
991
- const result = await this.msgRequest(request.instanceId, "send", payload);
992
- return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
1026
+ try {
1027
+ const result = await this.msgRequest(request.instanceId, "send", payload);
1028
+ return result ?? { delivered: false, instance: request.instanceId, reason: "unreachable" };
1029
+ } catch {
1030
+ return { delivered: false, instance: request.instanceId, reason: "unreachable" };
1031
+ }
993
1032
  }
994
1033
  /**
995
1034
  * Deliver one text message back to the peer that a local session last
996
1035
  * received a send from. `sessionId` names the LOCAL replying session; the
997
1036
  * outbound target is the `sender` that session recorded, addressed through
998
1037
  * this instance's own link to the sender's instance.
1038
+ * @param request - the LOCAL replying session id, the reply text, and optional delivery/resume overrides.
1039
+ * @returns the recalled sender's answer, or an unreachable result when no link answers.
999
1040
  */
1000
1041
  async reply(request) {
1001
1042
  const sender = this.senders.get(request.sessionId);
@@ -1011,13 +1052,19 @@ var InterconnectService = class extends Service {
1011
1052
  // back — chaining the conversation.
1012
1053
  sender: this.selfSender(request.sessionId)
1013
1054
  };
1014
- const result = await this.msgRequest(sender.instanceId, "send", payload);
1015
- return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
1055
+ try {
1056
+ const result = await this.msgRequest(sender.instanceId, "send", payload);
1057
+ return result ?? { delivered: false, instance: sender.instanceId, reason: "unreachable" };
1058
+ } catch {
1059
+ return { delivered: false, instance: sender.instanceId, reason: "unreachable" };
1060
+ }
1016
1061
  }
1017
1062
  /**
1018
1063
  * Probe a peer instance for liveness and identity over its persistent link.
1019
1064
  * Returns the peer identity when reachable, or undefined when the link is
1020
1065
  * not up or no answer arrives.
1066
+ * @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
1067
+ * @returns the peer's identity, or undefined when the link is not up or no answer arrives.
1021
1068
  */
1022
1069
  async ping(instanceId) {
1023
1070
  return this.queryRequest(instanceId, { kind: "ping" });
@@ -1025,15 +1072,12 @@ var InterconnectService = class extends Service {
1025
1072
  /**
1026
1073
  * List a peer instance's live sessions over its persistent link. Undefined on
1027
1074
  * transport failure, matching `ping`.
1075
+ * @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
1076
+ * @returns the peer's live session rows, or undefined on transport failure.
1028
1077
  */
1029
1078
  async list(instanceId) {
1030
1079
  return this.queryRequest(instanceId, { kind: "list" });
1031
1080
  }
1032
- /** The origin this instance dials to reach a configured peer, or undefined. */
1033
- originOf(instanceId) {
1034
- const state = this.linkStates.get(instanceId);
1035
- return state?.peer;
1036
- }
1037
1081
  /**
1038
1082
  * Send a `msg` frame over the live link to a peer and resolve with its
1039
1083
  * correlated `msg-result`. Undefined when the peer has no live link here
@@ -1043,7 +1087,7 @@ var InterconnectService = class extends Service {
1043
1087
  async msgRequest(instanceId, kind, payload) {
1044
1088
  const state = this.linkStates.get(instanceId);
1045
1089
  if (state === void 0 || !state.writable()) return void 0;
1046
- const reqId = `m${++this.reqIdCounter}-${crypto.randomUUID()}`;
1090
+ const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1047
1091
  const message = {
1048
1092
  kind,
1049
1093
  sessionId: payload.sessionId,
@@ -1052,7 +1096,7 @@ var InterconnectService = class extends Service {
1052
1096
  ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1053
1097
  ...payload.resume === void 0 ? {} : { resume: payload.resume }
1054
1098
  };
1055
- return this.waitForResult(reqId, (failure) => {
1099
+ return this.waitForResult(reqId, "msg", (failure) => {
1056
1100
  const wrote = state.sendFrame({ type: "msg", reqId, message });
1057
1101
  if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while sending`));
1058
1102
  });
@@ -1061,32 +1105,34 @@ var InterconnectService = class extends Service {
1061
1105
  async queryRequest(instanceId, query) {
1062
1106
  const state = this.linkStates.get(instanceId);
1063
1107
  if (state === void 0 || !state.writable()) return void 0;
1064
- const reqId = `q${++this.reqIdCounter}-${crypto.randomUUID()}`;
1065
- return this.waitForResult(reqId, (failure) => {
1066
- const wrote = state.sendFrame({ type: "query", reqId, query });
1067
- if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while querying`));
1068
- });
1108
+ const reqId = `q${++this.reqIdCounter}-${randomUUID()}`;
1109
+ const accept = query.kind === "ping" ? (result) => typeof result === "object" && result !== null && result.pong === true && typeof result.instance === "string" : (result) => typeof result === "object" && result !== null && Array.isArray(result.sessions) && typeof result.instance === "string";
1110
+ try {
1111
+ return await this.waitForResult(reqId, "query", (failure) => {
1112
+ const wrote = state.sendFrame({ type: "query", reqId, query });
1113
+ if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while querying`));
1114
+ }, accept);
1115
+ } catch {
1116
+ return void 0;
1117
+ }
1069
1118
  }
1070
1119
  /** Settle a `reqId` frame against its `*-result`, timing out at `requestTimeoutMs`. */
1071
- waitForResult(reqId, send) {
1120
+ waitForResult(reqId, kind, send, accept) {
1072
1121
  return new Promise((resolve2, reject) => {
1073
1122
  const timer = setTimeout(() => {
1074
1123
  this.pendingMessages.delete(reqId);
1075
1124
  reject(new Error(`interconnect: no result for ${reqId} within ${this.requestTimeoutMs}ms`));
1076
1125
  }, this.requestTimeoutMs);
1077
- this.pendingMessages.set(reqId, { timer });
1078
- const pending = this.pendingMessages.get(reqId);
1079
- if (pending === void 0) return;
1080
- pending.resolve = (result) => {
1081
- clearTimeout(timer);
1082
- this.pendingMessages.delete(reqId);
1083
- resolve2(result);
1084
- };
1085
- pending.reject = (error) => {
1086
- clearTimeout(timer);
1087
- this.pendingMessages.delete(reqId);
1088
- reject(error instanceof Error ? error : new Error(String(error)));
1089
- };
1126
+ this.pendingMessages.set(reqId, {
1127
+ timer,
1128
+ kind,
1129
+ ...accept === void 0 ? {} : { accept },
1130
+ resolve: (result) => {
1131
+ clearTimeout(timer);
1132
+ this.pendingMessages.delete(reqId);
1133
+ resolve2(result);
1134
+ }
1135
+ });
1090
1136
  send((error) => {
1091
1137
  clearTimeout(timer);
1092
1138
  this.pendingMessages.delete(reqId);
@@ -1102,12 +1148,20 @@ var InterconnectService = class extends Service {
1102
1148
  * @returns disposer removing the peer route.
1103
1149
  */
1104
1150
  subscribe(instanceId, origin) {
1105
- this.link(instanceId, origin);
1151
+ const linked = this.link(instanceId, origin);
1152
+ const dialedOrigin = trimBase(origin);
1106
1153
  return () => {
1107
- this.forgetLink(instanceId);
1154
+ const state = this.linkStates.get(instanceId);
1155
+ if (state === linked && state.peer === dialedOrigin) {
1156
+ state.close();
1157
+ this.linkStates.delete(instanceId);
1158
+ }
1108
1159
  };
1109
1160
  }
1110
- /** Remove a peer route, closing its outbound link. */
1161
+ /**
1162
+ * Remove a peer route, closing its outbound link.
1163
+ * @param instanceId - the peer route to remove.
1164
+ */
1111
1165
  unsubscribe(instanceId) {
1112
1166
  const state = this.linkStates.get(instanceId);
1113
1167
  if (state !== void 0) state.close();
@@ -1124,19 +1178,23 @@ var InterconnectService = class extends Service {
1124
1178
  */
1125
1179
  link(instanceId, origin) {
1126
1180
  const existing = this.linkStates.get(instanceId);
1127
- if (existing !== void 0) {
1181
+ if (existing !== void 0 && !existing.isClosed()) {
1128
1182
  existing.reroute(trimBase(origin));
1129
1183
  return existing;
1130
1184
  }
1131
- const state = new LinkState(this, instanceId, trimBase(origin));
1185
+ const state = new LinkState(
1186
+ (socket) => {
1187
+ this.attachSocket(socket);
1188
+ },
1189
+ this.ctx.logger,
1190
+ () => this.resolveTokenForDial(),
1191
+ instanceId,
1192
+ trimBase(origin)
1193
+ );
1132
1194
  this.linkStates.set(instanceId, state);
1133
1195
  state.dial();
1134
1196
  return state;
1135
1197
  }
1136
- /** Remove a closed outbound link's state so a later `link` re-dials fresh. */
1137
- forgetLink(instanceId) {
1138
- this.linkStates.delete(instanceId);
1139
- }
1140
1198
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1141
1199
  fanout(notification) {
1142
1200
  this.broadcast(notification);
@@ -1146,11 +1204,15 @@ var InterconnectService = class extends Service {
1146
1204
  if (this.sockets.size === 0) return;
1147
1205
  const frame = { type: "event", notification };
1148
1206
  const encoded = JSON.stringify(frame);
1207
+ const sent = /* @__PURE__ */ new Set();
1149
1208
  for (const socket of this.sockets) {
1150
1209
  if (socket.readyState !== WebSocket.OPEN) {
1151
1210
  this.sockets.delete(socket);
1152
1211
  continue;
1153
1212
  }
1213
+ const peer = this.peerOf.get(socket);
1214
+ if (peer !== void 0 && sent.has(peer)) continue;
1215
+ if (peer !== void 0) sent.add(peer);
1154
1216
  socket.send(encoded);
1155
1217
  }
1156
1218
  }
@@ -1160,27 +1222,18 @@ var InterconnectService = class extends Service {
1160
1222
  * result so a throwing handler cannot escape the socket's message callback.
1161
1223
  */
1162
1224
  async handleMsgFrame(socket, reqId, message) {
1163
- let result;
1164
- if (message.kind === "reply") {
1165
- result = await this.replyForSession({
1166
- sessionId: message.sessionId,
1167
- text: message.text,
1168
- ...message.delivery === void 0 ? {} : { delivery: message.delivery },
1169
- ...message.resume === void 0 ? {} : { resume: message.resume }
1170
- });
1171
- } else {
1172
- result = await this.deliver({
1173
- sessionId: message.sessionId,
1174
- text: message.text,
1175
- ...message.sender === void 0 ? {} : { sender: message.sender },
1176
- ...message.delivery === void 0 ? {} : { delivery: message.delivery },
1177
- ...message.resume === void 0 ? {} : { resume: message.resume }
1178
- });
1179
- }
1225
+ const result = await this.deliver({
1226
+ sessionId: message.sessionId,
1227
+ text: message.text,
1228
+ /* v8 ignore next 1 -- schemastery resolves an absent optional `sender` to an empty object, never undefined. */
1229
+ ...message.sender === void 0 ? {} : { sender: message.sender },
1230
+ ...message.delivery === void 0 ? {} : { delivery: message.delivery },
1231
+ ...message.resume === void 0 ? {} : { resume: message.resume }
1232
+ });
1180
1233
  this.sendFrame(socket, { type: "msg-result", reqId, result });
1181
1234
  }
1182
1235
  /** Dispatch one inbound `query` frame (ping/list/event) and answer on the socket. */
1183
- async handleQueryFrame(socket, reqId, query) {
1236
+ handleQueryFrame(socket, reqId, query) {
1184
1237
  let result;
1185
1238
  if (query.kind === "ping") {
1186
1239
  result = { pong: true, instance: this.instanceId };
@@ -1253,7 +1306,9 @@ var InterconnectService = class extends Service {
1253
1306
  if (resolved === void 0 || resolved === null) return { reason: "session-not-live" };
1254
1307
  return { agent: resolved };
1255
1308
  } catch (error) {
1256
- this.ctx.logger.info(`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1309
+ this.ctx.logger.info(
1310
+ `interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
1311
+ );
1257
1312
  return { reason: "resume-failed" };
1258
1313
  }
1259
1314
  }
@@ -1262,9 +1317,11 @@ var InterconnectService = class extends Service {
1262
1317
  * when the sender asked and this receiver allows it.
1263
1318
  */
1264
1319
  async deliver(payload) {
1320
+ if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
1265
1321
  let agent = this.ctx.agents.get(payload.sessionId);
1266
1322
  if (agent === void 0) {
1267
1323
  const woken = await this.wake(payload);
1324
+ if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
1268
1325
  if ("reason" in woken) {
1269
1326
  return { delivered: false, instance: this.instanceId, reason: woken.reason };
1270
1327
  }
@@ -1275,11 +1332,11 @@ var InterconnectService = class extends Service {
1275
1332
  }
1276
1333
  const sender = (() => {
1277
1334
  const candidate = payload.sender;
1278
- return candidate === void 0 || typeof candidate.instanceId !== "string" ? void 0 : candidate;
1335
+ if (typeof candidate !== "object" || candidate === null) return void 0;
1336
+ const record = candidate;
1337
+ if (typeof record.instanceId !== "string" || typeof record.sessionId !== "string") return void 0;
1338
+ return { instanceId: record.instanceId, sessionId: record.sessionId };
1279
1339
  })();
1280
- if (sender !== void 0) {
1281
- this.senders.set(payload.sessionId, sender);
1282
- }
1283
1340
  const message = createUserMessage({
1284
1341
  source: {
1285
1342
  kind: "plugin",
@@ -1302,27 +1359,21 @@ var InterconnectService = class extends Service {
1302
1359
  case "followup":
1303
1360
  agent.followup(message);
1304
1361
  break;
1362
+ /* v8 ignore next -- closed-union exhaustiveness guard */
1363
+ default:
1364
+ assertNever(mode);
1365
+ }
1366
+ if (sender !== void 0) {
1367
+ this.senders.set(payload.sessionId, sender);
1368
+ } else {
1369
+ this.senders.delete(payload.sessionId);
1305
1370
  }
1306
1371
  return { delivered: true, instance: this.instanceId, delivery: mode };
1307
1372
  }
1308
- /**
1309
- * Inbound `reply` dispatch: forward a request to {link reply}, resolving the
1310
- * outbound sender for the named local session. Kept separate from {link
1311
- * deliver} so the two wire endpoints stay distinct in the request pipeline.
1312
- */
1313
- async replyForSession(payload) {
1314
- return this.reply({
1315
- sessionId: payload.sessionId,
1316
- text: payload.text,
1317
- ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1318
- ...payload.resume === void 0 ? {} : { resume: payload.resume }
1319
- });
1320
- }
1321
1373
  async resolveToken() {
1322
1374
  const credential = await this.ctx.credentials.resolve(credentialRef(INTERCONNECT_TOKEN_REF));
1323
1375
  return credential === void 0 || credential.value.length === 0 ? void 0 : credential.value;
1324
1376
  }
1325
- /** Resolve the shared token for an outbound dial (same source as inbound). */
1326
1377
  resolveTokenForDial() {
1327
1378
  return this.resolveToken();
1328
1379
  }
@@ -1353,7 +1404,10 @@ var InterconnectService = class extends Service {
1353
1404
  /**
1354
1405
  * Install frame + liveness handling on one socket and add it to the live
1355
1406
  * pool. Used by both the server half (accepted upgrade) and the client half
1356
- * (outbound dial), so a single socket carries events both directions.
1407
+ * (outbound dial), so a single socket carries events both directions. Not a
1408
+ * model-facing entry point; the outbound dial reaches it through a private
1409
+ * closure.
1410
+ * @param websocket - the accepted or dialed socket to install handlers on.
1357
1411
  */
1358
1412
  attachSocket(websocket) {
1359
1413
  this.sockets.add(websocket);
@@ -1377,30 +1431,42 @@ var InterconnectService = class extends Service {
1377
1431
  /** Parse and route one inbound link frame, attributing events to the socket's announced peer. */
1378
1432
  handleFrame(socket, data) {
1379
1433
  if (Array.isArray(data)) return;
1434
+ if (data.byteLength > MAX_LINK_FRAME_BYTES) {
1435
+ this.ctx.logger.warn("interconnect: dropping oversized link frame");
1436
+ return;
1437
+ }
1380
1438
  const text = Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
1381
- let frame;
1439
+ let parsed;
1382
1440
  try {
1383
- frame = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
1441
+ parsed = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
1384
1442
  } catch {
1385
1443
  this.ctx.logger.warn("interconnect: dropping malformed link frame");
1386
1444
  return;
1387
1445
  }
1446
+ if (parsed === null || typeof parsed !== "object") {
1447
+ this.ctx.logger.warn("interconnect: dropping malformed link frame");
1448
+ return;
1449
+ }
1450
+ const frame = parsed;
1388
1451
  if (frame.type === "hello") {
1389
1452
  this.peerOf.set(socket, frame.sender);
1390
1453
  return;
1391
1454
  }
1392
1455
  if (frame.type === "event") {
1393
1456
  const sender = this.peerOf.get(socket) ?? "unknown-peer";
1457
+ const kind = frame.notification.kind;
1394
1458
  try {
1395
1459
  this.receiveEvent({ sender, notification: frame.notification });
1396
1460
  } catch (error) {
1397
- this.ctx.logger.warn(`interconnect: listener for a ${frame.notification.kind} event from ${sender} threw: ${error instanceof Error ? error.message : String(error)}`);
1461
+ this.ctx.logger.warn(
1462
+ `interconnect: listener for a ${kind} event from ${sender} threw: ` + (error instanceof Error ? error.message : String(error))
1463
+ );
1398
1464
  }
1399
1465
  return;
1400
1466
  }
1401
1467
  if (frame.type === "msg-result") {
1402
1468
  const pending = this.pendingMessages.get(frame.reqId);
1403
- if (pending !== void 0) {
1469
+ if (pending !== void 0 && pending.kind === "msg") {
1404
1470
  ;
1405
1471
  pending.resolve?.(frame.result);
1406
1472
  }
@@ -1408,7 +1474,7 @@ var InterconnectService = class extends Service {
1408
1474
  }
1409
1475
  if (frame.type === "query-result") {
1410
1476
  const pending = this.pendingMessages.get(frame.reqId);
1411
- if (pending !== void 0) {
1477
+ if (pending !== void 0 && pending.kind === "query" && (pending.accept === void 0 || pending.accept(frame.result))) {
1412
1478
  ;
1413
1479
  pending.resolve?.(frame.result);
1414
1480
  }
@@ -1427,8 +1493,8 @@ var InterconnectService = class extends Service {
1427
1493
  });
1428
1494
  return;
1429
1495
  }
1430
- void Promise.resolve().then(async () => {
1431
- await this.handleQueryFrame(socket, frame.reqId, frame.query);
1496
+ void Promise.resolve().then(() => {
1497
+ this.handleQueryFrame(socket, frame.reqId, frame.query);
1432
1498
  }).catch((error) => {
1433
1499
  this.ctx.logger.warn(`interconnect: query ${frame.reqId} handler threw: ${error instanceof Error ? error.message : String(error)}`);
1434
1500
  });
@@ -1441,15 +1507,20 @@ var InterconnectService = class extends Service {
1441
1507
  }
1442
1508
  };
1443
1509
  var LinkState = class {
1444
- constructor(owner, instanceId, origin) {
1445
- this.owner = owner;
1510
+ constructor(attachSocket, logger, resolveToken, instanceId, origin) {
1511
+ this.attachSocket = attachSocket;
1512
+ this.logger = logger;
1513
+ this.resolveToken = resolveToken;
1446
1514
  this.instanceId = instanceId;
1447
1515
  this.peer = origin;
1448
1516
  }
1449
1517
  socket;
1518
+ dialEpoch = 0;
1450
1519
  closed = false;
1451
1520
  retry = 0;
1452
1521
  reconnectTimer;
1522
+ /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1523
+ dialListeners;
1453
1524
  // `peer` is mutable so `reroute` can point the link at a new origin.
1454
1525
  peer;
1455
1526
  instanceId;
@@ -1457,9 +1528,16 @@ var LinkState = class {
1457
1528
  reroute(origin) {
1458
1529
  if (origin === this.peer) return;
1459
1530
  this.peer = origin;
1531
+ this.dialEpoch += 1;
1532
+ if (this.reconnectTimer !== void 0) {
1533
+ clearTimeout(this.reconnectTimer);
1534
+ this.reconnectTimer = void 0;
1535
+ }
1460
1536
  const socket = this.socket;
1461
1537
  if (socket !== void 0) {
1462
- socket.removeAllListeners();
1538
+ this.removeDialListeners(socket);
1539
+ socket.on("error", () => {
1540
+ });
1463
1541
  socket.terminate();
1464
1542
  this.socket = void 0;
1465
1543
  }
@@ -1468,29 +1546,54 @@ var LinkState = class {
1468
1546
  /** Open the socket; reconnect is scheduled by the close handler. */
1469
1547
  dial() {
1470
1548
  if (this.closed) return;
1471
- const url = new URL("/interconnect/link", this.peer);
1472
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1473
- void this.owner.resolveTokenForDial().then((token) => {
1474
- if (token === void 0 || this.closed) return;
1549
+ const epoch = this.dialEpoch;
1550
+ let url;
1551
+ try {
1552
+ url = linkUrl(this.peer);
1553
+ } catch {
1554
+ this.logger.warn(`interconnect: invalid peer origin ${this.peer}; link to ${this.instanceId} stays down`);
1555
+ return;
1556
+ }
1557
+ void this.resolveToken().then((token) => {
1558
+ if (token === void 0) {
1559
+ this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down until one is set`);
1560
+ return;
1561
+ }
1562
+ if (this.closed || epoch !== this.dialEpoch) return;
1475
1563
  const socket = new WebSocket(url, {
1476
- headers: { authorization: `Bearer ${token}` }
1564
+ headers: { authorization: `Bearer ${token}` },
1565
+ maxPayload: MAX_LINK_FRAME_BYTES,
1566
+ // A peer that accepts TCP but never completes the upgrade would leave
1567
+ // the socket in CONNECTING forever: no open (so no heartbeat) and no
1568
+ // close (so no reconnect). ws aborts the connection past the handshake
1569
+ // window, so its own error+close fire and the close handler schedules
1570
+ // a reconnect — and ws owns the timer, so nothing leaks on teardown.
1571
+ handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
1477
1572
  });
1478
1573
  this.socket = socket;
1479
- socket.once("open", () => {
1574
+ const onOpen = () => {
1480
1575
  this.retry = 0;
1481
- this.owner.attachSocket(socket);
1482
- });
1483
- socket.once("close", () => {
1576
+ this.attachSocket(socket);
1577
+ };
1578
+ const onClose = () => {
1484
1579
  if (this.closed) return;
1485
1580
  this.scheduleReconnect();
1486
- });
1487
- socket.on("error", () => {
1488
- });
1581
+ };
1582
+ const onError = () => {
1583
+ };
1584
+ this.dialListeners = { open: onOpen, close: onClose, error: onError };
1585
+ socket.once("open", onOpen);
1586
+ socket.once("close", onClose);
1587
+ socket.on("error", onError);
1489
1588
  }).catch(() => {
1490
- if (this.closed) return;
1589
+ if (this.closed || epoch !== this.dialEpoch) return;
1491
1590
  this.scheduleReconnect();
1492
1591
  });
1493
1592
  }
1593
+ /** Whether this link has been closed; a closed state is never reused by `link()`. */
1594
+ isClosed() {
1595
+ return this.closed;
1596
+ }
1494
1597
  /** Whether this link currently holds an open socket that can carry frames. */
1495
1598
  writable() {
1496
1599
  return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
@@ -1504,14 +1607,24 @@ var LinkState = class {
1504
1607
  }
1505
1608
  close() {
1506
1609
  this.closed = true;
1507
- this.owner.forgetLink(this.peer);
1508
1610
  if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
1509
1611
  const socket = this.socket;
1510
1612
  if (socket !== void 0) {
1511
- socket.removeAllListeners();
1613
+ this.removeDialListeners(socket);
1614
+ socket.on("error", () => {
1615
+ });
1512
1616
  socket.terminate();
1513
1617
  }
1514
1618
  }
1619
+ /** Detach the dial-registered handlers from a socket before terminating it. */
1620
+ removeDialListeners(socket) {
1621
+ const listeners = this.dialListeners;
1622
+ if (listeners === void 0) return;
1623
+ socket.removeListener("open", listeners.open);
1624
+ socket.removeListener("close", listeners.close);
1625
+ socket.removeListener("error", listeners.error);
1626
+ this.dialListeners = void 0;
1627
+ }
1515
1628
  scheduleReconnect() {
1516
1629
  const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
1517
1630
  this.retry += 1;
@@ -1522,18 +1635,23 @@ var LinkState = class {
1522
1635
  }
1523
1636
  };
1524
1637
  function timingSafeEqual(a, b) {
1525
- if (a.length !== b.length) return false;
1526
- let diff = 0;
1527
- for (let i = 0; i < a.length; i += 1) {
1528
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1529
- }
1530
- return diff === 0;
1638
+ return constantTimeEqual(createHash("sha256").update(a, "utf8").digest(), createHash("sha256").update(b, "utf8").digest());
1639
+ }
1640
+ function linkUrl(origin) {
1641
+ const url = new URL("/interconnect/link", origin);
1642
+ if (url.protocol === "http:") url.protocol = "ws:";
1643
+ else if (url.protocol === "https:") url.protocol = "wss:";
1644
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") {
1645
+ throw new TypeError(`interconnect: unsupported peer origin protocol ${url.protocol}`);
1646
+ }
1647
+ return url;
1531
1648
  }
1532
1649
  function trimBase(baseUrl) {
1533
1650
  return baseUrl.replace(/\/+$/, "");
1534
1651
  }
1535
1652
  var interconnect_default = InterconnectService;
1536
1653
  export {
1654
+ INTERCONNECT_CHANNEL,
1537
1655
  INTERCONNECT_TOKEN_REF,
1538
1656
  InterconnectService,
1539
1657
  interconnect_default as default