dsh-interconnect 0.11.13 → 0.11.15

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/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-interconnect",
3
3
  "description": "Cross-instance message/event handoff for DeepSeek Harness (DSH): WebSocket link, model-facing tools, and companion skill.",
4
- "version": "0.11.13",
4
+ "version": "0.11.15",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]
package/lib/index.js CHANGED
@@ -835,8 +835,7 @@ var PLUGIN_SOURCE = "dsh-interconnect";
835
835
  var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
836
  var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
837
837
  var LINK_CHANNEL = "/interconnect/link";
838
- var LIST_FRAME_ENVELOPE_BYTES = 4096;
839
- var MAX_LIST_ROWS_BYTES = MAX_LINK_FRAME_BYTES - LIST_FRAME_ENVELOPE_BYTES;
838
+ var MAX_REQUEST_ID_CHARS = 256;
840
839
  var MAX_LISTED_SESSIONS = 100;
841
840
  var rowBytes = (row) => Buffer.byteLength(JSON.stringify(row), "utf8") + 1;
842
841
  var notificationSchema = Schema.union([
@@ -920,10 +919,10 @@ var msgResultSchema = Schema.union([
920
919
  var linkFrameSchema = Schema.union([
921
920
  Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
922
921
  Schema.object({ type: Schema.const("event").required(), notification: notificationSchema.required() }),
923
- Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().required(), message: messageSchema.required() }),
924
- Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().required(), result: msgResultSchema.required() }),
925
- Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().required(), query: querySchema.required() }),
926
- Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().required(), result: queryResultSchema.required() })
922
+ Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), message: messageSchema.required() }),
923
+ Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: msgResultSchema.required() }),
924
+ Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), query: querySchema.required() }),
925
+ Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: queryResultSchema.required() })
927
926
  ]);
