dsh-interconnect 0.10.0 → 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,8 +1,8 @@
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
- import { hasApiRemoteSubagentOwner } from "@deepseek-ai/dsh-api-remotes";
6
6
 
7
7
  // ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
8
8
  function isNullable(value) {
@@ -819,64 +819,81 @@ defineMethod("transform", [
819
819
 
820
820
  // src/interconnect/index.ts
821
821
  import WebSocket, { WebSocketServer } from "ws";
822
-
823
- // src/interconnect/types.ts
822
+ function isSessionOwnedBySubagent(ctx, session, agent) {
823
+ if (session.header.origin === "subagent") return true;
824
+ const parentId = session.header.parentSession;
825
+ if (parentId === void 0 || agent === void 0) return false;
826
+ const parent = ctx.agents.get(parentId);
827
+ return parent !== void 0 && ctx.agents.isOwnedBy(agent.id, parent);
828
+ }
829
+ function assertNever(value) {
830
+ throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
831
+ }
832
+ var INTERCONNECT_CHANNEL = "/interconnect";
824
833
  var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
825
-
826
- // src/interconnect/index.ts
827
834
  var PLUGIN_SOURCE = "dsh-interconnect";
835
+ var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
+ var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
828
837
  var LINK_CHANNEL = "/interconnect/link";
829
838
  var notificationSchema = Schema.union([
830
- Schema.object({ kind: Schema.const("agent/created"), sessionId: Schema.string() }),
831
- Schema.object({ kind: Schema.const("agent/disposed"), sessionId: Schema.string() }),
832
- Schema.object({ kind: Schema.const("agent/status"), sessionId: Schema.string(), status: Schema.union([Schema.const("idle"), Schema.const("running")]) }),
833
- Schema.object({ kind: Schema.const("session/created"), sessionId: Schema.string(), parentSessionId: Schema.string() }),
834
- Schema.object({ kind: Schema.const("session/disposed"), sessionId: Schema.string() }),
835
- 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() })
836
845
  ]);
837
846
  var senderSchema = Schema.object({ instanceId: Schema.string(), sessionId: Schema.string() });
838
- var messageSchema = Schema.union([
847
+ var queryResultSchema = Schema.union([
848
+ Schema.object({ pong: Schema.const(true).required(), instance: Schema.string().required() }),
839
849
  Schema.object({
840
- kind: Schema.const("send"),
841
- sessionId: Schema.string(),
842
- text: Schema.string(),
843
- sender: senderSchema,
844
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
845
- 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()
846
856
  }),
847
- Schema.object({
848
- kind: Schema.const("reply"),
849
- sessionId: Schema.string(),
850
- text: Schema.string(),
851
- sender: senderSchema,
852
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
853
- resume: Schema.boolean()
854
- })
857
+ Schema.object({ accepted: Schema.const(true).required() })
855
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
+ });
856
867
  var querySchema = Schema.union([
857
- Schema.object({ kind: Schema.const("ping") }),
858
- Schema.object({ kind: Schema.const("list") }),
859
- 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() })
860
882
  ]);
861
883
  var linkFrameSchema = Schema.union([
862
- Schema.object({ type: Schema.const("hello"), sender: Schema.string() }),
863
- Schema.object({ type: Schema.const("event"), notification: notificationSchema }),
864
- Schema.object({ type: Schema.const("msg"), reqId: Schema.string(), message: messageSchema }),
865
- Schema.object({ type: Schema.const("msg-result"), reqId: Schema.string(), result: Schema.object({
866
- delivered: Schema.boolean(),
867
- instance: Schema.string(),
868
- delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
869
- reason: Schema.string()
870
- }) }),
871
- Schema.object({ type: Schema.const("query"), reqId: Schema.string(), query: querySchema }),
872
- 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() })
873
890
  ]);
