dsh-interconnect 0.11.14 → 0.11.16

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.
@@ -80,6 +80,8 @@ When a send/reply reports `delivered: false`, read `reason`:
80
80
  choose a live target instead.
81
81
  - `session-owned-by-subagent` — the session belongs to a subagent and its
82
82
  parent owns delivery; reach it through the parent.
83
+ - `message-too-large` — the message is larger than the peer link can carry
84
+ (1 MiB once encoded); shorten the text instead of retrying it unchanged.
83
85
  - `no-sender-known` — a `reply` was attempted for a local session that never
84
86
  received a sender-carrying interconnect message, or whose recorded sender was
85
87
  forgotten by a receiver restart; establish contact with `interconnect_send`
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.14",
4
+ "version": "0.11.16",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]
package/lib/index.js CHANGED
@@ -835,9 +835,9 @@ 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;
840
+ var frameFitsLink = (frame) => Buffer.byteLength(JSON.stringify(frame), "utf8") <= MAX_LINK_FRAME_BYTES;
841
841
  var rowBytes = (row) => Buffer.byteLength(JSON.stringify(row), "utf8") + 1;
842
842
  var notificationSchema = Schema.union([
843
843
  Schema.object({ kind: Schema.const("agent/created").required(), sessionId: Schema.string().required() }),
@@ -920,10 +920,10 @@ var msgResultSchema = Schema.union([
920
920
  var linkFrameSchema = Schema.union([
921
921
  Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
922
922
  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() })
923
+ Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), message: messageSchema.required() }),
924
+ Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: msgResultSchema.required() }),
925
+ Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), query: querySchema.required() }),
926
+ Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: queryResultSchema.required() })
927
927
  ]);
