dsh-interconnect 0.11.0 → 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.
package/README.md CHANGED
@@ -55,6 +55,10 @@ session-b07326da-… [running]
55
55
  只列 live session 是有意的:`send` 能到达的正好是这些。对端存在但没有运行 agent 的 session
56
56
  不会出现在列表里,也收不到消息。
57
57
 
58
+ 答案有行数上限(`MAX_LISTED_SESSIONS`,100 行):整帧必须待在链路的帧上限内,ws 对超限帧会
59
+ 直接关闭链路,所以 live session 极多的对端只回答前若干行,而不是把传输打断。ping 与 list 的
60
+ 答复还会按调用方问的 kind 校验形状,形状不符按「无答复」处理(对应 `unreachable`)。
61
+
58
62
  ### 回复(`reply`)
59
63
 
60
64
  `send` 的线负载带一个 `sender` 身份(**无地址**:`instanceId` + `sessionId`),收到消息的
@@ -261,7 +265,7 @@ pnpm run build # esbuild → lib/
261
265
 
262
266
  ## 验证
263
267
 
264
- - 145/145 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧绑定等回归用例);
268
+ - 154/154 单测通过(服务 + 工具 + skill,含 wire 校验、畸形帧、心跳/池清理、结果帧绑定、list 上限等回归用例);
265
269
  类型检查、构建均干净。
266
270
  - 已在两台机器之间实测双向互通:消息投递、WebSocket 事件推流、以及 agent 经
267
271
  `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.0",
4
+ "version": "0.11.1",
5
5
  "entry": {
6
6
  "name": "dsh-interconnect/interconnect",
7
7
  "inject": ["agents", "credentials"]
package/lib/index.js CHANGED
@@ -829,12 +829,12 @@ function isSessionOwnedBySubagent(ctx, session, agent) {
829
829
  function assertNever(value) {
830
830
  throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
831
831
  }
832
- var INTERCONNECT_CHANNEL = "/interconnect";
833
832
  var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
834
833
  var PLUGIN_SOURCE = "dsh-interconnect";
835
834
  var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
835
  var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
837
836
  var LINK_CHANNEL = "/interconnect/link";
837
+ var MAX_LISTED_SESSIONS = 100;
838
838
  var notificationSchema = Schema.union([
839
839
  Schema.object({ kind: Schema.const("agent/created").required(), sessionId: Schema.string().required() }),
840
840
  Schema.object({ kind: Schema.const("agent/disposed").required(), sessionId: Schema.string().required() }),
@@ -844,16 +844,26 @@ var notificationSchema = Schema.union([
844
844
  Schema.object({ kind: Schema.const("subagent/end").required(), provider: Schema.string().required(), childSessionId: Schema.string().required(), stopReason: Schema.string().required() })
845
845
  ]);
846
846
  var senderSchema = Schema.object({ instanceId: Schema.string(), sessionId: Schema.string() });
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
+ };
847
864
  var queryResultSchema = Schema.union([
848
- Schema.object({ pong: Schema.const(true).required(), instance: Schema.string().required() }),
849
- Schema.object({
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()
856
- }),
865
+ pingResultSchema,
866
+ listResultSchema,
857
867
  Schema.object({ accepted: Schema.const(true).required() })
858
868
  ]);
859
869
  var messageSchema = Schema.object({
@@ -931,7 +941,10 @@ var InterconnectService = class extends Service {
931
941
  const upgrade = {
932
942
  path: LINK_CHANNEL,
933
943
  handler: (req, socket, head) => {
934
- 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
+ });
935
948
  }
936
949
  };
937
950
  ctx.inject(["webServer"], (webCtx) => {
@@ -1106,7 +1119,7 @@ var InterconnectService = class extends Service {
1106
1119
  const state = this.linkStates.get(instanceId);
1107
1120
  if (state === void 0 || !state.writable()) return void 0;
1108
1121
  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";
1122
+ const accept = query.kind === "ping" ? (result) => acceptsQueryResult(pingResultSchema, result) : (result) => acceptsQueryResult(listResultSchema, result);
1110
1123
  try {
1111
1124
  return await this.waitForResult(reqId, "query", (failure) => {
1112
1125
  const wrote = state.sendFrame({ type: "query", reqId, query });
@@ -1255,9 +1268,11 @@ var InterconnectService = class extends Service {
1255
1268
  this.ctx.emit("interconnect/event", eventPayload.notification, eventPayload.sender);
1256
1269
  }
1257
1270
  /**
1258
- * 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
1259
1272
  * instead of having to know a session id already. Only live agents are listed
1260
- * 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.
1261
1276
  *
1262
1277
  * Title and status are best-effort: the title projection is an optional
1263
1278
  * service, and a receiver without it still returns the ids. A projection that
@@ -1266,7 +1281,7 @@ var InterconnectService = class extends Service {
1266
1281
  */
1267
1282
  listSessions() {
1268
1283
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1269
- const sessions = reachable.map((agent) => {
1284
+ const sessions = reachable.slice(0, MAX_LISTED_SESSIONS).map((agent) => {
1270
1285
  let title;
1271
1286
  try {
1272
1287
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
@@ -1391,6 +1406,10 @@ var InterconnectService = class extends Service {
1391
1406
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 9\r\n\r\nforbidden");
1392
1407
  return;
1393
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
+ }
1394
1413
  const expected = `Bearer ${token}`;
1395
1414
  const header = req.headers.authorization;
1396
1415
  if (header === void 0 || !timingSafeEqual(header, expected)) {
@@ -1416,7 +1435,8 @@ var InterconnectService = class extends Service {
1416
1435
  ;
1417
1436
  websocket.isAlive = true;
1418
1437
  });
1419
- websocket.on("message", (data) => {
1438
+ websocket.on("message", (data, isBinary) => {
1439
+ if (isBinary) return;
1420
1440
  this.handleFrame(websocket, data);
1421
1441
  });
1422
1442
  websocket.on("close", () => {
@@ -1430,12 +1450,12 @@ var InterconnectService = class extends Service {
1430
1450
  }
1431
1451
  /** Parse and route one inbound link frame, attributing events to the socket's announced peer. */
1432
1452
  handleFrame(socket, data) {
1433
- if (Array.isArray(data)) return;
1434
- if (data.byteLength > MAX_LINK_FRAME_BYTES) {
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) {
1435
1455
  this.ctx.logger.warn("interconnect: dropping oversized link frame");
1436
1456
  return;
1437
1457
  }
1438
- const text = Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
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");
1439
1459
  let parsed;
1440
1460
  try {
1441
1461
  parsed = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
@@ -1519,6 +1539,7 @@ var LinkState = class {
1519
1539
  closed = false;
1520
1540
  retry = 0;
1521
1541
  reconnectTimer;
1542
+ warnedNoToken = false;
1522
1543
  /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1523
1544
  dialListeners;
1524
1545
  // `peer` is mutable so `reroute` can point the link at a new origin.
@@ -1556,7 +1577,12 @@ var LinkState = class {
1556
1577
  }
1557
1578
  void this.resolveToken().then((token) => {
1558
1579
  if (token === void 0) {
1559
- this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down until one is set`);
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();
1560
1586
  return;