874
891
  var InterconnectService = class extends Service {
875
- static inject = ["webServer", "agents", "credentials"];
892
+ static inject = ["agents", "credentials"];
876
893
  static Config = Schema.object({
877
894
  instanceId: Schema.string().default("dsh"),
878
895
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
879
- peers: Schema.object({}).default({}),
896
+ peers: Schema.dict(Schema.string()).default({}),
880
897
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
881
898
  allowResume: Schema.boolean().default(true)
882
899
  });
@@ -885,7 +902,7 @@ var InterconnectService = class extends Service {
885
902
  delivery;
886
903
  allowResume;
887
904
  subscriptions = [];
888
- server = new WebSocketServer({ noServer: true });
905
+ server = new WebSocketServer({ noServer: true, maxPayload: MAX_LINK_FRAME_BYTES });
889
906
  sockets = /* @__PURE__ */ new Set();
890
907
  /** Outbound peer links keyed by the peer's `instanceId`. */
891
908
  linkStates = /* @__PURE__ */ new Map();
@@ -897,12 +914,17 @@ var InterconnectService = class extends Service {
897
914
  /** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
898
915
  pendingMessages = /* @__PURE__ */ new Map();
899
916
  reqIdCounter = 0;
917
+ /** Set when the service fiber unwinds; guards late inbound deliveries against driving disposed agents. */
918
+ disposed = false;
900
919
  constructor(ctx, config) {
901
920
  super(ctx, "interconnect");
902
921
  this.instanceId = config.instanceId;
903
922
  this.requestTimeoutMs = config.requestTimeoutMs;
904
923
  this.delivery = config.delivery ?? "followup";
905
924
  this.allowResume = config.allowResume ?? true;
925
+ for (const origin of Object.values(config.peers ?? {})) {
926
+ linkUrl(trimBase(origin));
927
+ }
906
928
  for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
907
929
  this.link(peerInstanceId, origin);
908
930
  }
@@ -912,9 +934,15 @@ var InterconnectService = class extends Service {
912
934
  void this.handleUpgrade(req, socket, head);
913
935
  }
914
936
  };
915
- 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
+ });
916
940
  this.heartbeatTimer = setInterval(() => {
917
941
  for (const socket of this.sockets) {
942
+ if (socket.readyState !== WebSocket.OPEN) {
943
+ this.sockets.delete(socket);
944
+ continue;
945
+ }
918
946
  if (socket.isAlive === false) {
919
947
  socket.terminate();
920
948
  this.sockets.delete(socket);
@@ -943,6 +971,7 @@ var InterconnectService = class extends Service {
943
971
  });
944
972
  }));
945
973
  this.subscriptions.push(ctx.on("session/disposed", (session) => {
974
+ this.senders.delete(String(session.id));
946
975
  this.fanout({ kind: "session/disposed", sessionId: String(session.id) });
947
976
  }));