928
927
  var InterconnectService = class extends Service {
929
928
  static inject = ["agents", "credentials"];
@@ -1213,6 +1212,12 @@ var InterconnectService = class extends Service {
1213
1212
  * open, so an announcement can suppress nothing but a duplicate the
1214
1213
  * controlled link already carries — an impostor cannot drop an event for a
1215
1214
  * peer, and without a dialed link every inbound socket receives it.
1215
+ *
1216
+ * That holds once a peer's announcement has been seen. Between this
1217
+ * instance's dialed link opening and the peer's `hello` arriving on its
1218
+ * inbound socket, the inbound socket is not yet attributable, so an event
1219
+ * emitted in that window leaves over both sockets and reaches that peer
1220
+ * twice. The duplicate is transient and never a dropped event.
1216
1221
  */
1217
1222
  broadcast(notification) {
1218
1223
  if (this.sockets.size === 0) return;
@@ -1257,7 +1262,12 @@ var InterconnectService = class extends Service {
1257
1262
  if (query.kind === "ping") {
1258
1263
  result = { pong: true, instance: this.instanceId };
1259
1264
  } else if (query.kind === "list") {
1260
- result = this.listSessions();
1265
+ const rowBudget = this.listRowsBudgetBytes(reqId);
1266
+ if (rowBudget < 0) {
1267
+ this.ctx.logger.warn("interconnect: list answer cannot fit the link frame cap; not answering");
1268
+ return;
1269
+ }
1270
+ result = this.listSessions(rowBudget);
1261
1271
  } else {
1262
1272
  const payload = {
1263
1273
  sender: this.peerOf.get(socket) ?? "unknown-peer",
@@ -1285,32 +1295,57 @@ var InterconnectService = class extends Service {
1285
1295
  this.ctx.logger.warn(`interconnect: listener for a ${notification.kind} event from ${sender} threw: ${detail}`);
1286
1296
  });
1287
1297
  }
1298
+ /**
1299
+ * Bytes one `list` answer may spend on its `sessions` rows: the frame cap
1300
+ * minus the serialized envelope that answer will carry. The envelope holds
1301
+ * the peer-supplied `reqId` and this instance's own id, so it is measured per
1302
+ * answer instead of reserved with a fixed constant.
1303
+ * @param reqId - the request id the answer has to echo.
1304
+ * @returns the byte budget for the rows, negative when even an empty answer
1305
+ * exceeds the cap and so cannot be sent at all.
1306
+ */
1307
+ listRowsBudgetBytes(reqId) {
1308
+ const envelope = {
1309
+ type: "query-result",
1310
+ reqId,
1311
+ result: { sessions: [], instance: this.instanceId }
1312
+ };
1313
+ return MAX_LINK_FRAME_BYTES - Buffer.byteLength(JSON.stringify(envelope), "utf8");
1314
+ }
1288
1315
  /**
1289
1316
  * Summarize the live local sessions so a sender can discover valid targets
1290
1317
  * instead of having to know a session id already. Only live agents are listed
1291
- * because `send` can reach exactly those, and the answer carries at most
1292
- * {@link MAX_LISTED_SESSIONS} rows so a large live set cannot push the
1293
- * `query-result` frame past the link's frame cap.
1318
+ * because `send` can reach exactly those. The answer stops at
1319
+ * {@link MAX_LISTED_SESSIONS} rows or at the row budget the answer's own
1320
+ * envelope leaves, whichever comes first: that budget is what keeps the
1321
+ * complete `query-result` frame inside the link's cap, because a title's
1322
+ * length comes from the session-title projection's own bound rather than
1323
+ * from anything this listing controls. A title that does not fit the
1324
+ * remaining budget is dropped so its target stays listed; a row that does not
1325
+ * fit even untitled ends the listing, which keeps the answer a prefix of the
1326
+ * registration order instead of a set with gaps.
1294
1327
  *
1295
1328
  * Title and status are best-effort: the title projection is an optional
1296
1329
  * service, and a receiver without it still returns the ids. A projection that
1297
1330
  * throws degrades that one row rather than failing the whole listing, which
1298
1331
  * matches how the Host's own session listing treats its projection column.
1332
+ * @param rowBudgetBytes - bytes the `sessions` rows may occupy, envelope excluded.
1333
+ * @returns the bounded listing.
1299
1334
  */
1300
- listSessions() {
1335
+ listSessions(rowBudgetBytes) {
1301
1336
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1302
1337
  const sessions = [];
1303
1338
  let used = 0;
1304
1339
  for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
1305
1340
  const row = this.sessionRow(agent);
1306
1341
  const sized = used + rowBytes(row);
1307
- if (sized > MAX_LIST_ROWS_BYTES) {
1342
+ if (sized > rowBudgetBytes) {
1308
1343
  const untitled = {
1309
1344
  sessionId: row.sessionId,
1310
1345
  ...row.status === void 0 ? {} : { status: row.status }
1311
1346
  };
1312
1347
  const untitledSized = used + rowBytes(untitled);
1313
- if (untitledSized > MAX_LIST_ROWS_BYTES) break;
1348
+ if (untitledSized > rowBudgetBytes) break;
1314
1349
  sessions.push(untitled);
1315
1350
  used = untitledSized;
1316
1351
  continue;
@@ -835,8 +835,7 @@ var PLUGIN_SOURCE = "dsh-interconnect";
835
835
  var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
836
  var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
837
837
  var LINK_CHANNEL = "/interconnect/link";
838
- var LIST_FRAME_ENVELOPE_BYTES = 4096;
839
- var MAX_LIST_ROWS_BYTES = MAX_LINK_FRAME_BYTES - LIST_FRAME_ENVELOPE_BYTES;
838
+ var MAX_REQUEST_ID_CHARS = 256;
840
839
  var MAX_LISTED_SESSIONS = 100;
841
840
  var rowBytes = (row) => Buffer.byteLength(JSON.stringify(row), "utf8") + 1;
842
841
  var notificationSchema = Schema.union([
@@ -920,10 +919,10 @@ var msgResultSchema = Schema.union([
920
919
  var linkFrameSchema = Schema.union([
921
920
  Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
922
921
  Schema.object({ type: Schema.const("event").required(), notification: notificationSchema.required() }),
923
- Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().required(), message: messageSchema.required() }),
924
- Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().required(), result: msgResultSchema.required() }),
925
- Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().required(), query: querySchema.required() }),
926
- Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().required(), result: queryResultSchema.required() })
922
+ Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), message: messageSchema.required() }),
923
+ Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: msgResultSchema.required() }),
924
+ Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), query: querySchema.required() }),
925
+ Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: queryResultSchema.required() })
927
926
  ]);