1561
1587
  }
1562
1588
  if (this.closed || epoch !== this.dialEpoch) return;
@@ -1651,7 +1677,6 @@ function trimBase(baseUrl) {
1651
1677
  }
1652
1678
  var interconnect_default = InterconnectService;
1653
1679
  export {
1654
- INTERCONNECT_CHANNEL,
1655
1680
  INTERCONNECT_TOKEN_REF,
1656
1681
  InterconnectService,
1657
1682
  interconnect_default as default
@@ -829,12 +829,12 @@ function isSessionOwnedBySubagent(ctx, session, agent) {
829
829
  function assertNever(value) {
830
830
  throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
831
831
  }
832
- var INTERCONNECT_CHANNEL = "/interconnect";
833
832
  var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
834
833
  var PLUGIN_SOURCE = "dsh-interconnect";
835
834
  var MAX_LINK_FRAME_BYTES = 1024 * 1024;
836
835
  var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
837
836
  var LINK_CHANNEL = "/interconnect/link";
837
+ var MAX_LISTED_SESSIONS = 100;
838
838
  var notificationSchema = Schema.union([
839
839
  Schema.object({ kind: Schema.const("agent/created").required(), sessionId: Schema.string().required() }),
840
840
  Schema.object({ kind: Schema.const("agent/disposed").required(), sessionId: Schema.string().required() }),
@@ -844,16 +844,26 @@ var notificationSchema = Schema.union([
844
844
  Schema.object({ kind: Schema.const("subagent/end").required(), provider: Schema.string().required(), childSessionId: Schema.string().required(), stopReason: Schema.string().required() })
845
845
  ]);
846
846
  var senderSchema = Schema.object({ instanceId: Schema.string(), sessionId: Schema.string() });
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
+ };
847
864
  var queryResultSchema = Schema.union([
848
- Schema.object({ pong: Schema.const(true).required(), instance: Schema.string().required() }),
849
- Schema.object({
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()
856
- }),
865
+ pingResultSchema,
866
+ listResultSchema,
857
867
  Schema.object({ accepted: Schema.const(true).required() })
858
868
  ]);
859
869
  var messageSchema = Schema.object({
@@ -931,7 +941,10 @@ var InterconnectService = class extends Service {
931
941
  const upgrade = {
932
942
  path: LINK_CHANNEL,
933
943
  handler: (req, socket, head) => {
934
- 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
+ });
935
948
  }
936
949
  };
937
950
  ctx.inject(["webServer"], (webCtx) => {
@@ -1106,7 +1119,7 @@ var InterconnectService = class extends Service {
1106
1119
  const state = this.linkStates.get(instanceId);
1107
1120
  if (state === void 0 || !state.writable()) return void 0;
1108
1121
  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";
1122
+ const accept = query.kind === "ping" ? (result) => acceptsQueryResult(pingResultSchema, result) : (result) => acceptsQueryResult(listResultSchema, result);
1110
1123
  try {
1111
1124
  return await this.waitForResult(reqId, "query", (failure) => {
1112
1125
  const wrote = state.sendFrame({ type: "query", reqId, query });
@@ -1255,9 +1268,11 @@ var InterconnectService = class extends Service {
1255
1268
  this.ctx.emit("interconnect/event", eventPayload.notification, eventPayload.sender);
1256
1269
  }
1257
1270
  /**
1258
- * 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
1259
1272
  * instead of having to know a session id already. Only live agents are listed
1260
- * 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.
1261
1276
  *
1262
1277
  * Title and status are best-effort: the title projection is an optional
1263
1278
  * service, and a receiver without it still returns the ids. A projection that
@@ -1266,7 +1281,7 @@ var InterconnectService = class extends Service {
1266
1281
  */
1267
1282
  listSessions() {
1268
1283
  const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
1269
- const sessions = reachable.map((agent) => {
1284
+ const sessions = reachable.slice(0, MAX_LISTED_SESSIONS).map((agent) => {
1270
1285
  let title;
1271
1286
  try {
1272
1287
  const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
@@ -1391,6 +1406,10 @@ var InterconnectService = class extends Service {
1391
1406
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 9\r\n\r\nforbidden");
1392
1407
  return;
1393
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
+ }
1394
1413
  const expected = `Bearer ${token}`;
1395
1414
  const header = req.headers.authorization;
1396
1415
  if (header === void 0 || !timingSafeEqual(header, expected)) {
@@ -1416,7 +1435,8 @@ var InterconnectService = class extends Service {
1416
1435
  ;
1417
1436
  websocket.isAlive = true;
1418
1437
  });
1419
- websocket.on("message", (data) => {
1438
+ websocket.on("message", (data, isBinary) => {
1439
+ if (isBinary) return;
1420
1440
  this.handleFrame(websocket, data);
1421
1441
  });
1422
1442
  websocket.on("close", () => {
@@ -1430,12 +1450,12 @@ var InterconnectService = class extends Service {
1430
1450
  }
1431
1451
  /** Parse and route one inbound link frame, attributing events to the socket's announced peer. */
1432
1452
  handleFrame(socket, data) {
1433
- if (Array.isArray(data)) return;
1434
- if (data.byteLength > MAX_LINK_FRAME_BYTES) {
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) {
1435
1455
  this.ctx.logger.warn("interconnect: dropping oversized link frame");
1436
1456
  return;
1437
1457
  }
1438
- const text = Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
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");
1439
1459
  let parsed;
1440
1460
  try {
1441
1461
  parsed = Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0];
@@ -1519,6 +1539,7 @@ var LinkState = class {
1519
1539
  closed = false;
1520
1540
  retry = 0;
1521
1541
  reconnectTimer;
1542
+ warnedNoToken = false;
1522
1543
  /** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
1523
1544
  dialListeners;
1524
1545
  // `peer` is mutable so `reroute` can point the link at a new origin.
@@ -1556,7 +1577,12 @@ var LinkState = class {
1556
1577
  }
1557
1578
  void this.resolveToken().then((token) => {
1558
1579
  if (token === void 0) {
1559
- this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down until one is set`);
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();
1560
1586
  return;
1561
1587
  }
1562
1588
  if (this.closed || epoch !== this.dialEpoch) return;
@@ -1651,7 +1677,6 @@ function trimBase(baseUrl) {
1651
1677
  }
1652
1678
  var index_default = InterconnectService;
1653
1679
  export {
1654
- INTERCONNECT_CHANNEL,
1655
1680
  INTERCONNECT_TOKEN_REF,
1656
1681
  InterconnectService,
1657
1682
  index_default as default,