948
977
  this.subscriptions.push(ctx.on("subagent/end", (info) => {
@@ -964,16 +993,28 @@ var InterconnectService = class extends Service {
964
993
  for (const socket of this.sockets) socket.terminate();
965
994
  this.sockets.clear();
966
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;
967
1002
  }, "interconnect: websocket teardown");
968
1003
  }
969
1004
  /**
970
1005
  * This instance's identity to attach to outbound messages. Always present:
971
1006
  * it needs no address, only this instance's id and the calling session.
972
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.
973
1009
  */
974
1010
  selfSender(sessionId) {
975
1011
  return { instanceId: this.instanceId, sessionId };
976
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
+ */
977
1018
  async send(request) {
978
1019
  const payload = {
979
1020
  sessionId: request.sessionId,
@@ -982,14 +1023,20 @@ var InterconnectService = class extends Service {
982
1023
  ...request.delivery === void 0 ? {} : { delivery: request.delivery },
983
1024
  ...request.resume === void 0 ? {} : { resume: request.resume }
984
1025
  };
985
- const result = await this.msgRequest(request.instanceId, "send", payload);
986
- 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
+ }
987
1032
  }
988
1033
  /**
989
1034
  * Deliver one text message back to the peer that a local session last
990
1035
  * received a send from. `sessionId` names the LOCAL replying session; the
991
1036
  * outbound target is the `sender` that session recorded, addressed through
992
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.
993
1040
  */
994
1041
  async reply(request) {
995
1042
  const sender = this.senders.get(request.sessionId);
@@ -1005,13 +1052,19 @@ var InterconnectService = class extends Service {
1005
1052
  // back — chaining the conversation.
1006
1053
  sender: this.selfSender(request.sessionId)
1007
1054
  };
1008
- const result = await this.msgRequest(sender.instanceId, "send", payload);
1009
- 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
+ }
1010
1061
  }
1011
1062
  /**
1012
1063
  * Probe a peer instance for liveness and identity over its persistent link.
1013
1064
  * Returns the peer identity when reachable, or undefined when the link is
1014
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.
1015
1068
  */
1016
1069
  async ping(instanceId) {
1017
1070
  return this.queryRequest(instanceId, { kind: "ping" });
@@ -1019,15 +1072,12 @@ var InterconnectService = class extends Service {
1019
1072
  /**
1020
1073
  * List a peer instance's live sessions over its persistent link. Undefined on
1021
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.
1022
1077
  */
1023
1078
  async list(instanceId) {
1024
1079
  return this.queryRequest(instanceId, { kind: "list" });
1025
1080
  }
1026
- /** The origin this instance dials to reach a configured peer, or undefined. */
1027
- originOf(instanceId) {
1028
- const state = this.linkStates.get(instanceId);
1029
- return state?.peer;
1030
- }
1031
1081
  /**
1032
1082
  * Send a `msg` frame over the live link to a peer and resolve with its
1033
1083
  * correlated `msg-result`. Undefined when the peer has no live link here
@@ -1037,7 +1087,7 @@ var InterconnectService = class extends Service {
1037
1087
  async msgRequest(instanceId, kind, payload) {
1038
1088
  const state = this.linkStates.get(instanceId);
1039
1089
  if (state === void 0 || !state.writable()) return void 0;
1040
- const reqId = `m${++this.reqIdCounter}-${crypto.randomUUID()}`;
1090
+ const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1041
1091
  const message = {
1042
1092
  kind,
1043
1093
  sessionId: payload.sessionId,
@@ -1046,7 +1096,7 @@ var InterconnectService = class extends Service {
1046
1096
  ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1047
1097
  ...payload.resume === void 0 ? {} : { resume: payload.resume }
1048
1098
  };
1049
- return this.waitForResult(reqId, (failure) => {
1099
+ return this.waitForResult(reqId, "msg", (failure) => {
1050
1100
  const wrote = state.sendFrame({ type: "msg", reqId, message });
1051
1101
  if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while sending`));
1052
1102
  });
@@ -1055,32 +1105,34 @@ var InterconnectService = class extends Service {
1055
1105
  async queryRequest(instanceId, query) {
1056
1106
  const state = this.linkStates.get(instanceId);
1057
1107
  if (state === void 0 || !state.writable()) return void 0;
1058
- const reqId = `q${++this.reqIdCounter}-${crypto.randomUUID()}`;
1059
- return this.waitForResult(reqId, (failure) => {
1060
- const wrote = state.sendFrame({ type: "query", reqId, query });
1061
- if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while querying`));
1062
- });
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
+ }
1063
1118
  }
1064
1119
  /** Settle a `reqId` frame against its `*-result`, timing out at `requestTimeoutMs`. */
1065
- waitForResult(reqId, send) {
1120
+ waitForResult(reqId, kind, send, accept) {
1066
1121
  return new Promise((resolve2, reject) => {
1067
1122
  const timer = setTimeout(() => {
1068
1123
  this.pendingMessages.delete(reqId);
1069
1124
  reject(new Error(`interconnect: no result for ${reqId} within ${this.requestTimeoutMs}ms`));
1070
1125
  }, this.requestTimeoutMs);
1071
- this.pendingMessages.set(reqId, { timer });
1072
- const pending = this.pendingMessages.get(reqId);
1073
- if (pending === void 0) return;
1074
- pending.resolve = (result) => {
1075
- clearTimeout(timer);
1076
- this.pendingMessages.delete(reqId);
1077
- resolve2(result);
1078
- };
1079
- pending.reject = (error) => {
1080
- clearTimeout(timer);
1081
- this.pendingMessages.delete(reqId);
1082
- reject(error instanceof Error ? error : new Error(String(error)));
1083
- };
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
+ });
1084
1136
  send((error) => {
1085
1137
  clearTimeout(timer);
1086
1138
  this.pendingMessages.delete(reqId);
@@ -1096,12 +1148,20 @@ var InterconnectService = class extends Service {
1096
1148
  * @returns disposer removing the peer route.
1097
1149
  */
1098
1150
  subscribe(instanceId, origin) {
1099
- this.link(instanceId, origin);
1151
+ const linked = this.link(instanceId, origin);
1152
+ const dialedOrigin = trimBase(origin);
1100
1153
  return () => {
1101
- 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
+ }
1102
1159
  };
1103
1160
  }
1104
- /** 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
+ */
1105
1165
  unsubscribe(instanceId) {
1106
1166
  const state = this.linkStates.get(instanceId);
1107
1167
  if (state !== void 0) state.close();
@@ -1118,19 +1178,23 @@ var InterconnectService = class extends Service {
1118
1178
  */
1119
1179
  link(instanceId, origin) {
1120
1180
  const existing = this.linkStates.get(instanceId);
1121
- if (existing !== void 0) {
1181
+ if (existing !== void 0 && !existing.isClosed()) {
1122
1182
  existing.reroute(trimBase(origin));
1123
1183
  return existing;
1124
1184
  }
1125
- 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
+ );
1126
1194
  this.linkStates.set(instanceId, state);
1127
1195
  state.dial();
1128
1196
  return state;
1129
1197
  }
1130
- /** Remove a closed outbound link's state so a later `link` re-dials fresh. */
1131
- forgetLink(instanceId) {
1132
- this.linkStates.delete(instanceId);
1133
- }
1134
1198
  /** Push one serialized lifecycle fact out to every linked peer over WS. */
1135
1199
  fanout(notification) {
1136
1200
  this.broadcast(notification);
@@ -1140,11 +1204,15 @@ var InterconnectService = class extends Service {
1140
1204
  if (this.sockets.size === 0) return;
1141
1205
  const frame = { type: "event", notification };
1142
1206
  const encoded = JSON.stringify(frame);
1207
+ const sent = /* @__PURE__ */ new Set();
1143
1208
  for (const socket of this.sockets) {
1144
1209
  if (socket.readyState !== WebSocket.OPEN) {
1145
1210
  this.sockets.delete(socket);
1146
1211
  continue;
1147
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);
1148
1216
  socket.send(encoded);
1149
1217
  }
1150
1218
  }
@@ -1154,27 +1222,18 @@ var InterconnectService = class extends Service {
1154
1222
  * result so a throwing handler cannot escape the socket's message callback.
1155
1223
  */
1156
1224
  async handleMsgFrame(socket, reqId, message) {
1157
- let result;
1158
- if (message.kind === "reply") {
1159
- result = await this.replyForSession({
1160
- sessionId: message.sessionId,
1161
- text: message.text,
1162
- ...message.delivery === void 0 ? {} : { delivery: message.delivery },
1163
- ...message.resume === void 0 ? {} : { resume: message.resume }
1164
- });
1165
- } else {
1166
- result = await this.deliver({
1167
- sessionId: message.sessionId,
1168
- text: message.text,
1169
- ...message.sender === void 0 ? {} : { sender: message.sender },
1170
- ...message.delivery === void 0 ? {} : { delivery: message.delivery },
1171
- ...message.resume === void 0 ? {} : { resume: message.resume }
1172
- });
1173
- }
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
+ });
1174
1233
  this.sendFrame(socket, { type: "msg-result", reqId, result });
1175
1234
  }
1176
1235
  /** Dispatch one inbound `query` frame (ping/list/event) and answer on the socket. */
1177
- async handleQueryFrame(socket, reqId, query) {
1236
+ handleQueryFrame(socket, reqId, query) {
1178
1237
  let result;
1179
1238
  if (query.kind === "ping") {
1180
1239
  result = { pong: true, instance: this.instanceId };
@@ -1206,7 +1265,7 @@ var InterconnectService = class extends Service {
1206
1265
  * matches how the Host's own session listing treats its projection column.
1207
1266
  */
1208
1267
  listSessions() {
1209
- const reachable = this.ctx.agents.list().filter((agent) => !hasApiRemoteSubagentOwner(this.ctx, agent.session, agent));
1268
+ const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1210
1269
  const sessions = reachable.map((agent) => {
1211
1270
  let title;
1212
1271
  try {
@@ -1247,7 +1306,9 @@ var InterconnectService = class extends Service {
1247
1306
  if (resolved === void 0 || resolved === null) return { reason: "session-not-live" };
1248
1307
  return { agent: resolved };
1249
1308
  } catch (error) {
1250
- 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
+ );
1251
1312
  return { reason: "resume-failed" };
1252
1313
  }
1253
1314
  }
@@ -1256,24 +1317,26 @@ var InterconnectService = class extends Service {
1256
1317
  * when the sender asked and this receiver allows it.
1257
1318
  */
1258
1319
  async deliver(payload) {
1320
+ if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
1259
1321
  let agent = this.ctx.agents.get(payload.sessionId);
1260
1322
  if (agent === void 0) {
1261
1323
  const woken = await this.wake(payload);
1324
+ if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
1262
1325
  if ("reason" in woken) {
1263
1326
  return { delivered: false, instance: this.instanceId, reason: woken.reason };
1264
1327
  }
1265
1328
  agent = woken.agent;
1266
1329
  }
1267
- if (hasApiRemoteSubagentOwner(this.ctx, agent.session, agent)) {
1330
+ if (isSessionOwnedBySubagent(this.ctx, agent.session, agent)) {
1268
1331
  return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1269
1332
  }
1270
1333
  const sender = (() => {
1271
1334
  const candidate = payload.sender;
1272
- 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 };
1273
1339
  })();
1274
- if (sender !== void 0) {
1275
- this.senders.set(payload.sessionId, sender);
1276
- }
1277
1340
  const message = createUserMessage({
1278
1341
  source: {
1279
1342
  kind: "plugin",
@@ -1296,27 +1359,21 @@ var InterconnectService = class extends Service {
1296
1359
  case "followup":
1297
1360
  agent.followup(message);
1298
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);
1299
1370
  }
1300
1371
  return { delivered: true, instance: this.instanceId, delivery: mode };
1301
1372
  }
1302
- /**
1303
- * Inbound `reply` dispatch: forward a request to {link reply}, resolving the
1304
- * outbound sender for the named local session. Kept separate from {link
1305
- * deliver} so the two wire endpoints stay distinct in the request pipeline.
1306
- */
1307
- async replyForSession(payload) {
1308
- return this.reply({
1309
- sessionId: payload.sessionId,
1310
- text: payload.text,
1311
- ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1312
- ...payload.resume === void 0 ? {} : { resume: payload.resume }
1313
- });
1314
- }
1315
1373
  async resolveToken() {
1316
1374
  const credential = await this.ctx.credentials.resolve(credentialRef(INTERCONNECT_TOKEN_REF));
1317
1375
  return credential === void 0 || credential.value.length === 0 ? void 0 : credential.value;
1318
1376
  }
1319
- /** Resolve the shared token for an outbound dial (same source as inbound). */
1320
1377
  resolveTokenForDial() {
1321
1378
  return this.resolveToken();
1322
1379
  }
@@ -1347,7 +1404,10 @@ var InterconnectService = class extends Service {
1347
1404
  /**
1348
1405
  * Install frame + liveness handling on one socket and add it to the live
1349
1406
  * pool. Used by both the server half (accepted upgrade) and the client half
1350
- * (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.
1351
1411
  */
1352
1412
  attachSocket(websocket) {
1353
1413
  this.sockets.add(websocket);
@@ -1371,30 +1431,42 @@ var InterconnectService = class extends Service {
1371
1431
  /** Parse and route one inbound link frame, attributing events to the socket's announced peer. */
1372
1432
  handleFrame(socket, data) {
1373
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
+ }
1374
1438
  const text = Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
1375
- let frame;
1439
+ let parsed;
1376
1440
  try {
1377
- frame = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
1441
+ parsed = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
1378
1442
  } catch {
1379
1443
  this.ctx.logger.warn("interconnect: dropping malformed link frame");
1380
1444
  return;
1381
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;
1382
1451
  if (frame.type === "hello") {
1383
1452
  this.peerOf.set(socket, frame.sender);
1384
1453
  return;
1385
1454
  }
1386
1455
  if (frame.type === "event") {
1387
1456
  const sender = this.peerOf.get(socket) ?? "unknown-peer";
1457
+ const kind = frame.notification.kind;
1388
1458
  try {
1389
1459
  this.receiveEvent({ sender, notification: frame.notification });
1390
1460
  } catch (error) {
1391
- 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
+ );
1392
1464
  }
1393
1465
  return;
1394
1466
  }
1395
1467
  if (frame.type === "msg-result") {
1396
1468
  const pending = this.pendingMessages.get(frame.reqId);
1397
- if (pending !== void 0) {
1469
+ if (pending !== void 0 && pending.kind === "msg") {
1398
1470
  ;
1399
1471
  pending.resolve?.(frame.result);
1400
1472
  }
@@ -1402,7 +1474,7 @@ var InterconnectService = class extends Service {
1402
1474
  }
1403
1475
  if (frame.type === "query-result") {
1404
1476
  const pending = this.pendingMessages.get(frame.reqId);
1405
- if (pending !== void 0) {
1477
+ if (pending !== void 0 && pending.kind === "query" && (pending.accept === void 0 || pending.accept(frame.result))) {
1406
1478
  ;
1407
1479
  pending.resolve?.(frame.result);
1408
1480
  }
@@ -1421,8 +1493,8 @@ var InterconnectService = class extends Service {
1421
1493
  });
1422
1494
  return;
1423
1495
  }
1424
- void Promise.resolve().then(async () => {
1425
- await this.handleQueryFrame(socket, frame.reqId, frame.query);
1496
+ void Promise.resolve().then(() => {
1497
+ this.handleQueryFrame(socket, frame.reqId, frame.query);
1426
1498
  }).catch((error) => {
1427
1499
  this.ctx.logger.warn(`interconnect: query ${frame.reqId} handler threw: ${error instanceof Error ? error.message : String(error)}`);
1428
1500
  });
@@ -1435,15 +1507,20 @@ var InterconnectService = class extends Service {
1435
1507
  }
1436
1508
  };
1437
1509
  var LinkState = class {
1438
- constructor(owner, instanceId, origin) {
1439
- this.owner = owner;
1510
+ constructor(attachSocket, logger, resolveToken, instanceId, origin) {
1511
+ this.attachSocket = attachSocket;
1512
+ this.logger = logger;
1513
+ this.resolveToken = resolveToken;
1440
1514
  this.instanceId = instanceId;
1441
1515
  this.peer = origin;
1442
1516
  }
1443
1517
  socket;
1518
+ dialEpoch = 0;
1444
1519
  closed = false;
1445
1520
  retry = 0;
1446
1521
  reconnectTimer;
1522
+ /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1523
+ dialListeners;
1447
1524
  // `peer` is mutable so `reroute` can point the link at a new origin.
1448
1525
  peer;
1449
1526
  instanceId;
@@ -1451,9 +1528,16 @@ var LinkState = class {
1451
1528
  reroute(origin) {
1452
1529
  if (origin === this.peer) return;
1453
1530
  this.peer = origin;
1531
+ this.dialEpoch += 1;
1532
+ if (this.reconnectTimer !== void 0) {
1533
+ clearTimeout(this.reconnectTimer);
1534
+ this.reconnectTimer = void 0;
1535
+ }
1454
1536
  const socket = this.socket;
1455
1537
  if (socket !== void 0) {
1456
- socket.removeAllListeners();
1538
+ this.removeDialListeners(socket);
1539
+ socket.on("error", () => {
1540
+ });
1457
1541
  socket.terminate();
1458
1542
  this.socket = void 0;
1459
1543
  }
@@ -1462,29 +1546,54 @@ var LinkState = class {
1462
1546
  /** Open the socket; reconnect is scheduled by the close handler. */
1463
1547
  dial() {
1464
1548
  if (this.closed) return;
1465
- const url = new URL("/interconnect/link", this.peer);
1466
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1467
- void this.owner.resolveTokenForDial().then((token) => {
1468
- 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;
1469
1563
  const socket = new WebSocket(url, {
1470
- 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
1471
1572
  });
1472
1573
  this.socket = socket;
1473
- socket.once("open", () => {
1574
+ const onOpen = () => {
1474
1575
  this.retry = 0;
1475
- this.owner.attachSocket(socket);
1476
- });
1477
- socket.once("close", () => {
1576
+ this.attachSocket(socket);
1577
+ };
1578
+ const onClose = () => {
1478
1579
  if (this.closed) return;
1479
1580
  this.scheduleReconnect();
1480
- });
1481
- socket.on("error", () => {
1482
- });
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);
1483
1588
  }).catch(() => {
1484
- if (this.closed) return;
1589
+ if (this.closed || epoch !== this.dialEpoch) return;
1485
1590
  this.scheduleReconnect();
1486
1591
  });
1487
1592
  }
1593
+ /** Whether this link has been closed; a closed state is never reused by `link()`. */
1594
+ isClosed() {
1595
+ return this.closed;
1596
+ }
1488
1597
  /** Whether this link currently holds an open socket that can carry frames. */
1489
1598
  writable() {
1490
1599
  return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
@@ -1498,14 +1607,24 @@ var LinkState = class {
1498
1607
  }
1499
1608
  close() {
1500
1609
  this.closed = true;
1501
- this.owner.forgetLink(this.peer);
1502
1610
  if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
1503
1611
  const socket = this.socket;
1504
1612
  if (socket !== void 0) {
1505
- socket.removeAllListeners();
1613
+ this.removeDialListeners(socket);
1614
+ socket.on("error", () => {
1615
+ });
1506
1616
  socket.terminate();
1507
1617
  }
1508
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
+ }
1509
1628
  scheduleReconnect() {
1510
1629
  const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
1511
1630
  this.retry += 1;
@@ -1516,18 +1635,23 @@ var LinkState = class {
1516
1635
  }
1517
1636
  };
1518
1637
  function timingSafeEqual(a, b) {
1519
- if (a.length !== b.length) return false;
1520
- let diff = 0;
1521
- for (let i = 0; i < a.length; i += 1) {
1522
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1523
- }
1524
- 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;
1525
1648
  }
1526
1649
  function trimBase(baseUrl) {
1527
1650
  return baseUrl.replace(/\/+$/, "");
1528
1651
  }
1529
1652
  var interconnect_default = InterconnectService;
1530
1653
  export {
1654
+ INTERCONNECT_CHANNEL,
1531
1655
  INTERCONNECT_TOKEN_REF,
1532
1656
  InterconnectService,
1533
1657
  interconnect_default as default