928
927
  var InterconnectService = class extends Service {
929
928
  static inject = ["agents", "credentials"];
@@ -1213,6 +1212,12 @@ var InterconnectService = class extends Service {
1213
1212
  * open, so an announcement can suppress nothing but a duplicate the
1214
1213
  * controlled link already carries — an impostor cannot drop an event for a
1215
1214
  * peer, and without a dialed link every inbound socket receives it.
1215
+ *
1216
+ * That holds once a peer's announcement has been seen. Between this
1217
+ * instance's dialed link opening and the peer's `hello` arriving on its
1218
+ * inbound socket, the inbound socket is not yet attributable, so an event
1219
+ * emitted in that window leaves over both sockets and reaches that peer
1220
+ * twice. The duplicate is transient and never a dropped event.
1216
1221
  */
1217
1222
  broadcast(notification) {
1218
1223
  if (this.sockets.size === 0) return;
@@ -1257,7 +1262,12 @@ var InterconnectService = class extends Service {
1257
1262
  if (query.kind === "ping") {
1258
1263
  result = { pong: true, instance: this.instanceId };
1259
1264
  } else if (query.kind === "list") {
1260
- result = this.listSessions();
1265
+ const rowBudget = this.listRowsBudgetBytes(reqId);
1266
+ if (rowBudget < 0) {
1267
+ this.ctx.logger.warn("interconnect: list answer cannot fit the link frame cap; not answering");
1268
+ return;
1269
+ }
1270
+ result = this.listSessions(rowBudget);
1261
1271
  } else {
1262
1272
  const payload = {
1263
1273
  sender: this.peerOf.get(socket) ?? "unknown-peer",
@@ -1285,32 +1295,57 @@ var InterconnectService = class extends Service {
1285
1295
  this.ctx.logger.warn(`interconnect: listener for a ${notification.kind} event from ${sender} threw: ${detail}`);
1286
1296
  });
1287
1297
  }
1298
+ /**
1299
+ * Bytes one `list` answer may spend on its `sessions` rows: the frame cap
1300
+ * minus the serialized envelope that answer will carry. The envelope holds
1301
+ * the peer-supplied `reqId` and this instance's own id, so it is measured per
1302
+ * answer instead of reserved with a fixed constant.
1303
+ * @param reqId - the request id the answer has to echo.
1304
+ * @returns the byte budget for the rows, negative when even an empty answer
1305
+ * exceeds the cap and so cannot be sent at all.
1306
+ */
1307
+ listRowsBudgetBytes(reqId) {
1308
+ const envelope = {
1309
+ type: "query-result",
1310
+ reqId,
1311
+ result: { sessions: [], instance: this.instanceId }
1312
+ };
1313
+ return MAX_LINK_FRAME_BYTES - Buffer.byteLength(JSON.stringify(envelope), "utf8");
1314
+ }
1288
1315
  /**
1289
1316
  * Summarize the live local sessions so a sender can discover valid targets
1290
1317
  * instead of having to know a session id already. Only live agents are listed
1291
- * because `send` can reach exactly those, and the answer carries at most
1292
- * {@link MAX_LISTED_SESSIONS} rows so a large live set cannot push the
1293
- * `query-result` frame past the link's frame cap.
1318
+ * because `send` can reach exactly those. The answer stops at
1319
+ * {@link MAX_LISTED_SESSIONS} rows or at the row budget the answer's own
1320
+ * envelope leaves, whichever comes first: that budget is what keeps the
1321
+ * complete `query-result` frame inside the link's cap, because a title's
1322
+ * length comes from the session-title projection's own bound rather than
1323
+ * from anything this listing controls. A title that does not fit the
1324
+ * remaining budget is dropped so its target stays listed; a row that does not
1325
+ * fit even untitled ends the listing, which keeps the answer a prefix of the
1326
+ * registration order instead of a set with gaps.
1294
1327
  *
1295
1328
  * Title and status are best-effort: the title projection is an optional
1296
1329
  * service, and a receiver without it still returns the ids. A projection that
1297
1330
  * throws degrades that one row rather than failing the whole listing, which
1298
1331
  * matches how the Host's own session listing treats its projection column.
1332
+ * @param rowBudgetBytes - bytes the `sessions` rows may occupy, envelope excluded.
1333
+ * @returns the bounded listing.
1299
1334
  */
1300
- listSessions() {
1335
+ listSessions(rowBudgetBytes) {
1301
1336
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1302
1337
  const sessions = [];
1303
1338
  let used = 0;
1304
1339
  for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
1305
1340
  const row = this.sessionRow(agent);
1306
1341
  const sized = used + rowBytes(row);
1307
- if (sized > MAX_LIST_ROWS_BYTES) {
1342
+ if (sized > rowBudgetBytes) {
1308
1343
  const untitled = {
1309
1344
  sessionId: row.sessionId,
1310
1345
  ...row.status === void 0 ? {} : { status: row.status }
1311
1346
  };
1312
1347
  const untitledSized = used + rowBytes(untitled);
1313
- if (untitledSized > MAX_LIST_ROWS_BYTES) break;
1348
+ if (untitledSized > rowBudgetBytes) break;
1314
1349
  sessions.push(untitled);
1315
1350
  used = untitledSized;
1316
1351
  continue;