dsh-interconnect 0.10.1 → 0.11.1

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.
@@ -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,84 @@ 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_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
833
833
  var PLUGIN_SOURCE = "dsh-interconnect";
834
+ var MAX_LINK_FRAME_BYTES = 1024 * 1024;
835
+ var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
834
836
  var LINK_CHANNEL = "/interconnect/link";
837
+ var MAX_LISTED_SESSIONS = 100;
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([
845
- 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()
852
- }),
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
- })
847
+ var pingResultSchema = Schema.object({ pong: Schema.const(true).required(), instance: Schema.string().required() });
848
+ var listResultSchema = Schema.object({
849
+ instance: Schema.string().required(),
850
+ sessions: Schema.array(Schema.object({
851
+ sessionId: Schema.string().required(),
852
+ title: Schema.string().required(false),
853
+ status: Schema.string().required(false)
854
+ })).required()
855
+ });
856
+ var acceptsQueryResult = (schema, result) => {
857
+ try {
858
+ const [parsed] = Schema.resolve(result, schema, {});
859
+ return parsed !== void 0 && parsed !== null;
860
+ } catch {
861
+ return false;
862
+ }
863
+ };
864
+ var queryResultSchema = Schema.union([
865
+ pingResultSchema,
866
+ listResultSchema,
867
+ Schema.object({ accepted: Schema.const(true).required() })
861
868
  ]);
869
+ var messageSchema = Schema.object({
870
+ kind: Schema.const("send").required(),
871
+ sessionId: Schema.string().required(),
872
+ text: Schema.string().required(),
873
+ sender: senderSchema,
874
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
875
+ resume: Schema.boolean()
876
+ });
862
877
  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 })
878
+ Schema.object({ kind: Schema.const("ping").required() }),
879
+ Schema.object({ kind: Schema.const("list").required() }),
880
+ Schema.object({ kind: Schema.const("event").required(), notification: notificationSchema.required() })
881
+ ]);
882
+ var msgResultSchema = Schema.union([
883
+ Schema.object({ delivered: Schema.const(true).required(), instance: Schema.string().required(), delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]) }),
884
+ Schema.object({ delivered: Schema.const(false).required(), instance: Schema.string().required(), reason: Schema.union([
885
+ Schema.const("session-not-live"),
886
+ Schema.const("unreachable"),
887
+ Schema.const("resume-refused"),
888
+ Schema.const("resume-failed"),
889
+ Schema.const("session-owned-by-subagent"),
890
+ Schema.const("no-sender-known")
891
+ ]).required() })
866
892
  ]);
867
893
  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() })
894
+ Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
895
+ Schema.object({ type: Schema.const("event").required(), notification: notificationSchema.required() }),
896
+ Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().required(), message: messageSchema.required() }),
897
+ Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().required(), result: msgResultSchema.required() }),
898
+ Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().required(), query: querySchema.required() }),
899
+ Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().required(), result: queryResultSchema.required() })
879
900
  ]);