928
928
  var InterconnectService = class extends Service {
929
929
  static inject = ["agents", "credentials"];
@@ -1124,22 +1124,30 @@ var InterconnectService = class extends Service {
1124
1124
  * Send a `msg` frame over the live link to a peer and resolve with its
1125
1125
  * correlated `msg-result`. Undefined when the peer has no live link here
1126
1126
  * (not configured, or the link is down) — sends can only address configured+
1127
- * connected peers by design.
1127
+ * connected peers by design — and a `message-too-large` result when the
1128
+ * encoded frame would not fit the link.
1128
1129
  */
1129
1130
  async msgRequest(instanceId, kind, payload) {
1130
1131
  const state = this.linkStates.get(instanceId);
1131
1132
  if (state === void 0 || !state.writable()) return void 0;
1132
1133
  const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1133
- const message = {
1134
- kind,
1135
- sessionId: payload.sessionId,
1136
- text: payload.text,
1137
- ...payload.sender === void 0 ? {} : { sender: payload.sender },
1138
- ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1139
- ...payload.resume === void 0 ? {} : { resume: payload.resume }
1134
+ const frame = {
1135
+ type: "msg",
1136
+ reqId,
1137
+ message: {
1138
+ kind,
1139
+ sessionId: payload.sessionId,
1140
+ text: payload.text,
1141
+ ...payload.sender === void 0 ? {} : { sender: payload.sender },
1142
+ ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1143
+ ...payload.resume === void 0 ? {} : { resume: payload.resume }
1144
+ }
1140
1145
  };
1146
+ if (!frameFitsLink(frame)) {
1147
+ return { delivered: false, instance: instanceId, reason: "message-too-large" };
1148
+ }
1141
1149
  return this.waitForResult(reqId, "msg", (failure) => {
1142
- const wrote = state.sendFrame({ type: "msg", reqId, message });
1150
+ const wrote = state.sendFrame(frame);
1143
1151
  if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while sending`));
1144
1152
  });
1145
1153
  }
@@ -1263,7 +1271,12 @@ var InterconnectService = class extends Service {
1263
1271
  if (query.kind === "ping") {
1264
1272
  result = { pong: true, instance: this.instanceId };
1265
1273
  } else if (query.kind === "list") {
1266
- result = this.listSessions();
1274
+ const rowBudget = this.listRowsBudgetBytes(reqId);
1275
+ if (rowBudget < 0) {
1276
+ this.ctx.logger.warn("interconnect: list answer cannot fit the link frame cap; not answering");
1277
+ return;
1278
+ }
1279
+ result = this.listSessions(rowBudget);
1267
1280
  } else {
1268
1281
  const payload = {
1269
1282
  sender: this.peerOf.get(socket) ?? "unknown-peer",
@@ -1291,32 +1304,57 @@ var InterconnectService = class extends Service {
1291
1304
  this.ctx.logger.warn(`interconnect: listener for a ${notification.kind} event from ${sender} threw: ${detail}`);
1292
1305
  });
1293
1306
  }
1307
+ /**
1308
+ * Bytes one `list` answer may spend on its `sessions` rows: the frame cap
1309
+ * minus the serialized envelope that answer will carry. The envelope holds
1310
+ * the peer-supplied `reqId` and this instance's own id, so it is measured per
1311
+ * answer instead of reserved with a fixed constant.
1312
+ * @param reqId - the request id the answer has to echo.
1313
+ * @returns the byte budget for the rows, negative when even an empty answer
1314
+ * exceeds the cap and so cannot be sent at all.
1315
+ */
1316
+ listRowsBudgetBytes(reqId) {
1317
+ const envelope = {
1318
+ type: "query-result",
1319
+ reqId,
1320
+ result: { sessions: [], instance: this.instanceId }
1321
+ };
1322
+ return MAX_LINK_FRAME_BYTES - Buffer.byteLength(JSON.stringify(envelope), "utf8");
1323
+ }
1294
1324
  /**
1295
1325
  * Summarize the live local sessions so a sender can discover valid targets
1296
1326
  * instead of having to know a session id already. Only live agents are listed
1297
- * because `send` can reach exactly those, and the answer carries at most
1298
- * {@link MAX_LISTED_SESSIONS} rows so a large live set cannot push the
1299
- * `query-result` frame past the link's frame cap.
1327
+ * because `send` can reach exactly those. The answer stops at
1328
+ * {@link MAX_LISTED_SESSIONS} rows or at the row budget the answer's own
1329
+ * envelope leaves, whichever comes first: that budget is what keeps the
1330
+ * complete `query-result` frame inside the link's cap, because a title's
1331
+ * length comes from the session-title projection's own bound rather than
1332
+ * from anything this listing controls. A title that does not fit the
1333
+ * remaining budget is dropped so its target stays listed; a row that does not
1334
+ * fit even untitled ends the listing, which keeps the answer a prefix of the
1335
+ * registration order instead of a set with gaps.
1300
1336
  *
1301
1337
  * Title and status are best-effort: the title projection is an optional
1302
1338
  * service, and a receiver without it still returns the ids. A projection that
1303
1339
  * throws degrades that one row rather than failing the whole listing, which
1304
1340
  * matches how the Host's own session listing treats its projection column.
1341
+ * @param rowBudgetBytes - bytes the `sessions` rows may occupy, envelope excluded.
1342
+ * @returns the bounded listing.
1305
1343
  */
1306
- listSessions() {
1344
+ listSessions(rowBudgetBytes) {
1307
1345
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1308
1346
  const sessions = [];
1309
1347
  let used = 0;
1310
1348
  for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
1311
1349
  const row = this.sessionRow(agent);
1312
1350
  const sized = used + rowBytes(row);
1313
- if (sized > MAX_LIST_ROWS_BYTES) {
1351
+ if (sized > rowBudgetBytes) {
1314
1352
  const untitled = {
1315
1353
  sessionId: row.sessionId,
1316
1354
  ...row.status === void 0 ? {} : { status: row.status }
1317
1355
  };
1318
1356
  const untitledSized = used + rowBytes(untitled);
1319
- if (untitledSized > MAX_LIST_ROWS_BYTES) break;
1357
+ if (untitledSized > rowBudgetBytes) break;
1320
1358
  sessions.push(untitled);
1321
1359
  used = untitledSized;
1322
1360
  continue;
@@ -835,9 +835,9 @@ 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;
840
+ var frameFitsLink = (frame) => Buffer.byteLength(JSON.stringify(frame), "utf8") <= MAX_LINK_FRAME_BYTES;
841
841
  var rowBytes = (row) => Buffer.byteLength(JSON.stringify(row), "utf8") + 1;
842
842
  var notificationSchema = Schema.union([
843
843
  Schema.object({ kind: Schema.const("agent/created").required(), sessionId: Schema.string().required() }),
@@ -920,10 +920,10 @@ var msgResultSchema = Schema.union([
920
920
  var linkFrameSchema = Schema.union([
921
921
  Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
922
922
  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() })
923
+ Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), message: messageSchema.required() }),
924
+ Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: msgResultSchema.required() }),
925
+ Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), query: querySchema.required() }),
926
+ Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().max(MAX_REQUEST_ID_CHARS).required(), result: queryResultSchema.required() })
927
927
  ]);
928
928
  var InterconnectService = class extends Service {
929
929
  static inject = ["agents", "credentials"];
@@ -1124,22 +1124,30 @@ var InterconnectService = class extends Service {
1124
1124
  * Send a `msg` frame over the live link to a peer and resolve with its
1125
1125
  * correlated `msg-result`. Undefined when the peer has no live link here
1126
1126
  * (not configured, or the link is down) — sends can only address configured+
1127
- * connected peers by design.
1127
+ * connected peers by design — and a `message-too-large` result when the
1128
+ * encoded frame would not fit the link.
1128
1129
  */
1129
1130
  async msgRequest(instanceId, kind, payload) {
1130
1131
  const state = this.linkStates.get(instanceId);
1131
1132
  if (state === void 0 || !state.writable()) return void 0;
1132
1133
  const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
1133
- const message = {
1134
- kind,
1135
- sessionId: payload.sessionId,
1136
- text: payload.text,
1137
- ...payload.sender === void 0 ? {} : { sender: payload.sender },
1138
- ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1139
- ...payload.resume === void 0 ? {} : { resume: payload.resume }
1134
+ const frame = {
1135
+ type: "msg",
1136
+ reqId,
1137
+ message: {
1138
+ kind,
1139
+ sessionId: payload.sessionId,
1140
+ text: payload.text,
1141
+ ...payload.sender === void 0 ? {} : { sender: payload.sender },
1142
+ ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1143
+ ...payload.resume === void 0 ? {} : { resume: payload.resume }
1144
+ }
1140
1145
  };
1146
+ if (!frameFitsLink(frame)) {
1147
+ return { delivered: false, instance: instanceId, reason: "message-too-large" };
1148
+ }
1141
1149
  return this.waitForResult(reqId, "msg", (failure) => {
1142
- const wrote = state.sendFrame({ type: "msg", reqId, message });
1150
+ const wrote = state.sendFrame(frame);
1143
1151
  if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while sending`));
1144
1152
  });
1145
1153
  }
@@ -1263,7 +1271,12 @@ var InterconnectService = class extends Service {
1263
1271
  if (query.kind === "ping") {
1264
1272
  result = { pong: true, instance: this.instanceId };
1265
1273
  } else if (query.kind === "list") {
1266
- result = this.listSessions();
1274
+ const rowBudget = this.listRowsBudgetBytes(reqId);
1275
+ if (rowBudget < 0) {
1276
+ this.ctx.logger.warn("interconnect: list answer cannot fit the link frame cap; not answering");
1277
+ return;
1278
+ }
1279
+ result = this.listSessions(rowBudget);
1267
1280
  } else {
1268
1281
  const payload = {
1269
1282
  sender: this.peerOf.get(socket) ?? "unknown-peer",
@@ -1291,32 +1304,57 @@ var InterconnectService = class extends Service {
1291
1304
  this.ctx.logger.warn(`interconnect: listener for a ${notification.kind} event from ${sender} threw: ${detail}`);
1292
1305
  });
1293
1306
  }
1307
+ /**
1308
+ * Bytes one `list` answer may spend on its `sessions` rows: the frame cap
1309
+ * minus the serialized envelope that answer will carry. The envelope holds
1310
+ * the peer-supplied `reqId` and this instance's own id, so it is measured per
1311
+ * answer instead of reserved with a fixed constant.
1312
+ * @param reqId - the request id the answer has to echo.
1313
+ * @returns the byte budget for the rows, negative when even an empty answer
1314
+ * exceeds the cap and so cannot be sent at all.
1315
+ */
1316
+ listRowsBudgetBytes(reqId) {
1317
+ const envelope = {
1318
+ type: "query-result",
1319
+ reqId,
1320
+ result: { sessions: [], instance: this.instanceId }
1321
+ };
1322
+ return MAX_LINK_FRAME_BYTES - Buffer.byteLength(JSON.stringify(envelope), "utf8");
1323
+ }
1294
1324
  /**
1295
1325
  * Summarize the live local sessions so a sender can discover valid targets
1296
1326
  * instead of having to know a session id already. Only live agents are listed
1297
- * because `send` can reach exactly those, and the answer carries at most
1298
- * {@link MAX_LISTED_SESSIONS} rows so a large live set cannot push the
1299
- * `query-result` frame past the link's frame cap.
1327
+ * because `send` can reach exactly those. The answer stops at
1328
+ * {@link MAX_LISTED_SESSIONS} rows or at the row budget the answer's own
1329
+ * envelope leaves, whichever comes first: that budget is what keeps the
1330
+ * complete `query-result` frame inside the link's cap, because a title's
1331
+ * length comes from the session-title projection's own bound rather than
1332
+ * from anything this listing controls. A title that does not fit the
1333
+ * remaining budget is dropped so its target stays listed; a row that does not
1334
+ * fit even untitled ends the listing, which keeps the answer a prefix of the
1335
+ * registration order instead of a set with gaps.
1300
1336
  *
1301
1337
  * Title and status are best-effort: the title projection is an optional
1302
1338
  * service, and a receiver without it still returns the ids. A projection that
1303
1339
  * throws degrades that one row rather than failing the whole listing, which
1304
1340
  * matches how the Host's own session listing treats its projection column.
1341
+ * @param rowBudgetBytes - bytes the `sessions` rows may occupy, envelope excluded.
1342
+ * @returns the bounded listing.
1305
1343
  */
1306
- listSessions() {
1344
+ listSessions(rowBudgetBytes) {
1307
1345
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1308
1346
  const sessions = [];
1309
1347
  let used = 0;
1310
1348
  for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
1311
1349
  const row = this.sessionRow(agent);
1312
1350
  const sized = used + rowBytes(row);
1313
- if (sized > MAX_LIST_ROWS_BYTES) {
1351
+ if (sized > rowBudgetBytes) {
1314
1352
  const untitled = {
1315
1353
  sessionId: row.sessionId,
1316
1354
  ...row.status === void 0 ? {} : { status: row.status }
1317
1355
  };
1318
1356
  const untitledSized = used + rowBytes(untitled);
1319
- if (untitledSized > MAX_LIST_ROWS_BYTES) break;
1357
+ if (untitledSized > rowBudgetBytes) break;
1320
1358
  sessions.push(untitled);
1321
1359
  used = untitledSized;
1322
1360
  continue;