880
901
  var InterconnectService = class extends Service {
881
- static inject = ["webServer", "agents", "credentials"];
902
+ static inject = ["agents", "credentials"];
882
903
  static Config = Schema.object({
883
904
  instanceId: Schema.string().default("dsh"),
884
905
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
885
- peers: Schema.object({}).default({}),
906
+ peers: Schema.dict(Schema.string()).default({}),
886
907
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
887
908
  allowResume: Schema.boolean().default(true)
888
909
  });
@@ -891,7 +912,7 @@ var InterconnectService = class extends Service {
891
912
  delivery;
892
913
  allowResume;
893
914
  subscriptions = [];
894
- server = new WebSocketServer({ noServer: true });
915
+ server = new WebSocketServer({ noServer: true, maxPayload: MAX_LINK_FRAME_BYTES });
895
916
  sockets = /* @__PURE__ */ new Set();
896
917
  /** Outbound peer links keyed by the peer's `instanceId`. */
897
918
  linkStates = /* @__PURE__ */ new Map();
@@ -903,24 +924,38 @@ var InterconnectService = class extends Service {
903
924
  /** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
904
925
  pendingMessages = /* @__PURE__ */ new Map();
905
926
  reqIdCounter = 0;
927
+ /** Set when the service fiber unwinds; guards late inbound deliveries against driving disposed agents. */
928
+ disposed = false;
906
929
  constructor(ctx, config) {
907
930
  super(ctx, "interconnect");
908
931
  this.instanceId = config.instanceId;
909
932
  this.requestTimeoutMs = config.requestTimeoutMs;
910
933
  this.delivery = config.delivery ?? "followup";
911
934
  this.allowResume = config.allowResume ?? true;
935
+ for (const origin of Object.values(config.peers ?? {})) {
936
+ linkUrl(trimBase(origin));
937
+ }
912
938
  for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
913
939
  this.link(peerInstanceId, origin);
914
940
  }
915
941
  const upgrade = {
916
942
  path: LINK_CHANNEL,
917
943
  handler: (req, socket, head) => {
918
- void this.handleUpgrade(req, socket, head);
944
+ void this.handleUpgrade(req, socket, head).catch((error) => {
945
+ this.ctx.logger.warn(`interconnect: upgrade failed: ${error instanceof Error ? error.message : String(error)}`);
946
+ socket.destroy();
947
+ });
919
948
  }
920
949
  };
921
- ctx.effect(() => ctx.webServer.registerUpgrade(upgrade), "interconnect: /interconnect/link websocket");
950
+ ctx.inject(["webServer"], (webCtx) => {
951
+ webCtx.effect(() => webCtx.webServer.registerUpgrade(upgrade), "interconnect: /interconnect/link websocket");
952
+ });
922
953
  this.heartbeatTimer = setInterval(() => {
923
954
  for (const socket of this.sockets) {
955
+ if (socket.readyState !== WebSocket.OPEN) {
956
+ this.sockets.delete(socket);
957
+ continue;
958
+ }
924
959
  if (socket.isAlive === false) {
925
960
  socket.terminate();
926
961
  this.sockets.delete(socket);
@@ -949,6 +984,7 @@ var InterconnectService = class extends Service {
949
984
  });
950
985
  }));
951
986
  this.subscriptions.push(ctx.on("session/disposed", (session) => {
987
+ this.senders.delete(String(session.id));
952
988
  this.fanout({ kind: "session/disposed", sessionId: String(session.id) });
953
989
  }));
954
990
  this.subscriptions.push(ctx.on("subagent/end", (info) => {
@@ -970,16 +1006,28 @@ var InterconnectService = class extends Service {
970
1006
  for (const socket of this.sockets) socket.terminate();
971
1007
  this.sockets.clear();
972
1008
  this.server.close();
1009
+ for (const pending of this.pendingMessages.values()) {
1010
+ clearTimeout(pending.timer);
1011
+ pending.resolve?.(void 0);
1012
+ }
1013
+ this.pendingMessages.clear();
1014
+ this.disposed = true;
973
1015
  }, "interconnect: websocket teardown");
974
1016
  }
975
1017
  /**
976
1018
  * This instance's identity to attach to outbound messages. Always present:
977
1019
  * it needs no address, only this instance's id and the calling session.
978
1020
  * @param sessionId - the local session that is sending, used as the reply target.
1021
+ * @returns this instance's address-free identity for the calling session.
979
1022
  */
980
1023
  selfSender(sessionId) {
981
1024
  return { instanceId: this.instanceId, sessionId };
982
1025
  }
1026
+ /**
1027
+ * Deliver one text message to a live session on a peer instance.
1028
+ * @param request - the peer instance id, target session, text, and optional delivery/resume overrides.
1029
+ * @returns the peer's answer, or an unreachable result when no link answers.
1030
+ */
983
1031
  async send(request) {
984
1032
  const payload = {
985
1033
  sessionId: request.sessionId,
@@ -988,14 +1036,20 @@ var InterconnectService = class extends Service {
988
1036
  ...request.delivery === void 0 ? {} : { delivery: request.delivery },
989
1037
  ...request.resume === void 0 ? {} : { resume: request.resume }
990
1038
  };
991
- const result = await this.msgRequest(request.instanceId, "send", payload);
992
- return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
1039
+ try {
1040
+ const result = await this.msgRequest(request.instanceId, "send", payload);
1041
+ return result ?? { delivered: false, instance: request.instanceId, reason: "unreachable" };
1042
+ } catch {
1043
+ return { delivered: false, instance: request.instanceId, reason: "unreachable" };
1044
+ }
993
1045
  }
994
1046
  /**
995
1047
  * Deliver one text message back to the peer that a local session last
996
1048
  * received a send from. `sessionId` names the LOCAL replying session; the
997
1049
  * outbound target is the `sender` that session recorded, addressed through
998
1050
  * this instance's own link to the sender's instance.
1051
+ * @param request - the LOCAL replying session id, the reply text, and optional delivery/resume overrides.
1052
+ * @returns the recalled sender's answer, or an unreachable result when no link answers.
999
1053
  */
1000
1054
  async reply(request) {
1001
1055
  const sender = this.senders.get(request.sessionId);
@@ -1011,13 +1065,19 @@ var InterconnectService = class extends Service {
1011
1065
  // back — chaining the conversation.
1012
1066
  sender: this.selfSender(request.sessionId)
1013
1067
  };
1014
- const result = await this.msgRequest(sender.instanceId, "send", payload);
1015
- return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
1068
+ try {
1069
+ const result = await this.msgRequest(sender.instanceId, "send", payload);
1070
+ return result ?? { delivered: false, instance: sender.instanceId, reason: "unreachable" };
1071
+ } catch {
1072
+ return { delivered: false, instance: sender.instanceId, reason: "unreachable" };
1073
+ }
1016
1074
  }
1017
1075
  /**
1018
1076
  * Probe a peer instance for liveness and identity over its persistent link.
1019
1077
  * Returns the peer identity when reachable, or undefined when the link is
1020
1078
  * not up or no answer arrives.
1079
+ * @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
1080
+ * @returns the peer's identity, or undefined when the link is not up or no answer arrives.
1021
1081
  */
1022
1082
  async ping(instanceId) {
1023
1083
  return this.queryRequest(instanceId, { kind: "ping" });
@@ -1025,15 +1085,12 @@ var InterconnectService = class extends Service {
1025
1085
  /**
1026
1086
  * List a peer instance's live sessions over its persistent link. Undefined on
1027
1087
  * transport failure, matching `ping`.
1088
+ * @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
1089
+ * @returns the peer's live session rows, or undefined on transport failure.
1028
1090
  */
1029
1091
  async list(instanceId) {
1030
1092
  return this.queryRequest(instanceId, { kind: "list" });
1031
1093
  }
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
1094
  /**
1038
1095
  * Send a `msg` frame over the live link to a peer and resolve with its
1039
1096
  * correlated `msg-result`. Undefined when the peer has no live link here
@@ -1043,7 +1100,7 @@ var InterconnectService = class extends Service {
1043
1100
  async msgRequest(instanceId, kind, payload) {
1044
1101
  const state = this.linkStates.get(instanceId);
1045
1102
  if (state === void 0 || !state.writable()) return void 0;
1046
- const reqId = `m${++this.reqIdCounter}-${crypto.randomUUID()}`;
1103
+ const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1047
1104
  const message = {
1048
1105
  kind,
1049
1106
  sessionId: payload.sessionId,
@@ -1052,7 +1109,7 @@ var InterconnectService = class extends Service {
1052
1109
  ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1053
1110
  ...payload.resume === void 0 ? {} : { resume: payload.resume }
1054
1111
  };
1055
- return this.waitForResult(reqId, (failure) => {
1112
+ return this.waitForResult(reqId, "msg", (failure) => {
1056
1113
  const wrote = state.sendFrame({ type: "msg", reqId, message });
1057
1114
  if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while sending`));
1058
1115
  });
@@ -1061,32 +1118,34 @@ var InterconnectService = class extends Service {
1061
1118
  async queryRequest(instanceId, query) {
1062
1119
  const state = this.linkStates.get(instanceId);
1063
1120
  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
- });
1121
+ const reqId = `q${++this.reqIdCounter}-${randomUUID()}`;
1122
+ const accept = query.kind === "ping" ? (result) => acceptsQueryResult(pingResultSchema, result) : (result) => acceptsQueryResult(listResultSchema, result);
1123
+ try {
1124
+ return await this.waitForResult(reqId, "query", (failure) => {
1125
+ const wrote = state.sendFrame({ type: "query", reqId, query });
1126
+ if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while querying`));
1127
+ }, accept);
1128
+ } catch {
1129
+ return void 0;
1130
+ }
1069
1131
  }
1070
1132
  /** Settle a `reqId` frame against its `*-result`, timing out at `requestTimeoutMs`. */
1071
- waitForResult(reqId, send) {
1133
+ waitForResult(reqId, kind, send, accept) {
1072
1134
  return new Promise((resolve2, reject) => {
1073
1135
  const timer = setTimeout(() => {
1074
1136
  this.pendingMessages.delete(reqId);
1075
1137
  reject(new Error(`interconnect: no result for ${reqId} within ${this.requestTimeoutMs}ms`));
1076
1138
  }, 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
- };
1139
+ this.pendingMessages.set(reqId, {
1140
+ timer,
1141
+ kind,
1142
+ ...accept === void 0 ? {} : { accept },
1143
+ resolve: (result) => {
1144
+ clearTimeout(timer);
1145
+ this.pendingMessages.delete(reqId);
1146
+ resolve2(result);
1147
+ }
1148
+ });
1090
1149
  send((error) => {
1091
1150
  clearTimeout(timer);
1092
1151
  this.pendingMessages.delete(reqId);
@@ -1102,12 +1161,20 @@ var InterconnectService = class extends Service {
1102
1161
  * @returns disposer removing the peer route.
1103
1162
  */
1104
1163
  subscribe(instanceId, origin) {
1105
- this.link(instanceId, origin);
1164
+ const linked = this.link(instanceId, origin);
1165
+ const dialedOrigin = trimBase(origin);
1106
1166
  return () => {
1107
- this.forgetLink(instanceId);
1167
+ const state = this.linkStates.get(instanceId);
1168
+ if (state === linked && state.peer === dialedOrigin) {
1169
+ state.close();
1170
+ this.linkStates.delete(instanceId);
1171
+ }
1108
1172
  };
1109
1173
  }
1110
- /** Remove a peer route, closing its outbound link. */
1174
+ /**
1175
+ * Remove a peer route, closing its outbound link.
1176
+ * @param instanceId - the peer route to remove.
1177
+ */
1111
1178
  unsubscribe(instanceId) {
1112
1179
  const state = this.linkStates.get(instanceId);
1113
1180
  if (state !== void 0) state.close();
@@ -1124,19 +1191,23 @@ var InterconnectService = class extends Service {
1124
1191
  */
1125
1192
  link(instanceId, origin) {
1126
1193
  const existing = this.linkStates.get(instanceId);
1127
- if (existing !== void 0) {
1194
+ if (existing !== void 0 && !existing.isClosed()) {
1128
1195
  existing.reroute(trimBase(origin));
1129
1196
  return existing;
1130
1197
  }
1131
- const state = new LinkState(this, instanceId, trimBase(origin));
1198
+ const state = new LinkState(
1199
+ (socket) => {
1200
+ this.attachSocket(socket);
1201
+ },
1202
+ this.ctx.logger,
1203
+ () => this.resolveTokenForDial(),
1204
+ instanceId,
1205
+ trimBase(origin)
1206
+ );
1132
1207
  this.linkStates.set(instanceId, state);
1133
1208
  state.dial();
1134
1209
  return state;
1135
1210
  }
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
1211
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1141
1212
  fanout(notification) {
1142
1213
  this.broadcast(notification);
@@ -1146,11 +1217,15 @@ var InterconnectService = class extends Service {
1146
1217
  if (this.sockets.size === 0) return;
1147
1218
  const frame = { type: "event", notification };
1148
1219
  const encoded = JSON.stringify(frame);
1220
+ const sent = /* @__PURE__ */ new Set();
1149
1221
  for (const socket of this.sockets) {
1150
1222
  if (socket.readyState !== WebSocket.OPEN) {
1151
1223
  this.sockets.delete(socket);
1152
1224
  continue;
1153
1225
  }
1226
+ const peer = this.peerOf.get(socket);
1227
+ if (peer !== void 0 && sent.has(peer)) continue;
1228
+ if (peer !== void 0) sent.add(peer);
1154
1229
  socket.send(encoded);
1155
1230
  }
1156
1231
  }
@@ -1160,27 +1235,18 @@ var InterconnectService = class extends Service {
1160
1235
  * result so a throwing handler cannot escape the socket's message callback.
1161
1236
  */
1162
1237
  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
- }
1238
+ const result = await this.deliver({
1239
+ sessionId: message.sessionId,
1240
+ text: message.text,
1241
+ /* v8 ignore next 1 -- schemastery resolves an absent optional `sender` to an empty object, never undefined. */
1242
+ ...message.sender === void 0 ? {} : { sender: message.sender },
1243
+ ...message.delivery === void 0 ? {} : { delivery: message.delivery },
1244
+ ...message.resume === void 0 ? {} : { resume: message.resume }
1245
+ });
1180
1246
  this.sendFrame(socket, { type: "msg-result", reqId, result });
1181
1247
  }
1182
1248
  /** Dispatch one inbound `query` frame (ping/list/event) and answer on the socket. */
1183
- async handleQueryFrame(socket, reqId, query) {
1249
+ handleQueryFrame(socket, reqId, query) {
1184
1250
  let result;
1185
1251
  if (query.kind === "ping") {
1186
1252
  result = { pong: true, instance: this.instanceId };
@@ -1202,9 +1268,11 @@ var InterconnectService = class extends Service {
1202
1268
  this.ctx.emit("interconnect/event", eventPayload.notification, eventPayload.sender);
1203
1269
  }
1204
1270
  /**
1205
- * Summarize every live local session so a sender can discover valid targets
1271
+ * Summarize the live local sessions so a sender can discover valid targets
1206
1272
  * instead of having to know a session id already. Only live agents are listed
1207
- * because `send` can reach exactly those.
1273
+ * because `send` can reach exactly those, and the answer carries at most
1274
+ * {@link MAX_LISTED_SESSIONS} rows so a large live set cannot push the
1275
+ * `query-result` frame past the link's frame cap.
1208
1276
  *
1209
1277
  * Title and status are best-effort: the title projection is an optional
1210
1278
  * service, and a receiver without it still returns the ids. A projection that
@@ -1213,7 +1281,7 @@ var InterconnectService = class extends Service {
1213
1281
  */
1214
1282
  listSessions() {
1215
1283
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1216
- const sessions = reachable.map((agent) => {
1284
+ const sessions = reachable.slice(0, MAX_LISTED_SESSIONS).map((agent) => {
1217
1285
  let title;
1218
1286
  try {
1219
1287
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
@@ -1253,7 +1321,9 @@ var InterconnectService = class extends Service {
1253
1321
  if (resolved === void 0 || resolved === null) return { reason: "session-not-live" };
1254
1322
  return { agent: resolved };
1255
1323
  } catch (error) {
1256
- this.ctx.logger.info(`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1324
+ this.ctx.logger.info(
1325
+ `interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
1326
+ );
1257
1327
  return { reason: "resume-failed" };
1258
1328
  }
1259
1329
  }
@@ -1262,9 +1332,11 @@ var InterconnectService = class extends Service {
1262
1332
  * when the sender asked and this receiver allows it.
1263
1333
  */
1264
1334
  async deliver(payload) {
1335
+ if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
1265
1336
  let agent = this.ctx.agents.get(payload.sessionId);
1266
1337
  if (agent === void 0) {
1267
1338
  const woken = await this.wake(payload);
1339
+ if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
1268
1340
  if ("reason" in woken) {
1269
1341
  return { delivered: false, instance: this.instanceId, reason: woken.reason };
1270
1342
  }
@@ -1275,11 +1347,11 @@ var InterconnectService = class extends Service {
1275
1347
  }
1276
1348
  const sender = (() => {
1277
1349
  const candidate = payload.sender;
1278
- return candidate === void 0 || typeof candidate.instanceId !== "string" ? void 0 : candidate;
1350
+ if (typeof candidate !== "object" || candidate === null) return void 0;
1351
+ const record = candidate;
1352
+ if (typeof record.instanceId !== "string" || typeof record.sessionId !== "string") return void 0;
1353
+ return { instanceId: record.instanceId, sessionId: record.sessionId };
1279
1354
  })();
1280
- if (sender !== void 0) {
1281
- this.senders.set(payload.sessionId, sender);
1282
- }
1283
1355
  const message = createUserMessage({
1284
1356
  source: {
1285
1357
  kind: "plugin",
@@ -1302,27 +1374,21 @@ var InterconnectService = class extends Service {
1302
1374
  case "followup":
1303
1375
  agent.followup(message);
1304
1376
  break;
1377
+ /* v8 ignore next -- closed-union exhaustiveness guard */
1378
+ default:
1379
+ assertNever(mode);
1380
+ }
1381
+ if (sender !== void 0) {
1382
+ this.senders.set(payload.sessionId, sender);
1383
+ } else {
1384
+ this.senders.delete(payload.sessionId);
1305
1385
  }
1306
1386
  return { delivered: true, instance: this.instanceId, delivery: mode };
1307
1387
  }
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
1388
  async resolveToken() {
1322
1389
  const credential = await this.ctx.credentials.resolve(credentialRef(INTERCONNECT_TOKEN_REF));
1323
1390
  return credential === void 0 || credential.value.length === 0 ? void 0 : credential.value;
1324
1391
  }
1325
- /** Resolve the shared token for an outbound dial (same source as inbound). */
1326
1392
  resolveTokenForDial() {
1327
1393
  return this.resolveToken();
1328
1394
  }
@@ -1340,6 +1406,10 @@ var InterconnectService = class extends Service {
1340
1406
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 9\r\n\r\nforbidden");
1341
1407
  return;
1342
1408
  }
1409
+ if (this.disposed) {
1410
+ socket.end("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 11\r\n\r\nunavailable");
1411
+ return;
1412
+ }
1343
1413
  const expected = `Bearer ${token}`;
1344
1414
  const header = req.headers.authorization;
1345
1415
  if (header === void 0 || !timingSafeEqual(header, expected)) {
@@ -1353,7 +1423,10 @@ var InterconnectService = class extends Service {
1353
1423
  /**
1354
1424
  * Install frame + liveness handling on one socket and add it to the live
1355
1425
  * pool. Used by both the server half (accepted upgrade) and the client half
1356
- * (outbound dial), so a single socket carries events both directions.
1426
+ * (outbound dial), so a single socket carries events both directions. Not a
1427
+ * model-facing entry point; the outbound dial reaches it through a private
1428
+ * closure.
1429
+ * @param websocket - the accepted or dialed socket to install handlers on.
1357
1430
  */
1358
1431
  attachSocket(websocket) {
1359
1432
  this.sockets.add(websocket);
@@ -1362,7 +1435,8 @@ var InterconnectService = class extends Service {
1362
1435
  ;
1363
1436
  websocket.isAlive = true;
1364
1437
  });
1365
- websocket.on("message", (data) => {
1438
+ websocket.on("message", (data, isBinary) => {
1439
+ if (isBinary) return;
1366
1440
  this.handleFrame(websocket, data);
1367
1441
  });
1368
1442
  websocket.on("close", () => {
@@ -1376,31 +1450,43 @@ var InterconnectService = class extends Service {
1376
1450
  }
1377
1451
  /** Parse and route one inbound link frame, attributing events to the socket's announced peer. */
1378
1452
  handleFrame(socket, data) {
1379
- if (Array.isArray(data)) return;
1380
- const text = Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
1381
- let frame;
1453
+ const byteLength = typeof data === "string" ? Buffer.byteLength(data) : Array.isArray(data) ? data.reduce((total, part) => total + part.byteLength, 0) : data.byteLength;
1454
+ if (byteLength > MAX_LINK_FRAME_BYTES) {
1455
+ this.ctx.logger.warn("interconnect: dropping oversized link frame");
1456
+ return;
1457
+ }
1458
+ const text = typeof data === "string" ? data : Array.isArray(data) ? Buffer.concat(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
1459
+ let parsed;
1382
1460
  try {
1383
- frame = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
1461
+ parsed = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
1384
1462
  } catch {
1385
1463
  this.ctx.logger.warn("interconnect: dropping malformed link frame");
1386
1464
  return;
1387
1465
  }
1466
+ if (parsed === null || typeof parsed !== "object") {
1467
+ this.ctx.logger.warn("interconnect: dropping malformed link frame");
1468
+ return;
1469
+ }
1470
+ const frame = parsed;
1388
1471
  if (frame.type === "hello") {
1389
1472
  this.peerOf.set(socket, frame.sender);
1390
1473
  return;
1391
1474
  }
1392
1475
  if (frame.type === "event") {
1393
1476
  const sender = this.peerOf.get(socket) ?? "unknown-peer";
1477
+ const kind = frame.notification.kind;
1394
1478
  try {
1395
1479
  this.receiveEvent({ sender, notification: frame.notification });
1396
1480
  } 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)}`);
1481
+ this.ctx.logger.warn(
1482
+ `interconnect: listener for a ${kind} event from ${sender} threw: ` + (error instanceof Error ? error.message : String(error))
1483
+ );
1398
1484
  }
1399
1485
  return;
1400
1486
  }
1401
1487
  if (frame.type === "msg-result") {
1402
1488
  const pending = this.pendingMessages.get(frame.reqId);
1403
- if (pending !== void 0) {
1489
+ if (pending !== void 0 && pending.kind === "msg") {
1404
1490
  ;
1405
1491
  pending.resolve?.(frame.result);
1406
1492
  }
@@ -1408,7 +1494,7 @@ var InterconnectService = class extends Service {
1408
1494
  }
1409
1495
  if (frame.type === "query-result") {
1410
1496
  const pending = this.pendingMessages.get(frame.reqId);
1411
- if (pending !== void 0) {
1497
+ if (pending !== void 0 && pending.kind === "query" && (pending.accept === void 0 || pending.accept(frame.result))) {
1412
1498
  ;
1413
1499
  pending.resolve?.(frame.result);
1414
1500
  }
@@ -1427,8 +1513,8 @@ var InterconnectService = class extends Service {
1427
1513
  });
1428
1514
  return;
1429
1515
  }
1430
- void Promise.resolve().then(async () => {
1431
- await this.handleQueryFrame(socket, frame.reqId, frame.query);
1516
+ void Promise.resolve().then(() => {
1517
+ this.handleQueryFrame(socket, frame.reqId, frame.query);
1432
1518
  }).catch((error) => {
1433
1519
  this.ctx.logger.warn(`interconnect: query ${frame.reqId} handler threw: ${error instanceof Error ? error.message : String(error)}`);
1434
1520
  });
@@ -1441,15 +1527,21 @@ var InterconnectService = class extends Service {
1441
1527
  }
1442
1528
  };
1443
1529
  var LinkState = class {
1444
- constructor(owner, instanceId, origin) {
1445
- this.owner = owner;
1530
+ constructor(attachSocket, logger, resolveToken, instanceId, origin) {
1531
+ this.attachSocket = attachSocket;
1532
+ this.logger = logger;
1533
+ this.resolveToken = resolveToken;
1446
1534
  this.instanceId = instanceId;
1447
1535
  this.peer = origin;
1448
1536
  }
1449
1537
  socket;
1538
+ dialEpoch = 0;
1450
1539
  closed = false;
1451
1540
  retry = 0;
1452
1541
  reconnectTimer;
1542
+ warnedNoToken = false;
1543
+ /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1544
+ dialListeners;
1453
1545
  // `peer` is mutable so `reroute` can point the link at a new origin.
1454
1546
  peer;
1455
1547
  instanceId;
@@ -1457,9 +1549,16 @@ var LinkState = class {
1457
1549
  reroute(origin) {
1458
1550
  if (origin === this.peer) return;
1459
1551
  this.peer = origin;
1552
+ this.dialEpoch += 1;
1553
+ if (this.reconnectTimer !== void 0) {
1554
+ clearTimeout(this.reconnectTimer);
1555
+ this.reconnectTimer = void 0;
1556
+ }
1460
1557
  const socket = this.socket;
1461
1558
  if (socket !== void 0) {
1462
- socket.removeAllListeners();
1559
+ this.removeDialListeners(socket);
1560
+ socket.on("error", () => {
1561
+ });
1463
1562
  socket.terminate();
1464
1563
  this.socket = void 0;
1465
1564
  }
@@ -1468,29 +1567,59 @@ var LinkState = class {
1468
1567
  /** Open the socket; reconnect is scheduled by the close handler. */
1469
1568
  dial() {
1470
1569
  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;
1570
+ const epoch = this.dialEpoch;
1571
+ let url;
1572
+ try {
1573
+ url = linkUrl(this.peer);
1574
+ } catch {
1575
+ this.logger.warn(`interconnect: invalid peer origin ${this.peer}; link to ${this.instanceId} stays down`);
1576
+ return;
1577
+ }
1578
+ void this.resolveToken().then((token) => {
1579
+ if (token === void 0) {
1580
+ if (!this.warnedNoToken) {
1581
+ this.warnedNoToken = true;
1582
+ this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
1583
+ }
1584
+ if (this.closed || epoch !== this.dialEpoch) return;
1585
+ this.scheduleReconnect();
1586
+ return;
1587
+ }
1588
+ if (this.closed || epoch !== this.dialEpoch) return;
1475
1589
  const socket = new WebSocket(url, {
1476
- headers: { authorization: `Bearer ${token}` }
1590
+ headers: { authorization: `Bearer ${token}` },
1591
+ maxPayload: MAX_LINK_FRAME_BYTES,
1592
+ // A peer that accepts TCP but never completes the upgrade would leave
1593
+ // the socket in CONNECTING forever: no open (so no heartbeat) and no
1594
+ // close (so no reconnect). ws aborts the connection past the handshake
1595
+ // window, so its own error+close fire and the close handler schedules
1596
+ // a reconnect — and ws owns the timer, so nothing leaks on teardown.
1597
+ handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
1477
1598
  });
1478
1599
  this.socket = socket;
1479
- socket.once("open", () => {
1600
+ const onOpen = () => {
1480
1601
  this.retry = 0;
1481
- this.owner.attachSocket(socket);
1482
- });
1483
- socket.once("close", () => {
1602
+ this.attachSocket(socket);
1603
+ };
1604
+ const onClose = () => {
1484
1605
  if (this.closed) return;
1485
1606
  this.scheduleReconnect();
1486
- });
1487
- socket.on("error", () => {
1488
- });
1607
+ };
1608
+ const onError = () => {
1609
+ };
1610
+ this.dialListeners = { open: onOpen, close: onClose, error: onError };
1611
+ socket.once("open", onOpen);
1612
+ socket.once("close", onClose);
1613
+ socket.on("error", onError);
1489
1614
  }).catch(() => {
1490
- if (this.closed) return;
1615
+ if (this.closed || epoch !== this.dialEpoch) return;
1491
1616
  this.scheduleReconnect();
1492
1617
  });
1493
1618
  }
1619
+ /** Whether this link has been closed; a closed state is never reused by `link()`. */
1620
+ isClosed() {
1621
+ return this.closed;
1622
+ }
1494
1623
  /** Whether this link currently holds an open socket that can carry frames. */
1495
1624
  writable() {
1496
1625
  return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
@@ -1504,14 +1633,24 @@ var LinkState = class {
1504
1633
  }
1505
1634
  close() {
1506
1635
  this.closed = true;
1507
- this.owner.forgetLink(this.peer);
1508
1636
  if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
1509
1637
  const socket = this.socket;
1510
1638
  if (socket !== void 0) {
1511
- socket.removeAllListeners();
1639
+ this.removeDialListeners(socket);
1640
+ socket.on("error", () => {
1641
+ });
1512
1642
  socket.terminate();
1513
1643
  }
1514
1644
  }
1645
+ /** Detach the dial-registered handlers from a socket before terminating it. */
1646
+ removeDialListeners(socket) {
1647
+ const listeners = this.dialListeners;
1648
+ if (listeners === void 0) return;
1649
+ socket.removeListener("open", listeners.open);
1650
+ socket.removeListener("close", listeners.close);
1651
+ socket.removeListener("error", listeners.error);
1652
+ this.dialListeners = void 0;
1653
+ }
1515
1654
  scheduleReconnect() {
1516
1655
  const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
1517
1656
  this.retry += 1;
@@ -1522,12 +1661,16 @@ var LinkState = class {
1522
1661
  }
1523
1662
  };
1524
1663
  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;
1664
+ return constantTimeEqual(createHash("sha256").update(a, "utf8").digest(), createHash("sha256").update(b, "utf8").digest());
1665
+ }
1666
+ function linkUrl(origin) {
1667
+ const url = new URL("/interconnect/link", origin);
1668
+ if (url.protocol === "http:") url.protocol = "ws:";
1669
+ else if (url.protocol === "https:") url.protocol = "wss:";
1670
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") {
1671
+ throw new TypeError(`interconnect: unsupported peer origin protocol ${url.protocol}`);
1672
+ }
1673
+ return url;
1531
1674
  }
1532
1675
  function trimBase(baseUrl) {
1533
1676
  return baseUrl.replace(/\/+$/, "");
@@ -1536,6 +1679,7 @@ var index_default = InterconnectService;
1536
1679
  export {
1537
1680
  INTERCONNECT_TOKEN_REF,
1538
1681
  InterconnectService,
1539
- index_default as default
1682
+ index_default as default,
1683
+ linkUrl
1540
1684
  };
1541
1685
  //# sourceMappingURL=index.js.map