@band-ai/sdk 0.3.3 → 0.4.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.
Files changed (97) hide show
  1. package/README.md +54 -1
  2. package/dist/{ClaudeSDKAdapter-CXud2DBE.d.ts → CopilotACPAdapter-BCpbHFB0.d.ts} +240 -19
  3. package/dist/{ClaudeSDKAdapter-Cx6zhSaG.d.cts → CopilotACPAdapter-D4VWg8lJ.d.cts} +240 -19
  4. package/dist/{opencode-2YdscIc1.d.cts → acp-client-D-I_5lK-.d.cts} +23 -4
  5. package/dist/{opencode-Bld62x5t.d.ts → acp-client-DUXyczlF.d.ts} +23 -4
  6. package/dist/acp-server-BlcYMxKt.d.ts +17 -0
  7. package/dist/acp-server-CJhclo6P.d.cts +17 -0
  8. package/dist/adapters.cjs +2217 -973
  9. package/dist/adapters.d.cts +21 -132
  10. package/dist/adapters.d.ts +21 -132
  11. package/dist/adapters.js +26 -1914
  12. package/dist/{backends-D-dBdDm-.d.ts → backends-CgsPQa9B.d.ts} +2 -2
  13. package/dist/{backends-ZLxmQrTZ.d.cts → backends-Dh2WXcHQ.d.cts} +2 -2
  14. package/dist/{chunk-JY75VSQK.js → chunk-4OVNFDD7.js} +14 -1
  15. package/dist/chunk-6BE2EG5P.js +128 -0
  16. package/dist/{chunk-U6AFCLYZ.js → chunk-6TGXV5TE.js} +1824 -514
  17. package/dist/{chunk-OH6VVVKT.js → chunk-CBCHTTUY.js} +10 -0
  18. package/dist/{chunk-K5KAEYPF.js → chunk-HNWZZXAE.js} +288 -120
  19. package/dist/chunk-I34SXMTJ.js +6 -0
  20. package/dist/{chunk-XWHWJP4I.js → chunk-JDW5WSGF.js} +2 -2
  21. package/dist/{chunk-KXJ74J73.js → chunk-JIZE4EC6.js} +4 -10
  22. package/dist/chunk-OGOXY43L.js +119 -0
  23. package/dist/{chunk-W5IAI6Z4.js → chunk-OVXVZUPO.js} +5 -5
  24. package/dist/{chunk-YJ42AZO4.js → chunk-RJNJUISR.js} +5 -2
  25. package/dist/{chunk-MQY74R7D.js → chunk-SHZBLLDQ.js} +21 -9
  26. package/dist/{chunk-JADCAMZY.js → chunk-TGDDFBYS.js} +45 -0
  27. package/dist/{chunk-US226AS4.js → chunk-UENIC5FJ.js} +14 -11
  28. package/dist/{chunk-WA7CHXUS.js → chunk-UL3Y5C4J.js} +2 -2
  29. package/dist/{chunk-WL2HNJZ6.js → chunk-VOMJRQOM.js} +3 -3
  30. package/dist/{chunk-MQBBWYRP.js → chunk-YF7K4UB3.js} +3352 -572
  31. package/dist/{chunk-OD2G5LFQ.js → chunk-ZYOXI4JL.js} +41 -2
  32. package/dist/config.cjs +2 -12
  33. package/dist/config.js +2 -2
  34. package/dist/converters.cjs +36 -11
  35. package/dist/converters.d.cts +7 -5
  36. package/dist/converters.d.ts +7 -5
  37. package/dist/converters.js +11 -13
  38. package/dist/core.cjs +239 -5
  39. package/dist/core.d.cts +71 -6
  40. package/dist/core.d.ts +71 -6
  41. package/dist/core.js +31 -6
  42. package/dist/{customTools-BzF0IISO.d.cts → customTools-Bfecd0mJ.d.cts} +1 -1
  43. package/dist/{customTools-BzF0IISO.d.ts → customTools-Bfecd0mJ.d.ts} +1 -1
  44. package/dist/{disconnectReason-IuADl6TH.d.ts → disconnectReason-Cctmg1SN.d.cts} +1 -1
  45. package/dist/{disconnectReason-IuADl6TH.d.cts → disconnectReason-Cctmg1SN.d.ts} +1 -1
  46. package/dist/{dtos-B8KU_q4d.d.ts → dtos-CjQsN1_h.d.cts} +5 -12
  47. package/dist/{dtos-B8KU_q4d.d.cts → dtos-CjQsN1_h.d.ts} +5 -12
  48. package/dist/index.cjs +5237 -1474
  49. package/dist/index.d.cts +61 -22
  50. package/dist/index.d.ts +61 -22
  51. package/dist/index.js +151 -43
  52. package/dist/linear.cjs +45 -7
  53. package/dist/linear.d.cts +4 -3
  54. package/dist/linear.d.ts +4 -3
  55. package/dist/linear.js +10 -10
  56. package/dist/mcp-claude.cjs +11 -6
  57. package/dist/mcp-claude.d.cts +4 -3
  58. package/dist/mcp-claude.d.ts +4 -3
  59. package/dist/mcp-claude.js +4 -5
  60. package/dist/mcp.cjs +13 -7
  61. package/dist/mcp.d.cts +6 -5
  62. package/dist/mcp.d.ts +6 -5
  63. package/dist/mcp.js +5 -6
  64. package/dist/{pagination-DqdkQArT.d.cts → pagination-BZDhB_nO.d.cts} +2 -2
  65. package/dist/{pagination-BgEx-HeL.d.ts → pagination-CPz0J_vQ.d.ts} +2 -2
  66. package/dist/{protocols-Bz1rxcUg.d.cts → protocols-CabgO52c.d.cts} +38 -3
  67. package/dist/{protocols-Bwwcql0h.d.ts → protocols-Dpci6uae.d.ts} +38 -3
  68. package/dist/rest.cjs +43 -1
  69. package/dist/rest.d.cts +6 -5
  70. package/dist/rest.d.ts +6 -5
  71. package/dist/rest.js +6 -5
  72. package/dist/runtime.cjs +2113 -597
  73. package/dist/runtime.d.cts +44 -25
  74. package/dist/runtime.d.ts +44 -25
  75. package/dist/runtime.js +11 -11
  76. package/dist/{schemas-BgJhlqqO.d.cts → schemas-BKnh4fKR.d.cts} +209 -24
  77. package/dist/{schemas-C4mK1WSs.d.ts → schemas-CDa3V_I5.d.ts} +209 -24
  78. package/dist/{sdk-BTciSLRQ.d.cts → sdk-CXNqzoY1.d.cts} +1 -1
  79. package/dist/{sdk-qKJtMma_.d.ts → sdk-Cin80BTC.d.ts} +1 -1
  80. package/dist/{simpleAdapter-BpT4XbZC.d.cts → simpleAdapter-D75rcz9n.d.cts} +3 -1
  81. package/dist/{simpleAdapter--wznuoOw.d.ts → simpleAdapter-eLvsAQNo.d.ts} +3 -1
  82. package/dist/testing.cjs +25 -7
  83. package/dist/testing.d.cts +5 -3
  84. package/dist/testing.d.ts +5 -3
  85. package/dist/testing.js +9 -6
  86. package/dist/{types-BivGO7I9.d.ts → types-42-HWeX8.d.cts} +10 -4
  87. package/dist/types-CKU1N0SK.d.ts +137 -0
  88. package/dist/{types-BgYFd_Yw.d.cts → types-CjxZekMH.d.ts} +10 -4
  89. package/dist/types-DtcOLALn.d.cts +137 -0
  90. package/package.json +25 -26
  91. package/dist/acp-server-CiUqN3G3.d.ts +0 -24
  92. package/dist/acp-server-Dlj7D563.d.cts +0 -24
  93. package/dist/chunk-AVVDPXP4.js +0 -39
  94. package/dist/chunk-F63TPOI3.js +0 -29
  95. package/dist/chunk-V5TSWS7P.js +0 -62
  96. package/dist/types-BNNtOXu3.d.cts +0 -446
  97. package/dist/types-DFUtpxah.d.ts +0 -446
package/dist/runtime.cjs CHANGED
@@ -41,11 +41,11 @@ __export(runtime_exports, {
41
41
  MCP_TOOL_PREFIX: () => MCP_TOOL_PREFIX,
42
42
  MEMORY_SECTION: () => MEMORY_SECTION,
43
43
  MEMORY_TOOL_NAMES: () => MEMORY_TOOL_NAMES,
44
- ParticipantRoster: () => import_band_sdk_core6.ParticipantRoster,
44
+ ParticipantRoster: () => import_band_sdk_core10.ParticipantRoster,
45
45
  PlatformRuntime: () => PlatformRuntime,
46
- RetryTracker: () => import_band_sdk_core6.RetryTracker,
46
+ RetryTracker: () => import_band_sdk_core10.RetryTracker,
47
47
  RoomPresence: () => RoomPresence,
48
- RoomRoster: () => import_band_sdk_core6.RoomRoster,
48
+ RoomRoster: () => import_band_sdk_core10.RoomRoster,
49
49
  SYNTHETIC_CONTACT_EVENTS_SENDER_ID: () => SYNTHETIC_CONTACT_EVENTS_SENDER_ID,
50
50
  SYNTHETIC_CONTACT_EVENTS_SENDER_NAME: () => SYNTHETIC_CONTACT_EVENTS_SENDER_NAME,
51
51
  SYNTHETIC_SENDER_TYPE: () => SYNTHETIC_SENDER_TYPE,
@@ -89,14 +89,175 @@ function ensureHandlePrefix(handle) {
89
89
  return handle.startsWith("@") ? handle : `@${handle}`;
90
90
  }
91
91
 
92
+ // src/core/sensitiveTerms.ts
93
+ var SENSITIVE_KEY_TERMS = "authorization|api[-_ ]?key|token|secret|password|cookie";
94
+ var GATEWAY_FREE_TEXT_SENSITIVE_KEY_TERMS = `${SENSITIVE_KEY_TERMS}|session`;
95
+
92
96
  // src/core/logger.ts
93
97
  var noop = () => void 0;
98
+ var SENSITIVE_KEY_PATTERN = new RegExp(`(${SENSITIVE_KEY_TERMS})`, "i");
94
99
  var NoopLogger = class {
95
100
  debug = noop;
96
101
  info = noop;
97
102
  warn = noop;
98
103
  error = noop;
99
104
  };
105
+ function resolveLogger(logger) {
106
+ if (!logger) {
107
+ return new NoopLogger();
108
+ }
109
+ return logger instanceof GuardedLogger ? logger : new GuardedLogger(logger);
110
+ }
111
+ var GuardedLogger = class {
112
+ constructor(inner) {
113
+ this.inner = inner;
114
+ }
115
+ debug(message, context) {
116
+ return this.emit("debug", message, context);
117
+ }
118
+ info(message, context) {
119
+ return this.emit("info", message, context);
120
+ }
121
+ warn(message, context) {
122
+ return this.emit("warn", message, context);
123
+ }
124
+ error(message, context) {
125
+ return this.emit("error", message, context);
126
+ }
127
+ emit(level, message, context) {
128
+ try {
129
+ const result = this.inner[level](message, context);
130
+ if (result instanceof Promise) {
131
+ result.catch(noop);
132
+ }
133
+ return result;
134
+ } catch {
135
+ }
136
+ }
137
+ };
138
+
139
+ // src/core/coercion.ts
140
+ var MAX_ERROR_CAUSE_DEPTH = 5;
141
+ var MAX_ERROR_DETAIL_LENGTH = 500;
142
+ var LENGTH_TRUNCATED_MARKER = "... (truncated)";
143
+ var DEPTH_TRUNCATED_MARKER = "... (cause chain truncated)";
144
+ function asOptionalRecord(value) {
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
146
+ return void 0;
147
+ }
148
+ return value;
149
+ }
150
+ function asNonEmptyString(value) {
151
+ if (typeof value !== "string") {
152
+ return null;
153
+ }
154
+ const trimmed = value.trim();
155
+ return trimmed.length > 0 ? trimmed : null;
156
+ }
157
+ function toDisplayText(value) {
158
+ if (typeof value === "string") {
159
+ return value;
160
+ }
161
+ if (value == null) {
162
+ return "";
163
+ }
164
+ try {
165
+ const json = JSON.stringify(value);
166
+ return typeof json === "string" ? json : String(value);
167
+ } catch {
168
+ return String(value);
169
+ }
170
+ }
171
+ function asErrorMessage(error) {
172
+ return formatCaughtError(error, 0);
173
+ }
174
+ function formatCaughtError(error, depth) {
175
+ if (typeof error !== "object" || error === null) {
176
+ return truncate(String(error));
177
+ }
178
+ const message = truncate(asNestedMessage(error) ?? String(error));
179
+ const detail = selectDetail(asOptionalRecord(error));
180
+ if (detail === void 0) {
181
+ return message;
182
+ }
183
+ if (depth >= MAX_ERROR_CAUSE_DEPTH) {
184
+ return `${message} (${DEPTH_TRUNCATED_MARKER})`;
185
+ }
186
+ return `${message} (${formatErrorDetail(detail, depth + 1)})`;
187
+ }
188
+ function selectDetail(record) {
189
+ if (isPresentDetail(record?.data)) {
190
+ return record?.data;
191
+ }
192
+ if (isPresentDetail(record?.cause)) {
193
+ return record?.cause;
194
+ }
195
+ return void 0;
196
+ }
197
+ function isPresentDetail(value) {
198
+ if (value === void 0 || value === null) {
199
+ return false;
200
+ }
201
+ if (typeof value === "string") {
202
+ return asNonEmptyString(value) !== null;
203
+ }
204
+ if (value instanceof Error) {
205
+ return true;
206
+ }
207
+ if (Array.isArray(value)) {
208
+ return value.length > 0;
209
+ }
210
+ if (typeof value === "object") {
211
+ return isPlainObject(value) ? Object.keys(value).length > 0 : true;
212
+ }
213
+ return true;
214
+ }
215
+ function isPlainObject(value) {
216
+ const proto = Object.getPrototypeOf(value);
217
+ return proto === Object.prototype || proto === null;
218
+ }
219
+ function formatErrorDetail(detail, depth) {
220
+ if (detail instanceof Error || selectDetail(asOptionalRecord(detail)) !== void 0) {
221
+ return formatCaughtError(detail, depth);
222
+ }
223
+ return truncate(formatLeafDetail(detail));
224
+ }
225
+ function formatLeafDetail(detail) {
226
+ if (typeof detail === "string") {
227
+ return detail;
228
+ }
229
+ if (typeof detail === "number" && !Number.isFinite(detail)) {
230
+ return String(detail);
231
+ }
232
+ const builtin = formatNonEnumerableDetail(detail);
233
+ if (builtin !== null) {
234
+ return builtin;
235
+ }
236
+ const nested = asNestedMessage(detail);
237
+ if (nested !== null) {
238
+ return nested;
239
+ }
240
+ return toDisplayText(detail);
241
+ }
242
+ function formatNonEnumerableDetail(detail) {
243
+ if (detail instanceof RegExp) {
244
+ return detail.toString();
245
+ }
246
+ if (detail instanceof Map) {
247
+ return `Map ${toDisplayText([...detail])}`;
248
+ }
249
+ if (detail instanceof Set) {
250
+ return `Set ${toDisplayText([...detail])}`;
251
+ }
252
+ return null;
253
+ }
254
+ function truncate(text) {
255
+ return text.length > MAX_ERROR_DETAIL_LENGTH ? `${text.slice(0, MAX_ERROR_DETAIL_LENGTH)}${LENGTH_TRUNCATED_MARKER}` : text;
256
+ }
257
+ function asNestedMessage(value) {
258
+ const record = asOptionalRecord(value);
259
+ return asNonEmptyString(record?.message);
260
+ }
100
261
 
101
262
  // src/client/rest/requestOptions.ts
102
263
  var DEFAULT_REQUEST_OPTIONS = {
@@ -135,6 +296,80 @@ var RuntimeStateError = class extends BandSdkError {
135
296
  this.name = "RuntimeStateError";
136
297
  }
137
298
  };
299
+ var RecoverableTurnError = class extends BandSdkError {
300
+ constructor(message, cause) {
301
+ super(message, cause);
302
+ this.name = "RecoverableTurnError";
303
+ }
304
+ };
305
+
306
+ // src/contracts/chatEvents.ts
307
+ var CHAT_EVENT_TYPES = ["tool_call", "tool_result", "thought", "error", "task"];
308
+ var CHAT_MESSAGE_TYPES = ["text", ...CHAT_EVENT_TYPES];
309
+ function isChatEventType(value) {
310
+ return CHAT_EVENT_TYPES.includes(value);
311
+ }
312
+ function assertChatEventType(value) {
313
+ if (!isChatEventType(value)) {
314
+ throw new ValidationError(
315
+ `Invalid event message_type '${value}'. Expected one of: ${CHAT_EVENT_TYPES.join(", ")}`
316
+ );
317
+ }
318
+ }
319
+ var VISIBLE_CONTENT_PATTERN = /[\p{L}\p{N}\p{P}\p{S}]/u;
320
+ function isBlankEventContent(content) {
321
+ return !VISIBLE_CONTENT_PATTERN.test(content);
322
+ }
323
+
324
+ // src/contracts/protocols.ts
325
+ var FAILURE_EVENT_TYPE = "error";
326
+ var FAILURE_METADATA_KEY = "failure";
327
+ function sendFailureViaEvent(sendEvent, failure) {
328
+ const { content, messageType, metadata } = toFailureEvent(failure);
329
+ return sendEvent(content, messageType, metadata);
330
+ }
331
+ function toFailureEvent(failure) {
332
+ const content = isBlankEventContent(failure.message) ? `${failure.provider} failed without an error message.` : failure.message;
333
+ return {
334
+ content,
335
+ messageType: FAILURE_EVENT_TYPE,
336
+ metadata: { [FAILURE_METADATA_KEY]: failure.toObject() }
337
+ };
338
+ }
339
+ var TOOL_EXECUTOR_ERROR_TYPES = [
340
+ "ToolArgumentsValidationError",
341
+ "ToolNotFoundError",
342
+ "ToolExecutionError"
343
+ ];
344
+ function createToolExecutorError(input) {
345
+ return {
346
+ ok: false,
347
+ errorType: input.errorType,
348
+ toolName: input.toolName,
349
+ message: input.message,
350
+ legacyMessage: input.legacyMessage ?? input.message,
351
+ ...input.details ? { details: input.details } : {}
352
+ };
353
+ }
354
+ function isToolExecutorError(value) {
355
+ if (!value || typeof value !== "object") {
356
+ return false;
357
+ }
358
+ const candidate = value;
359
+ return candidate.ok === false && typeof candidate.errorType === "string" && TOOL_EXECUTOR_ERROR_TYPES.includes(candidate.errorType) && typeof candidate.toolName === "string" && typeof candidate.message === "string" && typeof candidate.legacyMessage === "string";
360
+ }
361
+ function isStructuredToolFailure(value) {
362
+ if (!value || typeof value !== "object") {
363
+ return false;
364
+ }
365
+ const payload = value;
366
+ return payload.ok === false && typeof payload.message === "string";
367
+ }
368
+ var DEFAULT_AGENT_TOOLS_CAPABILITIES = {
369
+ peers: true,
370
+ contacts: true,
371
+ memory: true
372
+ };
138
373
 
139
374
  // src/runtime/formatters.ts
140
375
  function isMetadataMap(value) {
@@ -338,9 +573,11 @@ var ContactCallbackTools = class {
338
573
  rest;
339
574
  roomId;
340
575
  contactTools;
341
- constructor(rest, roomId) {
576
+ logger;
577
+ constructor(rest, roomId, logger) {
342
578
  this.rest = rest;
343
579
  this.roomId = roomId;
580
+ this.logger = resolveLogger(logger);
344
581
  const hasContactMethods = Boolean(
345
582
  rest.listContacts || rest.addContact || rest.removeContact || rest.listContactRequests || rest.respondContactRequest
346
583
  );
@@ -375,14 +612,22 @@ var ContactCallbackTools = class {
375
612
  if (!this.rest.createChatEvent) {
376
613
  throw new UnsupportedFeatureError("Event sending is not available in current REST adapter");
377
614
  }
378
- return this.rest.createChatEvent(
379
- roomId,
380
- {
381
- content,
382
- messageType,
383
- ...metadata ? { metadata } : {}
384
- }
385
- );
615
+ try {
616
+ return await this.rest.createChatEvent(
617
+ roomId,
618
+ {
619
+ content,
620
+ messageType,
621
+ ...metadata ? { metadata } : {}
622
+ }
623
+ );
624
+ } catch (error) {
625
+ this.logger.warn("contact callback chat event send failed", { roomId, messageType, error });
626
+ return { ok: false, status: "failed", message: asErrorMessage(error) };
627
+ }
628
+ }
629
+ async sendFailure(failure) {
630
+ return sendFailureViaEvent(this.sendEvent.bind(this), failure);
386
631
  }
387
632
  async addParticipant() {
388
633
  throw new UnsupportedFeatureError("addParticipant is unavailable for contact callbacks");
@@ -613,7 +858,7 @@ var ContactEventHandler = class {
613
858
  constructor(options) {
614
859
  this.config = options.config;
615
860
  this.rest = options.rest;
616
- this.logger = options.logger ?? new NoopLogger();
861
+ this.logger = resolveLogger(options.logger);
617
862
  this.onBroadcast = options.onBroadcast;
618
863
  this.onHubEvent = options.onHubEvent;
619
864
  this.onHubInit = options.onHubInit;
@@ -663,7 +908,7 @@ var ContactEventHandler = class {
663
908
  this.logger.warn("Contact event callback strategy configured but no onEvent callback provided");
664
909
  return;
665
910
  }
666
- const callbackTools = tools ?? new ContactCallbackTools(this.rest, event.roomId);
911
+ const callbackTools = tools ?? new ContactCallbackTools(this.rest, event.roomId, this.logger);
667
912
  try {
668
913
  await callback(event, callbackTools);
669
914
  } catch (error) {
@@ -748,7 +993,8 @@ var ContactEventHandler = class {
748
993
  const msg = event.payload.message ? `
749
994
  Message: "${event.payload.message}"` : "";
750
995
  const handle = normalizeHandle(event.payload.from_handle);
751
- return `[Contact Request] ${event.payload.from_name} (${handle}) wants to connect.${msg}
996
+ const name = displayName(event.payload.from_name);
997
+ return `[Contact Request] ${name} (${handle}) wants to connect.${msg}
752
998
  Request ID: ${event.payload.id}`;
753
999
  }
754
1000
  case "contact_request_updated": {
@@ -767,7 +1013,8 @@ Request ID: ${event.payload.id}`;
767
1013
  }
768
1014
  case "contact_added": {
769
1015
  const handle = normalizeHandle(event.payload.handle);
770
- return `[Contact Added] ${event.payload.name} (${handle}) is now a contact.
1016
+ const name = displayName(event.payload.name);
1017
+ return `[Contact Added] ${name} (${handle}) is now a contact.
771
1018
  Type: ${event.payload.type}, ID: ${event.payload.id}`;
772
1019
  }
773
1020
  case "contact_removed":
@@ -779,7 +1026,7 @@ Type: ${event.payload.type}, ID: ${event.payload.id}`;
779
1026
  switch (event.type) {
780
1027
  case "contact_added": {
781
1028
  const handle = normalizeHandle(event.payload.handle);
782
- return `[Contacts]: ${handle} (${event.payload.name}) is now a contact`;
1029
+ return `[Contacts]: ${handle} (${displayName(event.payload.name)}) is now a contact`;
783
1030
  }
784
1031
  case "contact_removed":
785
1032
  return `[Contacts]: Contact ${event.payload.id} was removed`;
@@ -910,6 +1157,254 @@ function normalizeHandle(handle) {
910
1157
  if (!handle) return "@unknown";
911
1158
  return handle.startsWith("@") ? handle : `@${handle}`;
912
1159
  }
1160
+ function displayName(name) {
1161
+ return name?.trim() || "Unknown contact";
1162
+ }
1163
+
1164
+ // src/runtime/lifecycle.ts
1165
+ var RUNTIME_LIFECYCLE_TRANSITIONS = {
1166
+ not_started: ["starting"],
1167
+ starting: ["running", "stopping", "stopped", "failed"],
1168
+ running: ["stopping", "failed"],
1169
+ stopping: ["stopped", "failed"],
1170
+ stopped: ["starting"],
1171
+ failed: ["starting", "stopping", "failed"]
1172
+ };
1173
+ function isLegalRuntimeTransition(from, to) {
1174
+ return RUNTIME_LIFECYCLE_TRANSITIONS[from].includes(to);
1175
+ }
1176
+ var EXECUTION_LIFECYCLE_TRANSITIONS = {
1177
+ running: ["stopping", "failed"],
1178
+ stopping: ["stopped", "failed"],
1179
+ stopped: [],
1180
+ failed: []
1181
+ };
1182
+ function isLegalExecutionTransition(from, to) {
1183
+ return EXECUTION_LIFECYCLE_TRANSITIONS[from].includes(to);
1184
+ }
1185
+ function toLifecycleError(value) {
1186
+ return value instanceof Error ? value : new Error(String(value));
1187
+ }
1188
+ var TerminalSignal = class {
1189
+ waiters = /* @__PURE__ */ new Set();
1190
+ outcome = null;
1191
+ /**
1192
+ * Resolves once {@link settle} has been called with `null`, rejects once it has
1193
+ * been called with an error — including for callers that arrive afterwards.
1194
+ */
1195
+ async wait() {
1196
+ if (this.outcome) {
1197
+ if (this.outcome.error) {
1198
+ throw this.outcome.error;
1199
+ }
1200
+ return;
1201
+ }
1202
+ await new Promise((resolve, reject) => {
1203
+ this.waiters.add({ resolve, reject });
1204
+ });
1205
+ }
1206
+ /**
1207
+ * Forget the recorded outcome so a restarted owner can settle again.
1208
+ *
1209
+ * Callers already parked in {@link wait} stay parked — a wait started before a
1210
+ * restart still resolves only when the owner actually stops or fails.
1211
+ */
1212
+ rearm() {
1213
+ this.outcome = null;
1214
+ }
1215
+ /** First settle wins; later ones are ignored, so a terminal state stays terminal. */
1216
+ settle(error) {
1217
+ if (this.outcome) {
1218
+ return;
1219
+ }
1220
+ this.outcome = { error };
1221
+ const pending = [...this.waiters];
1222
+ this.waiters.clear();
1223
+ for (const waiter of pending) {
1224
+ if (error) {
1225
+ waiter.reject(error);
1226
+ } else {
1227
+ waiter.resolve();
1228
+ }
1229
+ }
1230
+ }
1231
+ };
1232
+ var SingleFlight = class {
1233
+ operation = null;
1234
+ /** The in-flight (or last latched) operation, if any. */
1235
+ get pending() {
1236
+ return this.operation;
1237
+ }
1238
+ /** Forget the latched operation so the next {@link run}/{@link runOrRetry} starts fresh. */
1239
+ reset() {
1240
+ this.operation = null;
1241
+ }
1242
+ /**
1243
+ * Runs `factory()` once; concurrent and later callers get the same promise.
1244
+ * The result — success or failure — stays latched until {@link reset}.
1245
+ */
1246
+ run(factory) {
1247
+ if (this.operation) {
1248
+ return this.operation;
1249
+ }
1250
+ const operation = factory();
1251
+ this.operation = operation;
1252
+ return operation;
1253
+ }
1254
+ /**
1255
+ * Like {@link run}, but unlatches on rejection so the next call retries
1256
+ * instead of replaying the same failure. Only unlatches its own operation —
1257
+ * one already replaced by a {@link reset} or a subsequent call is left alone.
1258
+ */
1259
+ async runOrRetry(factory) {
1260
+ if (this.operation) {
1261
+ return this.operation;
1262
+ }
1263
+ const operation = factory();
1264
+ this.operation = operation;
1265
+ try {
1266
+ return await operation;
1267
+ } catch (error) {
1268
+ if (this.operation === operation) {
1269
+ this.operation = null;
1270
+ }
1271
+ throw error;
1272
+ }
1273
+ }
1274
+ };
1275
+ var LifecycleTracker = class {
1276
+ options;
1277
+ current;
1278
+ constructor(initial, options) {
1279
+ this.options = options;
1280
+ Object.freeze(initial);
1281
+ this.current = initial;
1282
+ }
1283
+ /** The current state. Frozen, so callers cannot mutate the instance's lifecycle. */
1284
+ get state() {
1285
+ return this.current;
1286
+ }
1287
+ /**
1288
+ * Whether `state` is still the instance this tracker holds.
1289
+ *
1290
+ * Every accepted {@link transition} installs a *new* frozen object, so an
1291
+ * async operation that captured the state it began under can ask whether the
1292
+ * lifecycle moved on while it was awaiting — "was I superseded?" — without a
1293
+ * second, parallel generation counter living outside the tracker. A status
1294
+ * comparison cannot answer it: a `stop()` followed by a fresh `start()`
1295
+ * returns to `"starting"`, but not to the *same* `"starting"`.
1296
+ */
1297
+ isCurrent(state) {
1298
+ return this.current === state;
1299
+ }
1300
+ /** Records `next` as the state. Callers pass a fresh object — see {@link isCurrent}. */
1301
+ transition(next, trigger) {
1302
+ const from = this.current.status;
1303
+ if (!this.options.isLegalTransition(from, next.status)) {
1304
+ throw new RuntimeStateError(
1305
+ `${this.options.owner} cannot transition from "${from}" to "${next.status}" (trigger: ${trigger})`
1306
+ );
1307
+ }
1308
+ Object.freeze(next);
1309
+ this.current = next;
1310
+ this.options.logger?.debug(`${this.options.owner} lifecycle transition`, {
1311
+ ...this.options.logContext,
1312
+ from,
1313
+ to: next.status,
1314
+ trigger
1315
+ });
1316
+ this.options.onTransition?.(next);
1317
+ }
1318
+ /**
1319
+ * Type-guard check against the current status: narrows `this` (and so
1320
+ * `this.state`) to the matching union member on success, e.g.
1321
+ * `if (tracker.is("failed")) { tracker.state.error }`.
1322
+ *
1323
+ * Prefer this over a raw `tracker.state.status === "..."` comparison —
1324
+ * same check, but the payload stays reachable without a separate
1325
+ * destructured snapshot.
1326
+ */
1327
+ is(status) {
1328
+ return this.current.status === status;
1329
+ }
1330
+ /**
1331
+ * Transition to a `"failed"` state carrying `error`, unless already
1332
+ * terminal (`"stopped"` or `"failed"`), in which case this is a no-op.
1333
+ * Returns whether the transition happened.
1334
+ *
1335
+ * Assumes `S` has a `{ status: "failed", error: Error }` member — true for
1336
+ * both `RuntimeLifecycleState` and `ExecutionLifecycleState`, the only two
1337
+ * states this tracker is used with.
1338
+ */
1339
+ fail(error, trigger) {
1340
+ if (this.is("stopped") || this.is("failed")) {
1341
+ return false;
1342
+ }
1343
+ this.transition({ status: "failed", error: toLifecycleError(error) }, trigger);
1344
+ return true;
1345
+ }
1346
+ };
1347
+ async function startWithGate(options) {
1348
+ if (options.lifecycle.state.status === "stopping") {
1349
+ throw new RuntimeStateError(`${options.ownerName} cannot start while a stop is in progress`);
1350
+ }
1351
+ if (options.startGate.pending) {
1352
+ return await options.startGate.pending;
1353
+ }
1354
+ options.stopGate.reset();
1355
+ options.stoppedSignal?.rearm();
1356
+ options.lifecycle.transition({ status: "starting" }, "start");
1357
+ await options.startGate.runOrRetry(options.runStart);
1358
+ }
1359
+
1360
+ // src/runtime/SyncBoundaryTracker.ts
1361
+ var SyncBoundaryTracker = class {
1362
+ // Every message id a sync scan has ever executed. Entries are never
1363
+ // removed: evicting on a message's own live redelivery was tried and
1364
+ // proved unsafe, because the backend can still return that same id from
1365
+ // `getNextMessage()` on a *later* reconnect's scan before its
1366
+ // mark-as-processed effect has propagated — an eviction keyed on "we saw
1367
+ // it once already" reopens exactly that race, just on a different
1368
+ // trigger. Permanent membership is what makes "already executed"
1369
+ // unconditional, at the cost of one entry per message ever synced via a
1370
+ // backlog scan for the life of this tracker — a real, bounded quantity.
1371
+ executedMessageIds = /* @__PURE__ */ new Set();
1372
+ initial = { messageId: null };
1373
+ // Ordered, oldest first, matching the order scans run in.
1374
+ queue = [this.initial];
1375
+ /** Whether every queued boundary's scan has finished. */
1376
+ get isComplete() {
1377
+ return this.queue.length === 0;
1378
+ }
1379
+ recordExecuted(messageId) {
1380
+ this.executedMessageIds.add(messageId);
1381
+ }
1382
+ isExecuted(messageId) {
1383
+ return this.executedMessageIds.has(messageId);
1384
+ }
1385
+ isSyncPoint(boundary, messageId) {
1386
+ return boundary.messageId !== null && messageId === boundary.messageId;
1387
+ }
1388
+ /** Anchors the oldest not-yet-anchored boundary to a live message's id, if one is open. */
1389
+ anchor(messageId) {
1390
+ const openBoundary = this.queue.find((boundary) => boundary.messageId === null);
1391
+ if (openBoundary) {
1392
+ openBoundary.messageId = messageId;
1393
+ }
1394
+ }
1395
+ /** Starts a new scan boundary for a reconnect and returns it. */
1396
+ beginBoundary() {
1397
+ const boundary = { messageId: null };
1398
+ this.queue.push(boundary);
1399
+ return boundary;
1400
+ }
1401
+ /** Marks `boundary`'s scan finished, dequeuing it only if it's still the oldest. */
1402
+ completeBoundary(boundary) {
1403
+ if (this.queue[0] === boundary) {
1404
+ this.queue.shift();
1405
+ }
1406
+ }
1407
+ };
913
1408
 
914
1409
  // src/runtime/Execution.ts
915
1410
  function toMessageEvent(message) {
@@ -941,13 +1436,18 @@ var Execution = class {
941
1436
  eventQueue = [];
942
1437
  waiters = [];
943
1438
  idleWaiters = /* @__PURE__ */ new Set();
944
- drainedWsMessageIds = /* @__PURE__ */ new Set();
945
- syncProcessedIds = /* @__PURE__ */ new Set();
1439
+ stoppedSignal = new TerminalSignal();
1440
+ lifecycle;
946
1441
  processTask;
947
- firstWsMessageId = null;
948
- syncComplete = false;
949
- running = true;
1442
+ stopGate = new SingleFlight();
1443
+ syncBoundaries = new SyncBoundaryTracker();
950
1444
  inFlight = 0;
1445
+ /**
1446
+ * Set once the queue stops accepting new events, ahead of the lifecycle
1447
+ * itself reporting `"stopped"` (see {@link runStop}) — closing the queue
1448
+ * cannot wait on the drain it is closing for.
1449
+ */
1450
+ closed = false;
951
1451
  constructor(options) {
952
1452
  this.roomId = options.roomId;
953
1453
  this.link = options.link;
@@ -955,27 +1455,61 @@ var Execution = class {
955
1455
  this.retryTracker = this.context.getRetryTracker();
956
1456
  this.onExecute = options.onExecute;
957
1457
  this.onFailure = options.onFailure;
958
- this.logger = options.logger ?? new NoopLogger();
959
- this.processTask = this.processLoop();
1458
+ this.logger = resolveLogger(options.logger);
1459
+ this.lifecycle = new LifecycleTracker({ status: "running" }, {
1460
+ owner: "Execution",
1461
+ logContext: { roomId: this.roomId },
1462
+ logger: this.logger,
1463
+ isLegalTransition: isLegalExecutionTransition,
1464
+ onTransition: (state) => {
1465
+ if (state.status === "stopped") {
1466
+ this.stoppedSignal.settle(null);
1467
+ } else if (state.status === "failed") {
1468
+ this.stoppedSignal.settle(state.error);
1469
+ }
1470
+ }
1471
+ });
1472
+ this.processTask = this.runProcessLoop();
1473
+ void this.processTask.catch(() => void 0);
1474
+ }
1475
+ /**
1476
+ * Whether this `Execution` is still alive, and if not, why it ended.
1477
+ *
1478
+ * This is the *lifecycle* axis. For "is this turn's handler currently
1479
+ * executing" read `ExecutionContext.state` instead, which reports
1480
+ * `"starting" | "idle" | "processing"` for the room's context.
1481
+ *
1482
+ * @see ExecutionContext.state
1483
+ */
1484
+ get state() {
1485
+ return this.lifecycle.state;
960
1486
  }
961
- enqueue(event) {
962
- if (event.type === "message_created" && !this.syncComplete && this.firstWsMessageId === null) {
963
- this.firstWsMessageId = event.payload.id;
1487
+ async enqueue(event) {
1488
+ if (this.lifecycle.is("stopped") || this.lifecycle.is("failed") || this.closed) {
1489
+ throw new RuntimeStateError(
1490
+ `Execution for room ${this.roomId} has already ended or is stopping (status: ${this.lifecycle.state.status}); enqueue() is a no-op after stop()`
1491
+ );
1492
+ }
1493
+ let syncBoundary = null;
1494
+ if (event.type === "reconnected") {
1495
+ syncBoundary = this.syncBoundaries.beginBoundary();
1496
+ } else if (event.type === "message_created") {
1497
+ this.syncBoundaries.anchor(event.payload.id);
964
1498
  }
1499
+ const queued = { event, syncBoundary };
965
1500
  const waiter = this.waiters.shift();
966
1501
  if (waiter) {
967
- waiter(event);
1502
+ waiter(queued);
968
1503
  } else {
969
- this.eventQueue.push(event);
1504
+ this.eventQueue.push(queued);
970
1505
  }
971
- return Promise.resolve();
972
1506
  }
973
1507
  async bootstrapMessage(message) {
974
- this.syncProcessedIds.add(message.id);
1508
+ this.syncBoundaries.recordExecuted(message.id);
975
1509
  await this.executeSyncMessage(toMessageEvent(message), message.id);
976
1510
  }
977
1511
  isIdle() {
978
- return this.syncComplete && this.inFlight === 0 && this.eventQueue.length === 0;
1512
+ return this.syncBoundaries.isComplete && this.inFlight === 0 && this.eventQueue.length === 0;
979
1513
  }
980
1514
  async waitForIdle(timeoutMs) {
981
1515
  if (this.isIdle()) {
@@ -1010,27 +1544,58 @@ var Execution = class {
1010
1544
  });
1011
1545
  }
1012
1546
  async stop(timeoutMs) {
1547
+ return await this.stopGate.run(() => this.runStop(timeoutMs));
1548
+ }
1549
+ async runStop(timeoutMs) {
1550
+ if (this.lifecycle.is("failed")) {
1551
+ throw this.lifecycle.state.error;
1552
+ }
1553
+ if (this.lifecycle.is("stopped")) {
1554
+ return this.lifecycle.state.graceful;
1555
+ }
1556
+ this.lifecycle.transition({ status: "stopping" }, "stop");
1013
1557
  const graceful = await this.waitForIdle(timeoutMs);
1014
- this.running = false;
1558
+ if (this.lifecycle.is("failed")) {
1559
+ throw this.lifecycle.state.error;
1560
+ }
1561
+ this.closed = true;
1015
1562
  this.resolveEventWaiters(null);
1016
1563
  if (graceful || timeoutMs === void 0) {
1017
1564
  await this.processTask;
1018
1565
  }
1566
+ this.lifecycle.transition({ status: "stopped", graceful }, graceful ? "stopped" : "stopped-forced");
1019
1567
  return graceful;
1020
1568
  }
1569
+ /**
1570
+ * Resolve once this `Execution` has reached a terminal state — including a
1571
+ * forced, non-graceful stop that detached the process loop — or reject with
1572
+ * the error that ended it.
1573
+ */
1021
1574
  async waitUntilStopped() {
1022
- await this.processTask;
1575
+ await this.stoppedSignal.wait();
1576
+ }
1577
+ async runProcessLoop() {
1578
+ try {
1579
+ await this.processLoop();
1580
+ } catch (error) {
1581
+ this.markFailed(error, "process-loop-failed");
1582
+ throw error;
1583
+ }
1023
1584
  }
1024
1585
  async processLoop() {
1025
1586
  await this.recoverStaleProcessingMessages();
1026
- await this.synchronizeWithNext();
1027
- while (this.running) {
1028
- const event = await this.nextQueuedEvent();
1029
- if (!event) {
1587
+ await this.synchronizeWithNext(this.syncBoundaries.initial);
1588
+ while (this.isActive()) {
1589
+ const queued = await this.nextQueuedEvent();
1590
+ if (!queued) {
1030
1591
  return;
1031
1592
  }
1032
- if (event.type === "message_created" && this.drainedWsMessageIds.has(event.payload.id)) {
1033
- this.drainedWsMessageIds.delete(event.payload.id);
1593
+ const { event } = queued;
1594
+ if (event.type === "reconnected") {
1595
+ await this.synchronizeWithNext(queued.syncBoundary ?? { messageId: null });
1596
+ continue;
1597
+ }
1598
+ if (event.type === "message_created" && this.syncBoundaries.isExecuted(event.payload.id)) {
1034
1599
  this.notifyIfIdle();
1035
1600
  continue;
1036
1601
  }
@@ -1055,7 +1620,7 @@ var Execution = class {
1055
1620
  count: staleMessages.length
1056
1621
  });
1057
1622
  for (const message of staleMessages) {
1058
- if (!this.running) {
1623
+ if (!this.isActive()) {
1059
1624
  break;
1060
1625
  }
1061
1626
  if (this.retryTracker.isPermanentlyFailed(message.id)) {
@@ -1066,20 +1631,17 @@ var Execution = class {
1066
1631
  continue;
1067
1632
  }
1068
1633
  await this.executeSyncMessage(toMessageEvent(message), message.id);
1069
- this.syncProcessedIds.add(message.id);
1634
+ this.syncBoundaries.recordExecuted(message.id);
1070
1635
  }
1071
1636
  }
1072
- async synchronizeWithNext() {
1073
- while (this.running) {
1637
+ async synchronizeWithNext(boundary) {
1638
+ while (this.isActive()) {
1074
1639
  const nextMessage = await this.link.getNextMessage(this.roomId);
1075
1640
  if (!nextMessage) {
1076
1641
  break;
1077
1642
  }
1078
- if (this.syncProcessedIds.has(nextMessage.id)) {
1079
- const isSyncPoint2 = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
1080
- if (isSyncPoint2) {
1081
- this.drainedWsMessageIds.add(nextMessage.id);
1082
- this.firstWsMessageId = null;
1643
+ if (this.syncBoundaries.isExecuted(nextMessage.id)) {
1644
+ if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
1083
1645
  break;
1084
1646
  }
1085
1647
  continue;
@@ -1090,25 +1652,19 @@ var Execution = class {
1090
1652
  messageId: nextMessage.id
1091
1653
  });
1092
1654
  await this.markMessageFailed(nextMessage.id, "Message permanently failed after max retries");
1093
- const isSyncPoint2 = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
1094
- if (isSyncPoint2) {
1095
- this.drainedWsMessageIds.add(nextMessage.id);
1096
- this.firstWsMessageId = null;
1655
+ this.syncBoundaries.recordExecuted(nextMessage.id);
1656
+ if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
1097
1657
  break;
1098
1658
  }
1099
1659
  continue;
1100
1660
  }
1101
- const isSyncPoint = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
1102
1661
  await this.executeSyncMessage(toMessageEvent(nextMessage), nextMessage.id);
1103
- this.syncProcessedIds.add(nextMessage.id);
1104
- if (isSyncPoint) {
1105
- this.drainedWsMessageIds.add(nextMessage.id);
1106
- this.firstWsMessageId = null;
1662
+ this.syncBoundaries.recordExecuted(nextMessage.id);
1663
+ if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
1107
1664
  break;
1108
1665
  }
1109
1666
  }
1110
- this.syncProcessedIds.clear();
1111
- this.syncComplete = true;
1667
+ this.syncBoundaries.completeBoundary(boundary);
1112
1668
  this.notifyIfIdle();
1113
1669
  }
1114
1670
  async executeSyncMessage(event, messageId) {
@@ -1145,6 +1701,14 @@ var Execution = class {
1145
1701
  try {
1146
1702
  await this.onExecute(this.context, event);
1147
1703
  } catch (error) {
1704
+ if (error instanceof RecoverableTurnError) {
1705
+ this.logger.warn("Turn failed without stopping the room", {
1706
+ roomId: this.roomId,
1707
+ eventType: event.type,
1708
+ error
1709
+ });
1710
+ return;
1711
+ }
1148
1712
  if (this.onFailure) {
1149
1713
  await this.onFailure(error, event);
1150
1714
  } else {
@@ -1154,7 +1718,7 @@ var Execution = class {
1154
1718
  error
1155
1719
  });
1156
1720
  }
1157
- this.running = false;
1721
+ this.markFailed(error, "execution-failed");
1158
1722
  this.eventQueue.splice(0, this.eventQueue.length);
1159
1723
  this.resolveEventWaiters(null);
1160
1724
  throw error;
@@ -1169,13 +1733,21 @@ var Execution = class {
1169
1733
  if (queued) {
1170
1734
  return queued;
1171
1735
  }
1172
- if (!this.running) {
1736
+ if (this.closed || !this.isActive()) {
1173
1737
  return null;
1174
1738
  }
1175
1739
  return new Promise((resolve) => {
1176
1740
  this.waiters.push(resolve);
1177
1741
  });
1178
1742
  }
1743
+ /** True while the process loop should keep draining (`running` or mid-`stop()`). */
1744
+ isActive() {
1745
+ const status = this.lifecycle.state.status;
1746
+ return status === "running" || status === "stopping";
1747
+ }
1748
+ markFailed(error, trigger) {
1749
+ this.lifecycle.fail(error, trigger);
1750
+ }
1179
1751
  notifyIfIdle() {
1180
1752
  if (!this.isIdle()) {
1181
1753
  return;
@@ -1205,7 +1777,7 @@ var Execution = class {
1205
1777
  };
1206
1778
 
1207
1779
  // src/runtime/tools/AgentTools.ts
1208
- var import_band_sdk_core = require("@band-ai/band-sdk-core");
1780
+ var import_band_sdk_core2 = require("@band-ai/band-sdk-core");
1209
1781
 
1210
1782
  // src/contracts/capabilities.ts
1211
1783
  var CAPABILITY_LABELS = {
@@ -1219,64 +1791,15 @@ function assertCapability(capabilities, capability, label = CAPABILITY_LABELS[ca
1219
1791
  }
1220
1792
  }
1221
1793
 
1222
- // src/contracts/chatEvents.ts
1223
- var CHAT_EVENT_TYPES = ["tool_call", "tool_result", "thought", "error", "task"];
1224
- var CHAT_MESSAGE_TYPES = ["text", ...CHAT_EVENT_TYPES];
1225
- function isChatEventType(value) {
1226
- return CHAT_EVENT_TYPES.includes(value);
1227
- }
1228
- function assertChatEventType(value) {
1229
- if (!isChatEventType(value)) {
1230
- throw new ValidationError(
1231
- `Invalid event message_type '${value}'. Expected one of: ${CHAT_EVENT_TYPES.join(", ")}`
1232
- );
1233
- }
1234
- }
1235
-
1236
- // src/contracts/protocols.ts
1237
- var TOOL_EXECUTOR_ERROR_TYPES = [
1238
- "ToolArgumentsValidationError",
1239
- "ToolNotFoundError",
1240
- "ToolExecutionError"
1241
- ];
1242
- function createToolExecutorError(input) {
1243
- return {
1244
- ok: false,
1245
- errorType: input.errorType,
1246
- toolName: input.toolName,
1247
- message: input.message,
1248
- legacyMessage: input.legacyMessage ?? input.message,
1249
- ...input.details ? { details: input.details } : {}
1250
- };
1251
- }
1252
- function isToolExecutorError(value) {
1253
- if (!value || typeof value !== "object") {
1254
- return false;
1255
- }
1256
- const candidate = value;
1257
- return candidate.ok === false && typeof candidate.errorType === "string" && TOOL_EXECUTOR_ERROR_TYPES.includes(candidate.errorType) && typeof candidate.toolName === "string" && typeof candidate.message === "string" && typeof candidate.legacyMessage === "string";
1258
- }
1259
- function isStructuredToolFailure(value) {
1260
- if (!value || typeof value !== "object") {
1261
- return false;
1262
- }
1263
- const payload = value;
1264
- return payload.ok === false && typeof payload.message === "string";
1265
- }
1266
- var DEFAULT_AGENT_TOOLS_CAPABILITIES = {
1267
- peers: true,
1268
- contacts: true,
1269
- memory: true
1270
- };
1271
-
1272
- // src/contracts/memory.ts
1273
- var SENSORY_MEMORY_TYPES = [
1274
- "iconic",
1275
- // Visual input
1276
- "echoic",
1277
- // Auditory input
1278
- "haptic"
1279
- // Tactile input
1794
+ // src/contracts/memory.ts
1795
+ var import_band_sdk_core = require("@band-ai/band-sdk-core");
1796
+ var SENSORY_MEMORY_TYPES = [
1797
+ "iconic",
1798
+ // Visual input
1799
+ "echoic",
1800
+ // Auditory input
1801
+ "haptic"
1802
+ // Tactile input
1280
1803
  ];
1281
1804
  var COGNITIVE_MEMORY_TYPES = [
1282
1805
  "episodic",
@@ -1313,18 +1836,22 @@ var MEMORY_SEGMENTS = [
1313
1836
  // Behavioral rules or policies
1314
1837
  ];
1315
1838
  var MEMORY_STORE_SCOPES = [
1839
+ "agent",
1840
+ // Private to the storing agent; omit subject_id
1316
1841
  "subject",
1317
1842
  // About one person/agent; requires subject_id
1318
1843
  "organization"
1319
1844
  // Shared org-wide
1320
1845
  ];
1321
1846
  var MEMORY_LIST_SCOPES = [
1847
+ "agent",
1848
+ // Caller's private agent-scoped memories only
1322
1849
  "subject",
1323
1850
  // Subject-scoped memories only
1324
1851
  "organization",
1325
1852
  // Organization-scoped memories only
1326
1853
  "all"
1327
- // Both scopes (no scope filter)
1854
+ // All scopes (no scope filter)
1328
1855
  ];
1329
1856
  var MEMORY_STATUSES = [
1330
1857
  "active",
@@ -1347,7 +1874,15 @@ function isMemoryType(value) {
1347
1874
  return MEMORY_TYPES.includes(value);
1348
1875
  }
1349
1876
  function isMemoryTypeForSystem(system, type) {
1350
- return MEMORY_SYSTEM_TYPES[system].includes(type);
1877
+ try {
1878
+ (0, import_band_sdk_core.validateMemoryTypeForSystem)(system, type);
1879
+ return true;
1880
+ } catch (err) {
1881
+ if (err instanceof Error && "issues" in err) {
1882
+ return false;
1883
+ }
1884
+ throw err;
1885
+ }
1351
1886
  }
1352
1887
  function isMemorySegment(value) {
1353
1888
  return MEMORY_SEGMENTS.includes(value);
@@ -1550,7 +2085,7 @@ var TOOL_MODELS = {
1550
2085
  required: ["action"]
1551
2086
  },
1552
2087
  band_list_memories: {
1553
- description: "List memories accessible to the agent. Returns memories about the specified subject (cross-agent sharing) and organization-wide shared memories.",
2088
+ description: `List memories accessible to the agent. Use scope="agent" for the caller's private memories, scope="subject" for memories about a specific person or agent, and scope="organization" for org-wide shared memories. Omit scope or use scope="all" to search across scopes.`,
1554
2089
  properties: {
1555
2090
  subject_id: {
1556
2091
  type: "string",
@@ -1559,7 +2094,7 @@ var TOOL_MODELS = {
1559
2094
  scope: {
1560
2095
  type: "string",
1561
2096
  enum: [...MEMORY_LIST_SCOPES],
1562
- description: "Filter by scope."
2097
+ description: `Filter by scope. "agent" returns the caller's private memories; "subject" and "organization" filter to those audiences; "all" omits the filter.`
1563
2098
  },
1564
2099
  system: {
1565
2100
  type: "string",
@@ -1595,7 +2130,7 @@ var TOOL_MODELS = {
1595
2130
  required: []
1596
2131
  },
1597
2132
  band_store_memory: {
1598
- description: "Store a new memory entry. The memory will be associated with the authenticated agent as the source. For subject-scoped memories, provide a subject_id. For organization-scoped memories, omit subject_id.",
2133
+ description: 'Store a new memory entry. The memory will be associated with the authenticated agent as the source. Use scope="agent" for information private to this agent (no subject_id). Use scope="subject" with a subject_id for memories about a specific person or agent. Use scope="organization" for knowledge genuinely shared across the organization.',
1599
2134
  properties: {
1600
2135
  content: {
1601
2136
  type: "string",
@@ -1623,11 +2158,11 @@ var TOOL_MODELS = {
1623
2158
  scope: {
1624
2159
  type: "string",
1625
2160
  enum: [...MEMORY_STORE_SCOPES],
1626
- description: "Visibility scope."
2161
+ description: 'Visibility scope. "agent" is private to this agent; "subject" requires subject_id; "organization" is shared org-wide.'
1627
2162
  },
1628
2163
  subject_id: {
1629
2164
  type: "string",
1630
- description: "UUID of the subject this memory is about (required for subject scope)."
2165
+ description: "UUID of the subject this memory is about (required for subject scope; omit for agent scope)."
1631
2166
  },
1632
2167
  metadata: {
1633
2168
  type: "object",
@@ -1699,34 +2234,29 @@ function getToolDescription(name) {
1699
2234
  }
1700
2235
 
1701
2236
  // src/runtime/tools/AgentTools.ts
1702
- var REQUIRED_ADAPTER_TOOL_METHODS = [
1703
- "sendMessage",
1704
- "sendEvent",
1705
- "addParticipant",
1706
- "removeParticipant",
1707
- "getParticipants",
1708
- "createChatroom",
1709
- "getToolSchemas",
1710
- "getAnthropicToolSchemas",
1711
- "getOpenAIToolSchemas",
1712
- "executeToolCall"
1713
- ];
1714
- var OPTIONAL_ADAPTER_TOOL_METHODS = {
1715
- peers: ["lookupPeers"],
1716
- contacts: [
1717
- "listContacts",
1718
- "addContact",
1719
- "removeContact",
1720
- "listContactRequests",
1721
- "respondContactRequest"
1722
- ],
1723
- memory: [
1724
- "listMemories",
1725
- "storeMemory",
1726
- "getMemory",
1727
- "supersedeMemory",
1728
- "archiveMemory"
1729
- ]
2237
+ var ADAPTER_TOOL_METHODS = {
2238
+ sendMessage: null,
2239
+ sendEvent: null,
2240
+ sendFailure: null,
2241
+ addParticipant: null,
2242
+ removeParticipant: null,
2243
+ getParticipants: null,
2244
+ createChatroom: null,
2245
+ getToolSchemas: null,
2246
+ getAnthropicToolSchemas: null,
2247
+ getOpenAIToolSchemas: null,
2248
+ executeToolCall: null,
2249
+ lookupPeers: "peers",
2250
+ listContacts: "contacts",
2251
+ addContact: "contacts",
2252
+ removeContact: "contacts",
2253
+ listContactRequests: "contacts",
2254
+ respondContactRequest: "contacts",
2255
+ listMemories: "memory",
2256
+ storeMemory: "memory",
2257
+ getMemory: "memory",
2258
+ supersedeMemory: "memory",
2259
+ archiveMemory: "memory"
1730
2260
  };
1731
2261
  var CONTACT_REQUEST_ACTIONS = /* @__PURE__ */ new Set([
1732
2262
  "approve",
@@ -1744,8 +2274,8 @@ var AgentTools = class {
1744
2274
  constructor(options) {
1745
2275
  this.roomId = options.roomId;
1746
2276
  this.rest = options.rest;
1747
- this.roster = options.roster ?? new import_band_sdk_core.ParticipantRoster();
1748
- this.logger = options.logger ?? new NoopLogger();
2277
+ this.roster = options.roster ?? new import_band_sdk_core2.ParticipantRoster();
2278
+ this.logger = resolveLogger(options.logger);
1749
2279
  this.capabilities = {
1750
2280
  ...DEFAULT_AGENT_TOOLS_CAPABILITIES,
1751
2281
  ...options.capabilities
@@ -1785,14 +2315,14 @@ var AgentTools = class {
1785
2315
  }
1786
2316
  );
1787
2317
  } catch (error) {
1788
- try {
1789
- this.logger.warn("chat event send failed", { roomId: this.roomId, messageType, error });
1790
- } catch {
1791
- }
2318
+ this.logger.warn("chat event send failed", { roomId: this.roomId, messageType, error });
1792
2319
  const message = error instanceof Error ? error.message : String(error);
1793
2320
  return { ok: false, status: "failed", message };
1794
2321
  }
1795
2322
  }
2323
+ async sendFailure(failure) {
2324
+ return sendFailureViaEvent(this.sendEvent.bind(this), failure);
2325
+ }
1796
2326
  async createChatroom(taskId) {
1797
2327
  const room = await this.rest.createChat(taskId, DEFAULT_REQUEST_OPTIONS);
1798
2328
  return room.id;
@@ -1916,6 +2446,7 @@ var AgentTools = class {
1916
2446
  legacyMessage: `Invalid arguments for ${toolName}: ${error.message}`
1917
2447
  });
1918
2448
  }
2449
+ this.logger.error("unexpected tool execution error", { toolName, error });
1919
2450
  const message = error instanceof Error ? error.message : String(error);
1920
2451
  return createToolExecutorError({
1921
2452
  errorType: "ToolExecutionError",
@@ -2202,16 +2733,11 @@ var AgentTools = class {
2202
2733
  const tools = {
2203
2734
  capabilities: this.capabilities
2204
2735
  };
2205
- for (const methodName of REQUIRED_ADAPTER_TOOL_METHODS) {
2206
- tools[methodName] = this.bindAdapterToolMethod(methodName);
2207
- }
2208
- for (const [capabilityKey, methodNames] of Object.entries(OPTIONAL_ADAPTER_TOOL_METHODS)) {
2209
- if (!this.capabilities[capabilityKey]) {
2736
+ for (const [methodName, capability] of Object.entries(ADAPTER_TOOL_METHODS)) {
2737
+ if (capability !== null && !this.capabilities[capability]) {
2210
2738
  continue;
2211
2739
  }
2212
- for (const methodName of methodNames) {
2213
- tools[methodName] = this.bindAdapterToolMethod(methodName);
2214
- }
2740
+ tools[methodName] = this.bindAdapterToolMethod(methodName);
2215
2741
  }
2216
2742
  return Object.freeze(tools);
2217
2743
  }
@@ -2359,9 +2885,9 @@ var AgentTools = class {
2359
2885
  if (!segment) {
2360
2886
  throw new ValidationError(`segment must be one of: ${expectedList(MEMORY_SEGMENTS)}`);
2361
2887
  }
2362
- if (scope === "subject" && !subjectId) {
2888
+ if (scope === MEMORY_STORE_SCOPE.subject && !subjectId) {
2363
2889
  throw new ValidationError(
2364
- 'scope="subject" requires a subject_id (the UUID of the person or agent the memory is about). If you do not have a concrete subject UUID, retry with scope="organization" and omit subject_id. Do not invent a UUID.'
2890
+ `scope="${MEMORY_STORE_SCOPE.subject}" requires a subject_id (the UUID of the person or agent the memory is about). If you do not have a concrete subject UUID, retry with scope="${MEMORY_STORE_SCOPE.agent}" and omit subject_id. Do not invent a UUID.`
2365
2891
  );
2366
2892
  }
2367
2893
  return {
@@ -2539,22 +3065,6 @@ function validateToolArgs(toolName, args) {
2539
3065
  );
2540
3066
  }
2541
3067
  }
2542
- if (toolName === "band_store_memory") {
2543
- if (typeof args.system === "string" && !isMemorySystem(args.system)) {
2544
- errors.push(`system: Invalid value '${args.system}'. Expected one of: ${expectedList(MEMORY_SYSTEMS)}`);
2545
- }
2546
- if (typeof args.type === "string" && !isMemoryType(args.type)) {
2547
- errors.push(`type: Invalid value '${args.type}'. Expected one of: ${expectedList(MEMORY_TYPES)}`);
2548
- }
2549
- if (typeof args.system === "string" && isMemorySystem(args.system) && typeof args.type === "string" && isMemoryType(args.type) && !isMemoryTypeForSystem(args.system, args.type)) {
2550
- errors.push(
2551
- `type: Invalid value '${args.type}' for system '${args.system}'. Expected one of: ${expectedMemoryTypesForSystem(args.system)}`
2552
- );
2553
- }
2554
- if (typeof args.segment === "string" && !isMemorySegment(args.segment)) {
2555
- errors.push(`segment: Invalid value '${args.segment}'. Expected one of: ${expectedList(MEMORY_SEGMENTS)}`);
2556
- }
2557
- }
2558
3068
  if (errors.length > 0) {
2559
3069
  const message = `Invalid arguments for ${toolName}: ${errors.join("; ")}`;
2560
3070
  return createToolExecutorError({
@@ -2569,7 +3079,7 @@ function validateToolArgs(toolName, args) {
2569
3079
  }
2570
3080
 
2571
3081
  // src/runtime/ExecutionContext.ts
2572
- var import_band_sdk_core2 = require("@band-ai/band-sdk-core");
3082
+ var import_band_sdk_core3 = require("@band-ai/band-sdk-core");
2573
3083
  var DEDUP_CACHE_MAX = 500;
2574
3084
  var ExecutionContext = class {
2575
3085
  roomId;
@@ -2581,7 +3091,7 @@ var ExecutionContext = class {
2581
3091
  history = [];
2582
3092
  messageIds = /* @__PURE__ */ new Set();
2583
3093
  dedupCache = /* @__PURE__ */ new Map();
2584
- roster = new import_band_sdk_core2.ParticipantRoster();
3094
+ roster = new import_band_sdk_core3.ParticipantRoster();
2585
3095
  tools;
2586
3096
  adapterTools;
2587
3097
  participantsMessage = null;
@@ -2599,16 +3109,27 @@ var ExecutionContext = class {
2599
3109
  this.enableContextCache = options.enableContextCache ?? true;
2600
3110
  this.contextCacheTtlMs = Math.max(0, (options.contextCacheTtlSeconds ?? 300) * 1e3);
2601
3111
  this.enableContextHydration = options.enableContextHydration ?? true;
2602
- this.retryTrackerInstance = new import_band_sdk_core2.RetryTracker(options.maxMessageRetries ?? 1);
3112
+ this.retryTrackerInstance = new import_band_sdk_core3.RetryTracker(options.maxMessageRetries ?? 1);
2603
3113
  this.tools = new AgentTools({
2604
3114
  roomId: this.roomId,
2605
3115
  rest: this.link.rest,
2606
3116
  roster: this.roster,
2607
3117
  capabilities: this.link.capabilities,
2608
- logger: options.logger ?? new NoopLogger()
3118
+ logger: resolveLogger(options.logger)
2609
3119
  });
2610
3120
  this.adapterTools = this.tools.getAdapterTools();
2611
3121
  }
3122
+ /**
3123
+ * What this room's turn handler is currently doing.
3124
+ *
3125
+ * This is the *per-turn activity* axis, not a lifecycle: it flips to
3126
+ * `"processing"` while the adapter handles an event and back to `"idle"`
3127
+ * afterwards. To ask whether the room's `Execution` is still alive at all,
3128
+ * read `Execution.state` instead, which reports
3129
+ * `"running" | "stopping" | "stopped" | "failed"`.
3130
+ *
3131
+ * @see Execution.state
3132
+ */
2612
3133
  get state() {
2613
3134
  return this._state;
2614
3135
  }
@@ -2853,22 +3374,43 @@ var ExecutionContext = class {
2853
3374
  }
2854
3375
  };
2855
3376
 
3377
+ // src/core/teardown.ts
3378
+ async function isolateTeardown(errors, step) {
3379
+ try {
3380
+ await step();
3381
+ } catch (error) {
3382
+ errors.push(error);
3383
+ }
3384
+ }
3385
+ function combineTeardownErrors(errors, message) {
3386
+ const distinct = [...new Set(errors)];
3387
+ return distinct.length === 1 ? distinct[0] : new AggregateError(distinct, message);
3388
+ }
3389
+
2856
3390
  // src/runtime/rooms/RoomPresence.ts
2857
- var import_band_sdk_core3 = require("@band-ai/band-sdk-core");
3391
+ var import_band_sdk_core4 = require("@band-ai/band-sdk-core");
2858
3392
 
2859
3393
  // src/runtime/rooms/subscriptions.ts
3394
+ async function listExistingRooms(options) {
3395
+ const rooms = await options.link.listAllChats(void 0, options.requestOptions);
3396
+ const accepted = /* @__PURE__ */ new Map();
3397
+ for (const room of rooms) {
3398
+ const roomId = typeof room.id === "string" ? room.id : null;
3399
+ if (!roomId) {
3400
+ continue;
3401
+ }
3402
+ if (options.roomFilter && !options.roomFilter(room)) {
3403
+ continue;
3404
+ }
3405
+ accepted.set(roomId, room);
3406
+ }
3407
+ return accepted;
3408
+ }
2860
3409
  async function hydrateExistingRooms(options) {
2861
3410
  try {
2862
- const rooms = await options.link.listAllChats(void 0, options.requestOptions);
2863
- for (const room of rooms) {
2864
- const roomId = typeof room.id === "string" ? room.id : null;
2865
- if (!roomId) {
2866
- continue;
2867
- }
2868
- if (options.roomFilter && !options.roomFilter(room)) {
2869
- continue;
2870
- }
2871
- await options.onRoom(roomId, room);
3411
+ const rooms = await listExistingRooms(options);
3412
+ for (const [roomId, payload] of rooms) {
3413
+ await options.onRoom(roomId, payload);
2872
3414
  }
2873
3415
  } catch (error) {
2874
3416
  if (error instanceof UnsupportedFeatureError) {
@@ -2882,9 +3424,68 @@ async function hydrateExistingRooms(options) {
2882
3424
  }
2883
3425
  }
2884
3426
 
3427
+ // src/core/singleFlight.ts
3428
+ var SingleFlight2 = class {
3429
+ pending = null;
3430
+ /** The currently in-flight operation's promise, if any is running. */
3431
+ get current() {
3432
+ return this.pending;
3433
+ }
3434
+ run(start) {
3435
+ if (!this.pending) {
3436
+ const promise = start();
3437
+ this.pending = promise;
3438
+ const clear = () => {
3439
+ if (this.pending === promise) {
3440
+ this.pending = null;
3441
+ }
3442
+ };
3443
+ void promise.then(clear, clear);
3444
+ }
3445
+ return this.pending;
3446
+ }
3447
+ };
3448
+ var KeyedSingleFlight = class {
3449
+ flights = /* @__PURE__ */ new Map();
3450
+ /** The currently in-flight operation's promise for `key`, if any is running. */
3451
+ current(key) {
3452
+ return this.flights.get(key)?.current ?? null;
3453
+ }
3454
+ run(key, start) {
3455
+ let flight = this.flights.get(key);
3456
+ if (!flight) {
3457
+ flight = new SingleFlight2();
3458
+ this.flights.set(key, flight);
3459
+ }
3460
+ const promise = flight.run(start);
3461
+ const forget = () => {
3462
+ if (this.flights.get(key) === flight && !flight.current) {
3463
+ this.flights.delete(key);
3464
+ }
3465
+ };
3466
+ void promise.then(forget, forget);
3467
+ return promise;
3468
+ }
3469
+ /** Drops every key without waiting for its in-flight operation to settle. */
3470
+ clear() {
3471
+ this.flights.clear();
3472
+ }
3473
+ };
3474
+ var Serializer = class {
3475
+ tail = Promise.resolve();
3476
+ run(body) {
3477
+ const run = this.tail.then(body, body);
3478
+ this.tail = run.then(
3479
+ () => void 0,
3480
+ () => void 0
3481
+ );
3482
+ return run;
3483
+ }
3484
+ };
3485
+
2885
3486
  // src/runtime/rooms/RoomPresence.ts
2886
3487
  var RoomPresence = class {
2887
- roster = new import_band_sdk_core3.RoomRoster();
3488
+ roster = new import_band_sdk_core4.RoomRoster();
2888
3489
  onRoomJoined = null;
2889
3490
  onRoomLeft = null;
2890
3491
  onRoomEvent = null;
@@ -2896,7 +3497,7 @@ var RoomPresence = class {
2896
3497
  eventController = null;
2897
3498
  eventTask = null;
2898
3499
  contactsSubscribed = false;
2899
- lifecycle = Promise.resolve();
3500
+ lifecycle = new Serializer();
2900
3501
  admissionInFlight = /* @__PURE__ */ new Map();
2901
3502
  // Read once by `admitRoomOrThrow` right after a failed admission; a
2902
3503
  // subsequent successful subscribe clears it so a caller never attributes
@@ -2906,13 +3507,13 @@ var RoomPresence = class {
2906
3507
  this.link = options.link;
2907
3508
  this.roomFilter = options.roomFilter;
2908
3509
  this.autoSubscribeExistingRooms = options.autoSubscribeExistingRooms ?? true;
2909
- this.logger = options.logger ?? new NoopLogger();
3510
+ this.logger = resolveLogger(options.logger);
2910
3511
  }
2911
3512
  async start() {
2912
- return this.serialize(() => this.startBody());
3513
+ return this.lifecycle.run(() => this.startBody());
2913
3514
  }
2914
3515
  async stop() {
2915
- return this.serialize(() => this.stopBody());
3516
+ return this.lifecycle.run(() => this.stopBody());
2916
3517
  }
2917
3518
  async [Symbol.asyncDispose]() {
2918
3519
  await this.stop();
@@ -2977,14 +3578,6 @@ var RoomPresence = class {
2977
3578
  }
2978
3579
  return roomIsAdmitted;
2979
3580
  }
2980
- async serialize(body) {
2981
- const run = this.lifecycle.then(body, body);
2982
- this.lifecycle = run.then(
2983
- () => void 0,
2984
- () => void 0
2985
- );
2986
- return run;
2987
- }
2988
3581
  async startBody() {
2989
3582
  if (this.eventTask) {
2990
3583
  throw new RuntimeStateError("RoomPresence is already started");
@@ -2992,14 +3585,8 @@ var RoomPresence = class {
2992
3585
  if (!this.link.isConnected()) {
2993
3586
  await this.link.connect();
2994
3587
  }
2995
- const contactsReady = this.subscribeContacts();
2996
- try {
2997
- await this.link.subscribeAgentRooms();
2998
- } catch (error) {
2999
- this.logger.warn("RoomPresence failed to subscribe agent_rooms channel, continuing without it", {
3000
- error
3001
- });
3002
- }
3588
+ const contactsReady = this.subscribeContacts("start");
3589
+ await this.subscribeAgentRoomsChannel("start");
3003
3590
  if (this.autoSubscribeExistingRooms) {
3004
3591
  await this.subscribeExistingRooms();
3005
3592
  }
@@ -3007,7 +3594,17 @@ var RoomPresence = class {
3007
3594
  this.eventController = new AbortController();
3008
3595
  this.eventTask = this.consumeEvents(this.eventController.signal);
3009
3596
  }
3010
- async subscribeContacts() {
3597
+ contextualWarnMessage(channel, context) {
3598
+ return context === "reconnect" ? `RoomPresence failed to resubscribe ${channel} channel after reconnect` : `RoomPresence failed to subscribe ${channel} channel, continuing without it`;
3599
+ }
3600
+ async subscribeAgentRoomsChannel(context) {
3601
+ try {
3602
+ await this.link.subscribeAgentRooms();
3603
+ } catch (error) {
3604
+ this.logger.warn(this.contextualWarnMessage("agent_rooms", context), { error });
3605
+ }
3606
+ }
3607
+ async subscribeContacts(context) {
3011
3608
  if (!this.link.capabilities.contacts) {
3012
3609
  return;
3013
3610
  }
@@ -3015,9 +3612,7 @@ var RoomPresence = class {
3015
3612
  await this.link.subscribeAgentContacts();
3016
3613
  this.contactsSubscribed = true;
3017
3614
  } catch (error) {
3018
- this.logger.warn("RoomPresence failed to subscribe agent_contacts channel, continuing without it", {
3019
- error
3020
- });
3615
+ this.logger.warn(this.contextualWarnMessage("agent_contacts", context), { error });
3021
3616
  }
3022
3617
  }
3023
3618
  async stopBody() {
@@ -3071,6 +3666,9 @@ var RoomPresence = class {
3071
3666
  await this.onRoomEvent?.(event.roomId, event);
3072
3667
  }
3073
3668
  break;
3669
+ case "reconnected":
3670
+ await this.handleReconnected(event);
3671
+ break;
3074
3672
  default:
3075
3673
  assertNever2(event);
3076
3674
  }
@@ -3089,14 +3687,74 @@ var RoomPresence = class {
3089
3687
  if (!roomId) {
3090
3688
  return;
3091
3689
  }
3092
- await this.unsubscribeRoom(roomId);
3093
- this.lastSubscribeError.delete(roomId);
3690
+ await this.leaveRoomTracking(roomId);
3094
3691
  if (!this.roster.recordRoomRemoved(roomId)) {
3095
3692
  this.logger.debug("RoomPresence ignoring removal for untracked room", { roomId });
3096
3693
  return;
3097
3694
  }
3098
3695
  await this.onRoomLeft?.(roomId);
3099
3696
  }
3697
+ /** Unsubscribes the transport topic and clears any remembered subscribe failure for `roomId`. */
3698
+ async leaveRoomTracking(roomId) {
3699
+ await this.unsubscribeRoom(roomId);
3700
+ this.lastSubscribeError.delete(roomId);
3701
+ }
3702
+ /**
3703
+ * A REST fetch failure here must not blank the roster — it forwards the
3704
+ * reconnect to every currently tracked room regardless, so each room's
3705
+ * `Execution` can still re-run its `/next` synchronization even when
3706
+ * membership reconciliation itself has to wait for the next reconnect.
3707
+ */
3708
+ async handleReconnected(event) {
3709
+ const [, , accepted] = await Promise.all([
3710
+ this.subscribeAgentRoomsChannel("reconnect"),
3711
+ this.subscribeContacts("reconnect"),
3712
+ listExistingRooms({
3713
+ link: this.link,
3714
+ roomFilter: this.roomFilter,
3715
+ requestOptions: DEFAULT_REQUEST_OPTIONS
3716
+ }).catch((error) => {
3717
+ this.logger.warn("RoomPresence failed to fetch room snapshot after reconnect", { error });
3718
+ return null;
3719
+ })
3720
+ ]);
3721
+ if (accepted) {
3722
+ await this.reconcileRoomsWithSnapshot(accepted);
3723
+ }
3724
+ await Promise.all(
3725
+ this.roster.trackedRoomIds().map((roomId) => this.onRoomEvent?.(roomId, event) ?? Promise.resolve())
3726
+ );
3727
+ }
3728
+ async reconcileRoomsWithSnapshot(accepted) {
3729
+ const acceptedIds = this.autoSubscribeExistingRooms ? [...accepted.keys()] : this.roster.trackedRoomIds().filter((roomId) => accepted.has(roomId));
3730
+ const reconciliation = this.roster.reconcile(acceptedIds);
3731
+ await Promise.all(
3732
+ reconciliation.removed.map(async (roomId) => {
3733
+ await this.leaveRoomTracking(roomId);
3734
+ await this.onRoomLeft?.(roomId);
3735
+ })
3736
+ );
3737
+ await Promise.all(
3738
+ reconciliation.admitting.map(async ([roomId, ticket]) => {
3739
+ const admitted = await this.performAdmission(roomId, ticket);
3740
+ if (admitted) {
3741
+ await this.onRoomJoined?.(roomId, accepted.get(roomId) ?? {});
3742
+ }
3743
+ })
3744
+ );
3745
+ await Promise.all(
3746
+ reconciliation.resync.map(async (roomId) => {
3747
+ try {
3748
+ await this.link.subscribeRoom(roomId);
3749
+ } catch (error) {
3750
+ this.logger.warn("RoomPresence failed to resubscribe surviving room after reconnect", {
3751
+ roomId,
3752
+ error
3753
+ });
3754
+ }
3755
+ })
3756
+ );
3757
+ }
3100
3758
  async unsubscribeRoom(roomId) {
3101
3759
  try {
3102
3760
  await this.link.unsubscribeRoom(roomId);
@@ -3141,9 +3799,10 @@ var AgentRuntime = class {
3141
3799
  executions = /* @__PURE__ */ new Map();
3142
3800
  executionWatchers = /* @__PURE__ */ new Map();
3143
3801
  logger;
3144
- running = false;
3145
- stopping = false;
3146
- fatalError = null;
3802
+ stoppedSignal = new TerminalSignal();
3803
+ lifecycle;
3804
+ startGate = new SingleFlight();
3805
+ stopGate = new SingleFlight();
3147
3806
  constructor(options) {
3148
3807
  this.link = options.link;
3149
3808
  this.agentId = options.agentId;
@@ -3152,7 +3811,7 @@ var AgentRuntime = class {
3152
3811
  this.onRoomJoined = options.onRoomJoined;
3153
3812
  this.onRoomLeft = options.onRoomLeft;
3154
3813
  this.onError = options.onError;
3155
- this.logger = options.logger ?? new NoopLogger();
3814
+ this.logger = resolveLogger(options.logger);
3156
3815
  this.onContactEvent = options.onContactEvent;
3157
3816
  this.onParticipantAdded = options.onParticipantAdded;
3158
3817
  this.onParticipantRemoved = options.onParticipantRemoved;
@@ -3164,6 +3823,19 @@ var AgentRuntime = class {
3164
3823
  maxMessageRetries: options.sessionConfig?.maxMessageRetries ?? 1,
3165
3824
  enableContextHydration: options.sessionConfig?.enableContextHydration ?? true
3166
3825
  };
3826
+ this.lifecycle = new LifecycleTracker({ status: "not_started" }, {
3827
+ owner: "AgentRuntime",
3828
+ logContext: { agentId: this.agentId },
3829
+ logger: this.logger,
3830
+ isLegalTransition: isLegalRuntimeTransition,
3831
+ onTransition: (state) => {
3832
+ if (state.status === "stopped") {
3833
+ this.stoppedSignal.settle(null);
3834
+ } else if (state.status === "failed") {
3835
+ this.stoppedSignal.settle(state.error);
3836
+ }
3837
+ }
3838
+ });
3167
3839
  this.presence = new RoomPresence({
3168
3840
  link: this.link,
3169
3841
  roomFilter: options.roomFilter,
@@ -3199,6 +3871,7 @@ var AgentRuntime = class {
3199
3871
  return;
3200
3872
  }
3201
3873
  case "message_created":
3874
+ case "reconnected":
3202
3875
  await this.getOrCreateExecution(roomId).enqueue(event);
3203
3876
  return;
3204
3877
  default:
@@ -3207,65 +3880,138 @@ var AgentRuntime = class {
3207
3880
  };
3208
3881
  this.presence.onContactEvent = this.onContactEvent ?? null;
3209
3882
  }
3883
+ /** Current lifecycle state of this runtime. */
3884
+ get state() {
3885
+ return this.lifecycle.state;
3886
+ }
3887
+ /**
3888
+ * Connect, subscribe, and begin consuming platform events.
3889
+ *
3890
+ * Repeated or concurrent calls join the in-flight start instead of starting a
3891
+ * second consume loop. Calling `start()` while a `stop()` is still in flight
3892
+ * rejects with a `RuntimeStateError`.
3893
+ */
3210
3894
  async start() {
3211
- if (this.running) {
3212
- return;
3213
- }
3214
- this.running = true;
3215
- this.stopping = false;
3216
- this.fatalError = null;
3895
+ await startWithGate({
3896
+ lifecycle: this.lifecycle,
3897
+ startGate: this.startGate,
3898
+ stopGate: this.stopGate,
3899
+ stoppedSignal: this.stoppedSignal,
3900
+ ownerName: "AgentRuntime",
3901
+ runStart: () => this.runStart()
3902
+ });
3903
+ }
3904
+ async runStart() {
3217
3905
  try {
3218
3906
  await this.presence.start();
3219
3907
  } catch (error) {
3220
- await this.handleStartFailure();
3908
+ await this.finishFailedStart();
3221
3909
  throw error;
3222
3910
  }
3911
+ if (this.lifecycle.is("starting")) {
3912
+ this.lifecycle.transition({ status: "running" }, "started");
3913
+ }
3223
3914
  void this.presence.waitUntilStopped().catch((error) => this.failRuntime(error, syntheticRuntimeFailureEvent(this.agentId)));
3224
3915
  }
3916
+ async finishFailedStart() {
3917
+ try {
3918
+ await this.handleStartFailure();
3919
+ } catch (cleanupError) {
3920
+ this.markFailed(cleanupError, "start-cleanup-failed");
3921
+ throw cleanupError;
3922
+ }
3923
+ if (this.lifecycle.is("starting")) {
3924
+ this.lifecycle.transition({ status: "stopped" }, "start-failed");
3925
+ }
3926
+ }
3225
3927
  async handleStartFailure() {
3226
- this.running = false;
3227
- this.stopping = false;
3928
+ this.presence.abortEventLoop();
3228
3929
  await this.link.disconnect();
3229
3930
  }
3931
+ /**
3932
+ * Tear the runtime down.
3933
+ *
3934
+ * A concurrent second call joins the in-flight teardown and mirrors its
3935
+ * outcome — including rejecting with the *same* `Error` instance — instead of
3936
+ * reporting a shutdown it did not perform.
3937
+ */
3230
3938
  async stop(timeoutMs) {
3231
- if (this.stopping || !this.running && !this.fatalError) {
3939
+ return await this.stopGate.run(() => this.runStop(timeoutMs));
3940
+ }
3941
+ async runStop(timeoutMs) {
3942
+ const pendingStart = this.lifecycle.is("starting") ? this.startGate.pending : null;
3943
+ if (pendingStart) {
3944
+ try {
3945
+ await pendingStart;
3946
+ } catch (error) {
3947
+ this.logger.debug("AgentRuntime stop is proceeding after the in-flight start failed", { error });
3948
+ }
3949
+ }
3950
+ if (this.lifecycle.is("not_started") || this.lifecycle.is("stopped")) {
3232
3951
  return true;
3233
3952
  }
3234
- this.stopping = true;
3235
- this.running = false;
3953
+ const fatalError = this.lifecycle.is("failed") ? this.lifecycle.state.error : null;
3954
+ this.startGate.reset();
3955
+ this.lifecycle.transition({ status: "stopping" }, "stop");
3956
+ try {
3957
+ return await this.performStop(timeoutMs, fatalError);
3958
+ } catch (error) {
3959
+ this.markFailed(error, "stop-failed");
3960
+ throw error;
3961
+ }
3962
+ }
3963
+ async performStop(timeoutMs, fatalError) {
3964
+ const errors = [];
3236
3965
  this.presence.abortEventLoop();
3237
- await this.presence.waitUntilStopped().catch(() => void 0);
3966
+ await isolateTeardown(errors, () => this.presence.waitUntilStopped());
3238
3967
  let graceful = true;
3239
3968
  await Promise.all(
3240
3969
  [...this.executions].map(async ([roomId, execution]) => {
3241
- const stopped = await execution.stop(timeoutMs);
3242
- if (!stopped) {
3243
- graceful = false;
3244
- }
3970
+ await isolateTeardown(errors, async () => {
3971
+ graceful = await execution.stop(timeoutMs) && graceful;
3972
+ });
3245
3973
  this.executions.delete(roomId);
3246
3974
  })
3247
3975
  );
3248
- await this.presence.stop();
3976
+ await isolateTeardown(errors, () => this.presence.stop());
3249
3977
  for (const roomId of [...this.contexts.keys()]) {
3250
- await this.onSessionCleanup(roomId);
3978
+ await isolateTeardown(errors, () => this.onSessionCleanup(roomId));
3251
3979
  }
3252
3980
  this.contexts.clear();
3253
3981
  this.executions.clear();
3254
3982
  this.executionWatchers.clear();
3255
- await this.link.disconnect();
3256
- if (this.fatalError) {
3257
- throw this.fatalError instanceof Error ? this.fatalError : new Error(String(this.fatalError));
3983
+ await isolateTeardown(errors, () => this.link.disconnect());
3984
+ const failure = this.lifecycle.is("failed") ? this.lifecycle.state.error : fatalError;
3985
+ if (failure) {
3986
+ if (!this.lifecycle.is("failed")) {
3987
+ this.lifecycle.transition({ status: "failed", error: failure }, "stopped-after-failure");
3988
+ }
3989
+ errors.unshift(failure);
3990
+ }
3991
+ if (errors.length > 0) {
3992
+ throw combineTeardownErrors(errors, "AgentRuntime failed to tear down cleanly");
3258
3993
  }
3994
+ this.lifecycle.transition({ status: "stopped" }, "stopped");
3259
3995
  return graceful;
3260
3996
  }
3261
3997
  getContext(roomId) {
3262
3998
  return this.contexts.get(roomId);
3263
3999
  }
4000
+ /**
4001
+ * Resolve once the runtime has actually stopped, or reject with the fatal
4002
+ * error that ended it.
4003
+ *
4004
+ * A runtime that was never started stays pending until it stops or fails;
4005
+ * starting it does not resolve a pending wait.
4006
+ */
3264
4007
  async waitUntilStopped() {
3265
- await this.presence.waitUntilStopped().catch(() => void 0);
3266
- if (this.fatalError) {
3267
- throw this.fatalError instanceof Error ? this.fatalError : new Error(String(this.fatalError));
4008
+ await this.stoppedSignal.wait();
4009
+ }
4010
+ markFailed(error, trigger) {
4011
+ if (this.lifecycle.is("not_started")) {
4012
+ return false;
3268
4013
  }
4014
+ return this.lifecycle.fail(error, trigger);
3269
4015
  }
3270
4016
  getContexts() {
3271
4017
  return [...this.contexts.values()];
@@ -3278,14 +4024,23 @@ var AgentRuntime = class {
3278
4024
  await this.getOrCreateExecution(roomId).bootstrapMessage(message);
3279
4025
  }
3280
4026
  async resetRoomSession(roomId, timeoutMs) {
3281
- return this.teardownExecution(roomId, timeoutMs);
4027
+ return await this.teardownExecution(roomId, timeoutMs);
3282
4028
  }
3283
4029
  async teardownExecution(roomId, timeoutMs) {
3284
4030
  const execution = this.executions.get(roomId);
3285
- const graceful = execution ? await execution.stop(timeoutMs) : true;
4031
+ const errors = [];
4032
+ let graceful = true;
4033
+ if (execution) {
4034
+ await isolateTeardown(errors, async () => {
4035
+ graceful = await execution.stop(timeoutMs);
4036
+ });
4037
+ }
3286
4038
  this.executions.delete(roomId);
3287
4039
  this.contexts.delete(roomId);
3288
- await this.onSessionCleanup(roomId);
4040
+ await isolateTeardown(errors, () => this.onSessionCleanup(roomId));
4041
+ if (errors.length > 0) {
4042
+ throw combineTeardownErrors(errors, "AgentRuntime failed to tear down cleanly");
4043
+ }
3289
4044
  return graceful;
3290
4045
  }
3291
4046
  getOrCreateExecution(roomId) {
@@ -3346,15 +4101,20 @@ var AgentRuntime = class {
3346
4101
  return context;
3347
4102
  }
3348
4103
  async failRuntime(error, event) {
3349
- if (!this.fatalError) {
3350
- this.fatalError = error;
3351
- this.running = false;
4104
+ if (this.markFailed(error, "runtime-error")) {
3352
4105
  this.logger.error("Fatal runtime error handling platform event", {
3353
4106
  eventType: event.type,
3354
4107
  roomId: event.roomId,
3355
4108
  error
3356
4109
  });
3357
4110
  this.notifyOnError(error, event);
4111
+ } else {
4112
+ this.logger.debug("Runtime error after the lifecycle already ended", {
4113
+ status: this.lifecycle.state.status,
4114
+ eventType: event.type,
4115
+ roomId: event.roomId,
4116
+ error
4117
+ });
3358
4118
  }
3359
4119
  this.presence.abortEventLoop();
3360
4120
  }
@@ -3394,6 +4154,55 @@ function syntheticRuntimeFailureEvent(agentId) {
3394
4154
  };
3395
4155
  }
3396
4156
 
4157
+ // src/core/epoch.ts
4158
+ var Epoch = class {
4159
+ value = 0;
4160
+ get current() {
4161
+ return this.value;
4162
+ }
4163
+ /** Advances to the next generation and returns it. */
4164
+ bump() {
4165
+ this.value += 1;
4166
+ return this.value;
4167
+ }
4168
+ isStale(captured) {
4169
+ return captured !== this.value;
4170
+ }
4171
+ };
4172
+
4173
+ // src/platform/Session.ts
4174
+ var Session = class {
4175
+ epoch = new Epoch();
4176
+ active = false;
4177
+ /**
4178
+ * Teardown for whatever reconnect observer this session registered on the
4179
+ * transport — a plain field since a caller just assigns whatever
4180
+ * `onReconnected()` handed back (or `null` if the transport has none).
4181
+ */
4182
+ reconnectObserverTeardown = null;
4183
+ get isActive() {
4184
+ return this.active;
4185
+ }
4186
+ isStale(epoch) {
4187
+ return this.epoch.isStale(epoch);
4188
+ }
4189
+ /** Starts a new session and returns its epoch. */
4190
+ begin() {
4191
+ const epoch = this.epoch.bump();
4192
+ this.active = true;
4193
+ return epoch;
4194
+ }
4195
+ /** Marks the session inactive and advances the epoch, so any work still in flight for it is now stale. */
4196
+ deactivate() {
4197
+ this.active = false;
4198
+ this.epoch.bump();
4199
+ }
4200
+ clearReconnectObserver() {
4201
+ this.reconnectObserverTeardown?.();
4202
+ this.reconnectObserverTeardown = null;
4203
+ }
4204
+ };
4205
+
3397
4206
  // src/client/rest/pagination.ts
3398
4207
  var DEFAULT_PAGE_SIZE = 100;
3399
4208
  var DEFAULT_MAX_PAGES = 100;
@@ -3567,7 +4376,7 @@ function toNonNegativeInteger(value) {
3567
4376
  }
3568
4377
 
3569
4378
  // src/adapters/shared/coercion.ts
3570
- function asOptionalRecord(value) {
4379
+ function asOptionalRecord2(value) {
3571
4380
  if (!value || typeof value !== "object" || Array.isArray(value)) {
3572
4381
  return void 0;
3573
4382
  }
@@ -3588,7 +4397,7 @@ function asNullableString(value) {
3588
4397
 
3589
4398
  // src/client/rest/responseNormalization.ts
3590
4399
  function asMetadataMap(value) {
3591
- return asOptionalRecord(value);
4400
+ return asOptionalRecord2(value);
3592
4401
  }
3593
4402
  function normalizeContactRequestDirection(value) {
3594
4403
  const direction = asMetadataMap(value);
@@ -3623,7 +4432,7 @@ function mergeOptions(options, maxRetries) {
3623
4432
  };
3624
4433
  }
3625
4434
  function asMetadataMap2(value) {
3626
- return asOptionalRecord(value);
4435
+ return asOptionalRecord2(value);
3627
4436
  }
3628
4437
  function requireNonEmptyStringField(value, field, source) {
3629
4438
  if (typeof value !== "string" || value.trim().length === 0) {
@@ -3679,7 +4488,7 @@ function asRecordArray(value) {
3679
4488
  if (!Array.isArray(value)) {
3680
4489
  return void 0;
3681
4490
  }
3682
- return value.filter((entry) => asOptionalRecord(entry) !== void 0);
4491
+ return value.filter((entry) => asOptionalRecord2(entry) !== void 0);
3683
4492
  }
3684
4493
  function extractEnvelopeData(value) {
3685
4494
  const record = asMetadataMap2(value);
@@ -4176,107 +4985,213 @@ var FernRestAdapter = class {
4176
4985
  }
4177
4986
  };
4178
4987
 
4179
- // src/platform/streaming/payloadSchemas.ts
4180
- var import_zod = require("zod");
4181
- var mentionSchema = import_zod.z.object({
4182
- id: import_zod.z.string(),
4183
- handle: import_zod.z.string().nullish(),
4184
- name: import_zod.z.string().nullish(),
4185
- username: import_zod.z.string().nullish()
4186
- }).passthrough();
4187
- var messageMetadataSchema = import_zod.z.object({
4188
- mentions: import_zod.z.array(mentionSchema).nullish()
4189
- }).passthrough();
4190
- var messageCreatedPayloadSchema = import_zod.z.object({
4191
- id: import_zod.z.string(),
4192
- content: import_zod.z.string(),
4193
- message_type: import_zod.z.string(),
4194
- metadata: messageMetadataSchema.nullish(),
4195
- sender_id: import_zod.z.string(),
4196
- sender_type: import_zod.z.string(),
4197
- sender_name: import_zod.z.string().nullish(),
4198
- chat_room_id: import_zod.z.string().nullish(),
4199
- inserted_at: import_zod.z.string(),
4200
- updated_at: import_zod.z.string()
4201
- }).passthrough();
4202
- var roomOwnerSchema = import_zod.z.object({
4203
- id: import_zod.z.string(),
4204
- name: import_zod.z.string(),
4205
- type: import_zod.z.string()
4206
- }).passthrough();
4207
- var roomAddedPayloadSchema = import_zod.z.object({
4208
- id: import_zod.z.string(),
4209
- title: import_zod.z.string().nullish(),
4210
- task_id: import_zod.z.string().nullish(),
4211
- inserted_at: import_zod.z.string().nullish(),
4212
- updated_at: import_zod.z.string().nullish(),
4213
- owner: roomOwnerSchema.nullish(),
4214
- status: import_zod.z.string().nullish(),
4215
- type: import_zod.z.string().nullish(),
4216
- created_at: import_zod.z.string().nullish(),
4217
- participant_role: import_zod.z.string().nullish()
4218
- }).passthrough();
4219
- var roomRemovedPayloadSchema = import_zod.z.object({
4220
- id: import_zod.z.string(),
4221
- status: import_zod.z.string(),
4222
- type: import_zod.z.string(),
4223
- title: import_zod.z.string(),
4224
- removed_at: import_zod.z.string()
4225
- }).passthrough();
4226
- var participantAddedPayloadSchema = import_zod.z.object({
4227
- id: import_zod.z.string(),
4228
- name: import_zod.z.string(),
4229
- type: import_zod.z.string(),
4230
- handle: import_zod.z.string().nullish()
4231
- }).passthrough();
4232
- var participantRemovedPayloadSchema = import_zod.z.object({
4233
- id: import_zod.z.string()
4234
- }).passthrough();
4235
- var roomDeletedPayloadSchema = import_zod.z.object({
4236
- id: import_zod.z.string()
4237
- }).passthrough();
4238
- var contactRequestReceivedPayloadSchema = import_zod.z.object({
4239
- id: import_zod.z.string(),
4240
- from_handle: import_zod.z.string(),
4241
- from_name: import_zod.z.string(),
4242
- message: import_zod.z.string().nullish(),
4243
- status: import_zod.z.string(),
4244
- inserted_at: import_zod.z.string()
4245
- }).passthrough();
4246
- var contactRequestUpdatedPayloadSchema = import_zod.z.object({
4247
- id: import_zod.z.string(),
4248
- status: import_zod.z.string()
4249
- }).passthrough();
4250
- var contactAddedPayloadSchema = import_zod.z.object({
4251
- id: import_zod.z.string(),
4252
- handle: import_zod.z.string(),
4253
- name: import_zod.z.string(),
4254
- type: import_zod.z.string(),
4255
- description: import_zod.z.string().nullish(),
4256
- is_external: import_zod.z.boolean().nullish(),
4257
- inserted_at: import_zod.z.string()
4258
- }).passthrough();
4259
- var contactRemovedPayloadSchema = import_zod.z.object({
4260
- id: import_zod.z.string()
4261
- }).passthrough();
4262
- var payloadSchemas = {
4263
- message_created: messageCreatedPayloadSchema,
4264
- room_added: roomAddedPayloadSchema,
4265
- room_removed: roomRemovedPayloadSchema,
4266
- participant_added: participantAddedPayloadSchema,
4267
- participant_removed: participantRemovedPayloadSchema,
4268
- room_deleted: roomDeletedPayloadSchema,
4269
- contact_request_received: contactRequestReceivedPayloadSchema,
4270
- contact_request_updated: contactRequestUpdatedPayloadSchema,
4271
- contact_added: contactAddedPayloadSchema,
4272
- contact_removed: contactRemovedPayloadSchema
4273
- };
4274
-
4275
4988
  // src/platform/streaming/PhoenixChannelsTransport.ts
4989
+ var import_phoenix2 = require("phoenix");
4990
+
4991
+ // src/core/deferred.ts
4992
+ function createDeferred() {
4993
+ let resolve;
4994
+ const promise = new Promise((innerResolve) => {
4995
+ resolve = innerResolve;
4996
+ });
4997
+ return { promise, resolve };
4998
+ }
4999
+
5000
+ // src/platform/streaming/ChannelRegistry.ts
4276
5001
  var import_phoenix = require("phoenix");
5002
+ function supersededJoinError(topic) {
5003
+ return new TransportError(`Join superseded by transport disconnect for topic ${topic}`);
5004
+ }
5005
+ var ChannelRegistry = class {
5006
+ constructor(socket, epoch, logger, hooks) {
5007
+ this.socket = socket;
5008
+ this.epoch = epoch;
5009
+ this.logger = logger;
5010
+ this.hooks = hooks;
5011
+ }
5012
+ channels = /* @__PURE__ */ new Map();
5013
+ // A join's Channel and handler bindings, tracked from the moment doJoin
5014
+ // creates them — before the join Push settles — so a teardown mid-join can
5015
+ // find and detach it too, not only joins already promoted into `channels`.
5016
+ // Left untracked here, the underlying Phoenix Channel would survive
5017
+ // teardown unnoticed, keep its handlers bound, and could later be
5018
+ // resurrected by Phoenix's own reconnect machinery, redelivering live
5019
+ // events with no dedup anywhere upstream.
5020
+ pendingChannels = /* @__PURE__ */ new Map();
5021
+ joinFlights = new KeyedSingleFlight();
5022
+ leaveFlights = new KeyedSingleFlight();
5023
+ topics() {
5024
+ return this.channels.keys();
5025
+ }
5026
+ isJoined(topic) {
5027
+ return this.channels.has(topic);
5028
+ }
5029
+ /**
5030
+ * A promise for `topic` if it's already joined or has a join in flight,
5031
+ * without starting a new one. A topic mid-leave is never reported as
5032
+ * already joined — `channels` still holds it until the leave's Push
5033
+ * settles, but its handlers are already unbound and the channel is about
5034
+ * to be removed, so treating that window as "joined" would hand the
5035
+ * caller a promise that resolves into a channel already gone.
5036
+ */
5037
+ existingJoin(topic) {
5038
+ if (this.channels.has(topic) && !this.leaveFlights.current(topic)) {
5039
+ return Promise.resolve();
5040
+ }
5041
+ return this.joinFlights.current(topic) ?? void 0;
5042
+ }
5043
+ /**
5044
+ * Starts a new join for `topic`. Callers check `existingJoin()` first.
5045
+ * Waits out a leave already in flight for the same topic before starting,
5046
+ * so the new join's channel is never raced by the old one's teardown.
5047
+ */
5048
+ async join(topic, handlers) {
5049
+ const pendingLeave = this.leaveFlights.current(topic);
5050
+ if (pendingLeave) {
5051
+ await pendingLeave.catch(() => void 0);
5052
+ }
5053
+ return this.joinFlights.run(topic, () => this.doJoin(topic, handlers));
5054
+ }
5055
+ async doJoin(topic, handlers) {
5056
+ const epoch = this.epoch.current;
5057
+ const channel = this.socket.channel(topic, {});
5058
+ const refs = [];
5059
+ for (const [event, handler] of Object.entries(handlers)) {
5060
+ const ref = channel.on(event, this.hooks.wrapHandler(topic, event, handler));
5061
+ refs.push([event, ref]);
5062
+ }
5063
+ const joinPush = channel.join();
5064
+ try {
5065
+ await new Promise((resolve, reject) => {
5066
+ this.pendingChannels.set(topic, { channel, refs, reject });
5067
+ joinPush.receive("ok", () => {
5068
+ this.hooks.onJoinSettled(topic, true);
5069
+ resolve();
5070
+ }).receive("error", (error) => {
5071
+ this.hooks.onJoinSettled(topic, false);
5072
+ reject(new TransportError(`Failed to join topic ${topic}`, error));
5073
+ }).receive("timeout", () => {
5074
+ this.hooks.onJoinSettled(topic, false);
5075
+ reject(new TransportError(`Timeout joining topic ${topic}`));
5076
+ });
5077
+ });
5078
+ } catch (error) {
5079
+ const pending2 = this.forgetPendingChannel(topic, channel);
5080
+ if (pending2) {
5081
+ this.abandonChannel(topic, pending2);
5082
+ }
5083
+ throw error;
5084
+ }
5085
+ const pending = this.forgetPendingChannel(topic, channel);
5086
+ if (!pending || this.epoch.isStale(epoch)) {
5087
+ if (pending) {
5088
+ this.abandonChannel(topic, pending);
5089
+ }
5090
+ this.logger.debug("Join superseded by transport disconnect/reconnect", { topic });
5091
+ throw supersededJoinError(topic);
5092
+ }
5093
+ this.channels.set(topic, { channel, refs });
5094
+ this.logger.debug("Joined topic", { topic });
5095
+ }
5096
+ /**
5097
+ * Removes and returns `topic`'s pendingChannels entry only if it still
5098
+ * points at `channel`. A topic-keyed delete without this identity check
5099
+ * can drop a *different*, still-genuinely-pending join for the same topic
5100
+ * — reachable because a teardown clears `joinFlights`, so a later join for
5101
+ * a topic whose earlier join is still unsettled is possible, and that
5102
+ * earlier join's eventual (stale) settlement must not touch a slot it no
5103
+ * longer owns.
5104
+ */
5105
+ forgetPendingChannel(topic, channel) {
5106
+ const pending = this.pendingChannels.get(topic);
5107
+ if (pending?.channel !== channel) {
5108
+ return void 0;
5109
+ }
5110
+ this.pendingChannels.delete(topic);
5111
+ return pending;
5112
+ }
5113
+ /**
5114
+ * Detaches a join's channel and — via `pending.reject` — settles its
5115
+ * still-outstanding join promise directly, so abandoning it can never
5116
+ * leave `doJoin()`'s awaiter hanging on a Phoenix reply that a `leave()`
5117
+ * just made unroutable.
5118
+ */
5119
+ abandonChannel(topic, pending) {
5120
+ for (const [event, ref] of pending.refs) {
5121
+ pending.channel.off(event, ref);
5122
+ }
5123
+ pending.channel.leave();
5124
+ removeSocketChannel(this.socket, pending.channel);
5125
+ pending.reject(supersededJoinError(topic));
5126
+ }
5127
+ async leave(topic) {
5128
+ const pendingLeave = this.leaveFlights.current(topic);
5129
+ if (pendingLeave) {
5130
+ return pendingLeave;
5131
+ }
5132
+ const tracked = this.channels.get(topic);
5133
+ if (!tracked) {
5134
+ return;
5135
+ }
5136
+ return this.leaveFlights.run(topic, () => this.doLeave(topic, tracked));
5137
+ }
5138
+ async doLeave(topic, tracked) {
5139
+ const { channel, refs } = tracked;
5140
+ for (const [event, ref] of refs) {
5141
+ channel.off(event, ref);
5142
+ }
5143
+ await new Promise((resolve, reject) => {
5144
+ channel.leave().receive("ok", () => resolve()).receive(
5145
+ "error",
5146
+ (error) => reject(new TransportError(`Failed to leave topic ${topic}`, error))
5147
+ ).receive(
5148
+ "timeout",
5149
+ () => reject(new TransportError(`Timeout leaving topic ${topic}`))
5150
+ );
5151
+ });
5152
+ this.channels.delete(topic);
5153
+ this.hooks.onLeft(topic);
5154
+ this.logger.debug("Left topic", { topic });
5155
+ }
5156
+ /**
5157
+ * Attempts a graceful leave for every currently joined topic. A leave that
5158
+ * fails leaves its channel registered until `forceTeardown()` sweeps it.
5159
+ * Returns the rejection reasons of any leaves that failed.
5160
+ */
5161
+ async leaveAll() {
5162
+ const topics = [...this.channels.keys()];
5163
+ const results = await Promise.allSettled(topics.map((topic) => this.leave(topic)));
5164
+ return results.filter((result) => result.status === "rejected").map((result) => result.reason);
5165
+ }
5166
+ /**
5167
+ * Forcibly detaches anything still registered — a leave that failed above,
5168
+ * or a join still in flight — and clears all coalescing state. Call after
5169
+ * `leaveAll()` has settled.
5170
+ */
5171
+ forceTeardown() {
5172
+ for (const { channel, refs } of this.channels.values()) {
5173
+ for (const [event, ref] of refs) {
5174
+ channel.off(event, ref);
5175
+ }
5176
+ removeSocketChannel(this.socket, channel);
5177
+ }
5178
+ this.channels.clear();
5179
+ for (const [topic, pending] of this.pendingChannels) {
5180
+ this.abandonChannel(topic, pending);
5181
+ }
5182
+ this.pendingChannels.clear();
5183
+ this.joinFlights.clear();
5184
+ this.leaveFlights.clear();
5185
+ }
5186
+ };
5187
+ function removeSocketChannel(socket, channel) {
5188
+ const candidate = socket;
5189
+ candidate.remove?.(channel);
5190
+ }
4277
5191
 
4278
5192
  // src/platform/streaming/disconnectReason.ts
4279
- var import_zod2 = require("zod");
5193
+ var import_zod = require("zod");
5194
+ var import_band_sdk_core5 = require("@band-ai/band-sdk-core");
4280
5195
  var UPGRADE_CODES = [
4281
5196
  "invalid_on_conflict",
4282
5197
  "connection_conflict",
@@ -4305,9 +5220,9 @@ var UPGRADE_REASONS = {
4305
5220
  retryable: true
4306
5221
  }
4307
5222
  };
4308
- var upgradeErrorSchema = import_zod2.z.object({
4309
- status: import_zod2.z.number(),
4310
- body: import_zod2.z.preprocess(
5223
+ var upgradeErrorSchema = import_zod.z.object({
5224
+ status: import_zod.z.number(),
5225
+ body: import_zod.z.preprocess(
4311
5226
  (body) => {
4312
5227
  if (typeof body !== "string") {
4313
5228
  return body;
@@ -4318,16 +5233,16 @@ var upgradeErrorSchema = import_zod2.z.object({
4318
5233
  return null;
4319
5234
  }
4320
5235
  },
4321
- import_zod2.z.object({
4322
- error: import_zod2.z.object({
4323
- code: import_zod2.z.enum(UPGRADE_CODES),
4324
- message: import_zod2.z.string().optional(),
4325
- retry_after: import_zod2.z.number().nullable().optional(),
4326
- request_id: import_zod2.z.string().nullable().optional()
5236
+ import_zod.z.object({
5237
+ error: import_zod.z.object({
5238
+ code: import_zod.z.enum(UPGRADE_CODES),
5239
+ message: import_zod.z.string().optional(),
5240
+ retry_after: import_zod.z.number().nullable().optional(),
5241
+ request_id: import_zod.z.string().nullable().optional()
4327
5242
  })
4328
5243
  })
4329
5244
  ),
4330
- headers: import_zod2.z.record(import_zod2.z.unknown()).optional()
5245
+ headers: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
4331
5246
  });
4332
5247
  var WebSocketDisconnectError = class extends Error {
4333
5248
  reason;
@@ -4338,18 +5253,20 @@ var WebSocketDisconnectError = class extends Error {
4338
5253
  }
4339
5254
  };
4340
5255
  function parseSupersedeDisconnectReason(payload) {
4341
- if (typeof payload.reason !== "string" || typeof payload.message !== "string") {
5256
+ try {
5257
+ const normalized = (0, import_band_sdk_core5.validateEventPayload)("supersede", payload);
5258
+ return {
5259
+ source: "agent_control",
5260
+ code: normalized.reason,
5261
+ message: normalized.message,
5262
+ retryable: normalized.retryable,
5263
+ retryAfter: normalized.retry_after ?? null,
5264
+ targetSocketId: normalized.target_socket_id ?? null,
5265
+ correlationId: normalized.correlation_id
5266
+ };
5267
+ } catch {
4342
5268
  return null;
4343
5269
  }
4344
- return {
4345
- source: "agent_control",
4346
- code: payload.reason,
4347
- message: payload.message,
4348
- retryable: false,
4349
- retryAfter: typeof payload.retry_after === "number" ? payload.retry_after : null,
4350
- targetSocketId: typeof payload.target_socket_id === "string" ? payload.target_socket_id : null,
4351
- correlationId: typeof payload.correlation_id === "string" ? payload.correlation_id : null
4352
- };
4353
5270
  }
4354
5271
  function parseUpgradeDisconnectReason(event) {
4355
5272
  const parsed = upgradeErrorSchema.safeParse(event);
@@ -4434,19 +5351,90 @@ async function readResponseBody(response) {
4434
5351
  return Buffer.concat(chunks).toString("utf8");
4435
5352
  }
4436
5353
 
4437
- // src/platform/streaming/PhoenixChannelsTransport.ts
4438
- var import_band_sdk_core4 = require("@band-ai/band-sdk-core");
5354
+ // src/platform/streaming/ReconnectGenerationTracker.ts
5355
+ var ReconnectGenerationTracker = class {
5356
+ constructor(onSettled, onGenerationDropped) {
5357
+ this.onSettled = onSettled;
5358
+ this.onGenerationDropped = onGenerationDropped;
5359
+ }
5360
+ generations = /* @__PURE__ */ new Map();
5361
+ currentGeneration = 0;
5362
+ beginGeneration(topics) {
5363
+ const generation = ++this.currentGeneration;
5364
+ const attempted = new Set(topics);
5365
+ for (const staleGeneration of this.generations.keys()) {
5366
+ if (staleGeneration < generation) {
5367
+ const stale = this.generations.get(staleGeneration);
5368
+ if (stale) {
5369
+ this.onGenerationDropped?.(staleGeneration, stale.pending.size);
5370
+ this.generations.delete(staleGeneration);
5371
+ }
5372
+ }
5373
+ }
5374
+ this.generations.set(generation, { pending: new Set(attempted), attempted, joined: /* @__PURE__ */ new Set() });
5375
+ this.maybeFinalize(generation);
5376
+ return generation;
5377
+ }
5378
+ recordSettled(topic, joined) {
5379
+ const record = this.generations.get(this.currentGeneration);
5380
+ if (!record?.pending.delete(topic)) {
5381
+ return;
5382
+ }
5383
+ if (joined) {
5384
+ record.joined.add(topic);
5385
+ }
5386
+ this.maybeFinalize(this.currentGeneration);
5387
+ }
5388
+ removeTopic(topic) {
5389
+ for (const [generation, record] of this.generations) {
5390
+ if (record.pending.delete(topic)) {
5391
+ this.maybeFinalize(generation);
5392
+ }
5393
+ }
5394
+ }
5395
+ reset() {
5396
+ this.generations.clear();
5397
+ this.currentGeneration = 0;
5398
+ }
5399
+ maybeFinalize(generation) {
5400
+ const record = this.generations.get(generation);
5401
+ if (!record || record.pending.size > 0) {
5402
+ return;
5403
+ }
5404
+ this.generations.delete(generation);
5405
+ this.onSettled({ generation, attemptedTopics: record.attempted, joinedTopics: record.joined });
5406
+ }
5407
+ };
5408
+
5409
+ // src/platform/streaming/PhoenixChannelsTransport.ts
5410
+ var import_band_sdk_core6 = require("@band-ai/band-sdk-core");
4439
5411
  var PhoenixChannelsTransport = class {
4440
5412
  socket;
4441
5413
  agentId;
4442
- channels = /* @__PURE__ */ new Map();
4443
- channelRefs = /* @__PURE__ */ new Map();
4444
- pendingJoins = /* @__PURE__ */ new Map();
5414
+ registry;
5415
+ reconnectObservers = /* @__PURE__ */ new Set();
5416
+ // Topics joined with `{ exemptFromBuffering: true }`, recorded here so
5417
+ // `wrapHandler` can check by name on every delivered event rather than
5418
+ // threading the flag through the channel/handler plumbing.
5419
+ bufferingExemptTopics = /* @__PURE__ */ new Set();
5420
+ generationTracker = new ReconnectGenerationTracker(
5421
+ (snapshot) => this.notifyReconnectObservers(snapshot),
5422
+ (generation, pendingTopics) => this.logger.debug("Superseded reconnect generation before it fully settled", {
5423
+ generation,
5424
+ pendingTopics
5425
+ })
5426
+ );
5427
+ bufferedTopicEvents = [];
5428
+ hasOpenedOnce = false;
5429
+ epoch = new Epoch();
5430
+ bufferingGeneration = null;
5431
+ reconnectBarrier = null;
5432
+ observerChain = new Serializer();
4445
5433
  logger;
4446
5434
  onTerminalDisconnect;
4447
5435
  onHandlerError;
4448
5436
  connected = false;
4449
- connectPromise = null;
5437
+ connectFlight = new SingleFlight2();
4450
5438
  connectResolve = null;
4451
5439
  connectReject = null;
4452
5440
  lastDisconnectReason = null;
@@ -4455,7 +5443,7 @@ var PhoenixChannelsTransport = class {
4455
5443
  stoppingReconnect = false;
4456
5444
  suppressNextCloseReason = false;
4457
5445
  constructor(options) {
4458
- this.logger = options.logger ?? new NoopLogger();
5446
+ this.logger = resolveLogger(options.logger);
4459
5447
  this.agentId = options.agentId;
4460
5448
  this.onTerminalDisconnect = options.onTerminalDisconnect;
4461
5449
  let wsUrl = options.wsUrl;
@@ -4463,7 +5451,7 @@ var PhoenixChannelsTransport = class {
4463
5451
  wsUrl = wsUrl.slice(0, -"/websocket".length);
4464
5452
  }
4465
5453
  const reconnectAfterMs = options.reconnectAfterMs ?? ((tries) => [1e3, 2e3, 5e3, 1e4, 3e4][tries - 1] ?? 3e4);
4466
- this.socket = new import_phoenix.Socket(wsUrl, {
5454
+ this.socket = new import_phoenix2.Socket(wsUrl, {
4467
5455
  params: {
4468
5456
  agent_id: options.agentId,
4469
5457
  ...options.conflictPolicy ? { on_conflict: options.conflictPolicy } : {}
@@ -4477,6 +5465,35 @@ var PhoenixChannelsTransport = class {
4477
5465
  },
4478
5466
  transport: options.websocketFactory ?? resolveWebSocketFactory(options.apiKey)
4479
5467
  });
5468
+ this.registry = new ChannelRegistry(this.socket, this.epoch, this.logger, {
5469
+ wrapHandler: (topic, event, handler) => (payload) => {
5470
+ const reportError = (error) => {
5471
+ this.logger.error("Unhandled topic handler error", { topic, event, error });
5472
+ this.onHandlerError?.(error);
5473
+ };
5474
+ const deliver = () => {
5475
+ try {
5476
+ void Promise.resolve(handler(payload)).catch(reportError);
5477
+ } catch (error) {
5478
+ reportError(error);
5479
+ }
5480
+ };
5481
+ if (this.bufferingGeneration !== null && !this.bufferingExemptTopics.has(topic)) {
5482
+ this.bufferedTopicEvents.push({ topic, deliver });
5483
+ } else {
5484
+ deliver();
5485
+ }
5486
+ },
5487
+ onJoinSettled: (topic, joined) => this.generationTracker.recordSettled(topic, joined),
5488
+ onLeft: (topic) => {
5489
+ this.generationTracker.removeTopic(topic);
5490
+ for (let index = this.bufferedTopicEvents.length - 1; index >= 0; index -= 1) {
5491
+ if (this.bufferedTopicEvents[index]?.topic === topic) {
5492
+ this.bufferedTopicEvents.splice(index, 1);
5493
+ }
5494
+ }
5495
+ }
5496
+ });
4480
5497
  this.socket.onOpen(() => {
4481
5498
  void this.handleOpen();
4482
5499
  });
@@ -4513,42 +5530,25 @@ var PhoenixChannelsTransport = class {
4513
5530
  if (this.connected) {
4514
5531
  return;
4515
5532
  }
4516
- if (!this.connectPromise) {
5533
+ await this.connectFlight.run(() => {
4517
5534
  this.socket.connect();
4518
- const pending = this.waitForConnection();
4519
- this.connectPromise = pending;
4520
- void pending.then(
4521
- () => {
4522
- if (this.connectPromise === pending) {
4523
- this.connectPromise = null;
4524
- }
4525
- },
4526
- () => {
4527
- if (this.connectPromise === pending) {
4528
- this.connectPromise = null;
4529
- }
4530
- }
4531
- );
4532
- }
4533
- await this.connectPromise;
5535
+ return this.waitForConnection();
5536
+ });
4534
5537
  }
4535
5538
  async disconnect() {
4536
- const results = await Promise.allSettled(
4537
- [...this.channels.keys()].map((topic) => this.leave(topic))
4538
- );
5539
+ this.epoch.bump();
5540
+ const failures = await this.registry.leaveAll();
4539
5541
  this.socket.disconnect();
4540
5542
  this.connected = false;
4541
- const failures = [];
4542
- for (const result of results) {
4543
- if (result.status === "rejected") {
4544
- failures.push(result.reason);
4545
- }
4546
- }
5543
+ this.registry.forceTeardown();
5544
+ this.hasOpenedOnce = false;
5545
+ this.bufferingGeneration = null;
5546
+ this.reconnectBarrier?.resolve();
5547
+ this.reconnectBarrier = null;
5548
+ this.bufferedTopicEvents.splice(0);
5549
+ this.generationTracker.reset();
4547
5550
  if (failures.length > 0) {
4548
- throw new AggregateError(
4549
- failures,
4550
- "Failed to leave one or more Phoenix topics during disconnect"
4551
- );
5551
+ throw combineTeardownErrors(failures, "Failed to leave one or more Phoenix topics during disconnect");
4552
5552
  }
4553
5553
  }
4554
5554
  isConnected() {
@@ -4557,79 +5557,28 @@ var PhoenixChannelsTransport = class {
4557
5557
  getDisconnectReason() {
4558
5558
  return this.lastDisconnectReason;
4559
5559
  }
4560
- async join(topic, handlers) {
4561
- if (this.channels.has(topic)) {
4562
- return;
5560
+ async join(topic, handlers, options) {
5561
+ if (options?.exemptFromBuffering) {
5562
+ this.bufferingExemptTopics.add(topic);
4563
5563
  }
4564
- const pendingJoin = this.pendingJoins.get(topic);
4565
- if (pendingJoin) {
4566
- return pendingJoin;
5564
+ const existing = this.registry.existingJoin(topic);
5565
+ if (existing) {
5566
+ return existing;
4567
5567
  }
4568
- const joinPromise = this.doJoin(topic, handlers).finally(() => {
4569
- this.pendingJoins.delete(topic);
4570
- });
4571
- this.pendingJoins.set(topic, joinPromise);
4572
- return joinPromise;
4573
- }
4574
- async doJoin(topic, handlers) {
4575
- const channel = this.socket.channel(topic, {});
4576
- const refs = [];
4577
- for (const [event, handler] of Object.entries(handlers)) {
4578
- const ref = channel.on(event, (payload) => {
4579
- Promise.resolve(handler(payload)).catch((error) => {
4580
- this.logger.error("Unhandled topic handler error", {
4581
- topic,
4582
- event,
4583
- error
4584
- });
4585
- this.onHandlerError?.(error);
4586
- });
4587
- });
4588
- refs.push([event, ref]);
5568
+ const epoch = this.epoch.current;
5569
+ await this.reconnectBarrier?.promise;
5570
+ if (this.epoch.isStale(epoch)) {
5571
+ this.logger.debug("Join superseded by transport disconnect/reconnect", { topic });
5572
+ throw supersededJoinError(topic);
4589
5573
  }
4590
- try {
4591
- await new Promise((resolve, reject) => {
4592
- channel.join().receive("ok", () => resolve()).receive(
4593
- "error",
4594
- (error) => reject(new TransportError(`Failed to join topic ${topic}`, error))
4595
- ).receive(
4596
- "timeout",
4597
- () => reject(new TransportError(`Timeout joining topic ${topic}`))
4598
- );
4599
- });
4600
- } catch (error) {
4601
- for (const [event, ref] of refs) {
4602
- channel.off(event, ref);
4603
- }
4604
- channel.leave();
4605
- removeSocketChannel(this.socket, channel);
4606
- throw error;
5574
+ const resumed = this.registry.existingJoin(topic);
5575
+ if (resumed) {
5576
+ return resumed;
4607
5577
  }
4608
- this.channels.set(topic, channel);
4609
- this.channelRefs.set(topic, refs);
4610
- this.logger.debug("Joined topic", { topic });
5578
+ return this.registry.join(topic, handlers);
4611
5579
  }
4612
5580
  async leave(topic) {
4613
- const channel = this.channels.get(topic);
4614
- if (!channel) {
4615
- return;
4616
- }
4617
- const refs = this.channelRefs.get(topic) ?? [];
4618
- for (const [event, ref] of refs) {
4619
- channel.off(event, ref);
4620
- }
4621
- this.channelRefs.delete(topic);
4622
- await new Promise((resolve, reject) => {
4623
- channel.leave().receive("ok", () => resolve()).receive(
4624
- "error",
4625
- (error) => reject(new TransportError(`Failed to leave topic ${topic}`, error))
4626
- ).receive(
4627
- "timeout",
4628
- () => reject(new TransportError(`Timeout leaving topic ${topic}`))
4629
- );
4630
- });
4631
- this.channels.delete(topic);
4632
- this.logger.debug("Left topic", { topic });
5581
+ return this.registry.leave(topic);
4633
5582
  }
4634
5583
  async runForever(signal) {
4635
5584
  if (this.terminalDisconnectError) {
@@ -4667,7 +5616,21 @@ var PhoenixChannelsTransport = class {
4667
5616
  );
4668
5617
  });
4669
5618
  }
5619
+ onReconnected(observer) {
5620
+ this.reconnectObservers.add(observer);
5621
+ return () => {
5622
+ this.reconnectObservers.delete(observer);
5623
+ };
5624
+ }
4670
5625
  async handleOpen() {
5626
+ if (this.hasOpenedOnce) {
5627
+ this.bufferingGeneration = this.generationTracker.beginGeneration(this.registry.topics());
5628
+ if (!this.reconnectBarrier) {
5629
+ this.reconnectBarrier = createDeferred();
5630
+ }
5631
+ } else {
5632
+ this.hasOpenedOnce = true;
5633
+ }
4671
5634
  try {
4672
5635
  await this.subscribeAgentControl();
4673
5636
  } catch (error) {
@@ -4689,6 +5652,40 @@ var PhoenixChannelsTransport = class {
4689
5652
  channels: getSocketChannelCount(this.socket)
4690
5653
  });
4691
5654
  }
5655
+ notifyReconnectObservers(snapshot) {
5656
+ const epoch = this.epoch.current;
5657
+ const observers = [...this.reconnectObservers];
5658
+ void this.observerChain.run(async () => {
5659
+ if (this.epoch.isStale(epoch)) {
5660
+ return;
5661
+ }
5662
+ for (const observer of observers) {
5663
+ if (this.epoch.isStale(epoch)) {
5664
+ return;
5665
+ }
5666
+ if (!this.reconnectObservers.has(observer)) {
5667
+ continue;
5668
+ }
5669
+ try {
5670
+ await observer(snapshot);
5671
+ } catch (error) {
5672
+ this.logger.error("Reconnect observer failed", {
5673
+ generation: snapshot.generation,
5674
+ error
5675
+ });
5676
+ }
5677
+ }
5678
+ if (!this.epoch.isStale(epoch) && snapshot.generation === this.bufferingGeneration) {
5679
+ this.bufferingGeneration = null;
5680
+ const events = this.bufferedTopicEvents.splice(0);
5681
+ for (const { deliver } of events) {
5682
+ deliver();
5683
+ }
5684
+ this.reconnectBarrier?.resolve();
5685
+ this.reconnectBarrier = null;
5686
+ }
5687
+ });
5688
+ }
4692
5689
  stopReconnectIfNoChannels(options = {}) {
4693
5690
  if (this.stoppingReconnect || getSocketChannelCount(this.socket) !== 0) {
4694
5691
  return;
@@ -4702,18 +5699,23 @@ var PhoenixChannelsTransport = class {
4702
5699
  if (!this.agentId) {
4703
5700
  return;
4704
5701
  }
4705
- await this.join((0, import_band_sdk_core4.agentControlTopic)(this.agentId), {
4706
- supersede: (payload) => {
4707
- const reason = parseSupersedeDisconnectReason(payload);
4708
- if (!reason) {
4709
- this.logger.warn("Invalid agent_control supersede payload", {
4710
- payload
4711
- });
4712
- return;
5702
+ const topic = (0, import_band_sdk_core6.agentControlTopic)(this.agentId);
5703
+ await this.join(
5704
+ topic,
5705
+ {
5706
+ supersede: (payload) => {
5707
+ const reason = parseSupersedeDisconnectReason(payload);
5708
+ if (!reason) {
5709
+ this.logger.warn("Invalid agent_control supersede payload", {
5710
+ payload
5711
+ });
5712
+ return;
5713
+ }
5714
+ this.recordTerminalDisconnect(reason);
4713
5715
  }
4714
- this.recordTerminalDisconnect(reason);
4715
- }
4716
- });
5716
+ },
5717
+ { exemptFromBuffering: true }
5718
+ );
4717
5719
  }
4718
5720
  recordSocketClose(event) {
4719
5721
  this.connected = false;
@@ -4789,10 +5791,6 @@ function unwrapErrorEvent(event) {
4789
5791
  function isErrorEvent(event) {
4790
5792
  return typeof event === "object" && event !== null && "error" in event;
4791
5793
  }
4792
- function removeSocketChannel(socket, channel) {
4793
- const candidate = socket;
4794
- candidate.remove?.(channel);
4795
- }
4796
5794
  function getSocketChannelCount(socket) {
4797
5795
  const candidate = socket;
4798
5796
  if (!Array.isArray(candidate.channels)) {
@@ -4803,19 +5801,412 @@ function getSocketChannelCount(socket) {
4803
5801
 
4804
5802
  // src/platform/BandLink.ts
4805
5803
  var import_rest_client = require("@band-ai/rest-client");
4806
- var import_band_sdk_core5 = require("@band-ai/band-sdk-core");
5804
+ var import_band_sdk_core9 = require("@band-ai/band-sdk-core");
5805
+
5806
+ // src/platform/SubscriptionManager.ts
5807
+ var import_band_sdk_core8 = require("@band-ai/band-sdk-core");
5808
+
5809
+ // src/platform/roomTopics.ts
5810
+ var import_band_sdk_core7 = require("@band-ai/band-sdk-core");
5811
+ function roomTopics(roomId) {
5812
+ return { chat: (0, import_band_sdk_core7.chatRoomTopic)(roomId), participants: (0, import_band_sdk_core7.roomParticipantsTopic)(roomId) };
5813
+ }
5814
+ function isRejected(result) {
5815
+ return result.status === "rejected";
5816
+ }
5817
+ async function settleRoomLeaves(transport, roomId) {
5818
+ const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
5819
+ const results = await Promise.allSettled([
5820
+ transport.leave(chatTopic),
5821
+ transport.leave(participantsTopic)
5822
+ ]);
5823
+ return results.filter(isRejected);
5824
+ }
5825
+
5826
+ // src/platform/ReconciliationCoordinator.ts
5827
+ var ReconciliationCoordinator = class {
5828
+ constructor(tracker, transport, logger, epoch) {
5829
+ this.tracker = tracker;
5830
+ this.transport = transport;
5831
+ this.logger = logger;
5832
+ this.epoch = epoch;
5833
+ }
5834
+ roomsNeedingReconciliation = /* @__PURE__ */ new Set();
5835
+ agentTopicsNeedingReconciliation = /* @__PURE__ */ new Set();
5836
+ reconcileTail = new Serializer();
5837
+ lastReconciledGeneration = 0;
5838
+ markRoomNeedsReconciliation(roomId) {
5839
+ this.roomsNeedingReconciliation.add(roomId);
5840
+ }
5841
+ markAgentTopicNeedsReconciliation(topic) {
5842
+ this.agentTopicsNeedingReconciliation.add(topic);
5843
+ }
5844
+ /** Called on session end: every pending sweep is moot against a tracker that has itself just been reset. */
5845
+ reset() {
5846
+ this.roomsNeedingReconciliation.clear();
5847
+ this.agentTopicsNeedingReconciliation.clear();
5848
+ this.lastReconciledGeneration = 0;
5849
+ }
5850
+ /** Serialized: each generation's reconciliation completes before the next begins. */
5851
+ reconcileReconnect(snapshot) {
5852
+ if (snapshot.generation <= this.lastReconciledGeneration) {
5853
+ this.logger.debug("Ignoring stale or duplicate reconnect snapshot", {
5854
+ generation: snapshot.generation,
5855
+ lastReconciledGeneration: this.lastReconciledGeneration
5856
+ });
5857
+ return Promise.resolve();
5858
+ }
5859
+ this.lastReconciledGeneration = snapshot.generation;
5860
+ const epoch = this.epoch.current;
5861
+ this.tracker.onReconnected();
5862
+ const work = {
5863
+ snapshot,
5864
+ epoch,
5865
+ roomCandidates: this.tracker.roomRejoinCandidates(),
5866
+ agentTopicCandidates: this.tracker.agentTopicRejoinCandidates()
5867
+ };
5868
+ return this.reconcileTail.run(() => this.runReconcile(work));
5869
+ }
5870
+ async runReconcile(work) {
5871
+ const { snapshot, epoch, roomCandidates, agentTopicCandidates } = work;
5872
+ if (this.epoch.isStale(epoch)) {
5873
+ this.logger.debug("Reconnect reconciliation settled after session ended, skipping rejoin evaluation", {
5874
+ generation: snapshot.generation
5875
+ });
5876
+ return;
5877
+ }
5878
+ for (const [roomId, ticket] of roomCandidates) {
5879
+ const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
5880
+ if (!snapshot.attemptedTopics.has(chatTopic) || !snapshot.attemptedTopics.has(participantsTopic)) {
5881
+ continue;
5882
+ }
5883
+ const present = snapshot.joinedTopics.has(chatTopic) && snapshot.joinedTopics.has(participantsTopic);
5884
+ if (present) {
5885
+ continue;
5886
+ }
5887
+ if (this.tracker.markRoomRejoinFailed(roomId, ticket)) {
5888
+ this.markRoomNeedsReconciliation(roomId);
5889
+ }
5890
+ }
5891
+ for (const [topic, ticket] of agentTopicCandidates) {
5892
+ if (!snapshot.attemptedTopics.has(topic)) {
5893
+ continue;
5894
+ }
5895
+ if (snapshot.joinedTopics.has(topic)) {
5896
+ continue;
5897
+ }
5898
+ if (this.tracker.markAgentTopicRejoinFailed(topic, ticket)) {
5899
+ this.markAgentTopicNeedsReconciliation(topic);
5900
+ }
5901
+ }
5902
+ if (this.epoch.isStale(epoch)) {
5903
+ this.logger.debug("Reconnect reconciliation settled after session ended, skipping cleanup drain", {
5904
+ generation: snapshot.generation
5905
+ });
5906
+ return;
5907
+ }
5908
+ await this.drainReconciliation(epoch);
5909
+ }
5910
+ async drainReconciliation(epoch) {
5911
+ const rooms = [...this.roomsNeedingReconciliation];
5912
+ const topics = [...this.agentTopicsNeedingReconciliation];
5913
+ const [roomLeaveResults, topicLeaveResults] = await Promise.all([
5914
+ Promise.allSettled(rooms.map((roomId) => this.leaveRoomTopicsCleanly(roomId))),
5915
+ Promise.allSettled(topics.map((topic) => this.transport.leave(topic)))
5916
+ ]);
5917
+ if (this.epoch.isStale(epoch)) {
5918
+ this.logger.debug("Reconnect reconciliation cleanup settled after session ended, leaving tracker acknowledgements pending for next reconnect", {
5919
+ rooms: rooms.length,
5920
+ topics: topics.length
5921
+ });
5922
+ return;
5923
+ }
5924
+ this.acknowledgeCleanup({
5925
+ ids: rooms,
5926
+ results: roomLeaveResults,
5927
+ pending: this.roomsNeedingReconciliation,
5928
+ acknowledge: (roomId) => this.tracker.acknowledgeRoomReconciled(roomId),
5929
+ failureMessage: "Room reconciliation cleanup failed, retrying on next reconnect",
5930
+ logContext: (roomId) => ({ roomId })
5931
+ });
5932
+ this.acknowledgeCleanup({
5933
+ ids: topics,
5934
+ results: topicLeaveResults,
5935
+ pending: this.agentTopicsNeedingReconciliation,
5936
+ acknowledge: (topic) => this.tracker.acknowledgeAgentTopicReconciled(topic),
5937
+ failureMessage: "Agent topic reconciliation cleanup failed, retrying on next reconnect",
5938
+ logContext: (topic) => ({ topic })
5939
+ });
5940
+ }
5941
+ acknowledgeCleanup(options) {
5942
+ options.ids.forEach((id, index) => {
5943
+ if (options.results[index]?.status !== "fulfilled") {
5944
+ this.logger.warn(options.failureMessage, options.logContext(id));
5945
+ return;
5946
+ }
5947
+ if (options.acknowledge(id)) {
5948
+ options.pending.delete(id);
5949
+ }
5950
+ });
5951
+ }
5952
+ async leaveRoomTopicsCleanly(roomId) {
5953
+ const failures = await settleRoomLeaves(this.transport, roomId);
5954
+ if (failures.length > 0) {
5955
+ throw new AggregateError(
5956
+ failures.map((failure) => failure.reason),
5957
+ `Failed to clean up room ${roomId} during reconnect reconciliation`
5958
+ );
5959
+ }
5960
+ }
5961
+ };
5962
+
5963
+ // src/platform/SubscriptionManager.ts
5964
+ function roomOperationKey(roomId) {
5965
+ return `room:${roomId}`;
5966
+ }
5967
+ function topicOperationKey(topic) {
5968
+ return `topic:${topic}`;
5969
+ }
5970
+ var SubscriptionManager = class {
5971
+ tracker = new import_band_sdk_core8.SubscriptionTracker();
5972
+ transport;
5973
+ logger;
5974
+ operations = /* @__PURE__ */ new Map();
5975
+ epoch = new Epoch();
5976
+ reconciliation;
5977
+ constructor(options) {
5978
+ this.transport = options.transport;
5979
+ this.logger = resolveLogger(options.logger);
5980
+ this.reconciliation = new ReconciliationCoordinator(
5981
+ this.tracker,
5982
+ this.transport,
5983
+ this.logger,
5984
+ this.epoch
5985
+ );
5986
+ }
5987
+ subscribeRoom(roomId, handlers) {
5988
+ return this.runOperation(
5989
+ roomOperationKey(roomId),
5990
+ "up",
5991
+ () => this.claimRoomSubscribe(roomId, handlers)
5992
+ );
5993
+ }
5994
+ unsubscribeRoom(roomId) {
5995
+ return this.runOperation(
5996
+ roomOperationKey(roomId),
5997
+ "down",
5998
+ () => this.claimRoomUnsubscribe(roomId)
5999
+ );
6000
+ }
6001
+ subscribeAgentTopic(topic, handlers) {
6002
+ return this.runOperation(
6003
+ topicOperationKey(topic),
6004
+ "up",
6005
+ () => this.claimAgentTopicJoin(topic, handlers)
6006
+ );
6007
+ }
6008
+ unsubscribeAgentTopic(topic) {
6009
+ return this.runOperation(
6010
+ topicOperationKey(topic),
6011
+ "down",
6012
+ () => this.claimAgentTopicLeave(topic)
6013
+ );
6014
+ }
6015
+ /** Serialized: each generation's reconciliation completes before the next begins. */
6016
+ reconcileReconnect(snapshot) {
6017
+ return this.reconciliation.reconcileReconnect(snapshot);
6018
+ }
6019
+ /**
6020
+ * Ends this session: every ticket issued so far goes stale, every in-flight
6021
+ * operation's late completion becomes a no-op, and both reconciliation sets
6022
+ * start empty for the next session. The epoch guards host-side async work
6023
+ * and transport effects that can outlive the core session they started in.
6024
+ */
6025
+ endSession() {
6026
+ this.epoch.bump();
6027
+ this.tracker.endSession();
6028
+ this.operations.clear();
6029
+ this.reconciliation.reset();
6030
+ }
6031
+ leaveOutcome(epoch, succeeded) {
6032
+ return this.epoch.isStale(epoch) ? "unknown" : succeeded ? "left" : "failed";
6033
+ }
6034
+ // ---- generic operation coalescing -------------------------------------
6035
+ runOperation(key, kind, claim) {
6036
+ const existing = this.operations.get(key);
6037
+ if (existing?.kind === kind) {
6038
+ return existing.promise;
6039
+ }
6040
+ const promise = existing ? existing.promise.then(claim, claim) : claim();
6041
+ const operation = { kind, promise };
6042
+ this.operations.set(key, operation);
6043
+ const cleanup = () => {
6044
+ if (this.operations.get(key) === operation) {
6045
+ this.operations.delete(key);
6046
+ }
6047
+ };
6048
+ promise.then(cleanup, cleanup);
6049
+ return promise;
6050
+ }
6051
+ // ---- room subscribe/unsubscribe ----------------------------------------
6052
+ async claimRoomSubscribe(roomId, handlers) {
6053
+ const epoch = this.epoch.current;
6054
+ const ticket = this.tracker.beginRoomSubscribe(roomId);
6055
+ if (ticket === void 0) {
6056
+ return this.settleIdempotentRoomClaim(roomId);
6057
+ }
6058
+ const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
6059
+ try {
6060
+ await this.transport.join(chatTopic, handlers.chat);
6061
+ } catch (error) {
6062
+ if (!this.epoch.isStale(epoch)) {
6063
+ this.tracker.recordChatRoomJoinFailed(roomId, ticket);
6064
+ } else {
6065
+ this.logger.debug("Room chat-topic join settled after session ended, ignoring stale ticket", {
6066
+ roomId
6067
+ });
6068
+ }
6069
+ throw error;
6070
+ }
6071
+ try {
6072
+ await this.transport.join(participantsTopic, handlers.participants);
6073
+ } catch (participantError) {
6074
+ if (this.epoch.isStale(epoch)) {
6075
+ this.logger.debug(
6076
+ "Room participants-topic join settled after session ended, ignoring stale ticket",
6077
+ { roomId }
6078
+ );
6079
+ throw participantError;
6080
+ }
6081
+ let chatRoomLeft = false;
6082
+ let rollbackError;
6083
+ try {
6084
+ await this.transport.leave(chatTopic);
6085
+ chatRoomLeft = true;
6086
+ } catch (error) {
6087
+ rollbackError = error;
6088
+ }
6089
+ const result = this.tracker.recordRoomParticipantsJoinFailed(roomId, ticket, chatRoomLeft);
6090
+ if (result === "rollback_failed") {
6091
+ this.reconciliation.markRoomNeedsReconciliation(roomId);
6092
+ throw new AggregateError(
6093
+ [
6094
+ participantError,
6095
+ rollbackError ?? new TransportError(`Failed to roll back chat_room join for room ${roomId}`)
6096
+ ],
6097
+ `Failed to subscribe to room ${roomId} and roll back its chat_room join`
6098
+ );
6099
+ }
6100
+ throw participantError;
6101
+ }
6102
+ if (!this.epoch.isStale(epoch)) {
6103
+ this.tracker.recordBothRoomTopicsJoined(roomId, ticket);
6104
+ } else {
6105
+ this.logger.debug("Room subscribe settled after session ended, discarding stale success", {
6106
+ roomId
6107
+ });
6108
+ }
6109
+ }
6110
+ /** Resolves or rejects an idempotent no-op claim (the tracker reported no new ticket to act on). */
6111
+ settleIdempotentClaim(matched, describe) {
6112
+ return matched ? Promise.resolve() : Promise.reject(new RuntimeStateError(describe()));
6113
+ }
6114
+ settleIdempotentRoomClaim(roomId) {
6115
+ const status = this.tracker.roomStatus(roomId);
6116
+ return this.settleIdempotentClaim(
6117
+ status === "subscribed",
6118
+ () => `Room ${roomId} cannot be subscribed while it is "${status}"`
6119
+ );
6120
+ }
6121
+ async claimRoomUnsubscribe(roomId) {
6122
+ const epoch = this.epoch.current;
6123
+ const ticket = this.tracker.unsubscribeRoom(roomId);
6124
+ if (ticket === void 0) {
6125
+ return;
6126
+ }
6127
+ const failures = await settleRoomLeaves(this.transport, roomId);
6128
+ const outcome = this.leaveOutcome(epoch, failures.length === 0);
6129
+ if (outcome === "unknown") {
6130
+ this.logger.debug("Room unsubscribe settled after session ended, outcome ambiguous", { roomId });
6131
+ }
6132
+ if (this.tracker.markRoomLeaveComplete(roomId, ticket, outcome) && outcome !== "left") {
6133
+ this.reconciliation.markRoomNeedsReconciliation(roomId);
6134
+ }
6135
+ if (failures.length > 0) {
6136
+ throw new AggregateError(
6137
+ failures.map((failure) => failure.reason),
6138
+ `Failed to fully unsubscribe from room ${roomId}`
6139
+ );
6140
+ }
6141
+ }
6142
+ // ---- agent topic join/leave (agent_rooms, agent_contacts) --------------
6143
+ async claimAgentTopicJoin(topic, handlers) {
6144
+ const epoch = this.epoch.current;
6145
+ const ticket = this.tracker.beginAgentTopicJoin(topic);
6146
+ if (ticket === void 0) {
6147
+ return this.settleIdempotentTopicClaim(topic);
6148
+ }
6149
+ let joined = false;
6150
+ let joinError;
6151
+ try {
6152
+ await this.transport.join(topic, handlers);
6153
+ joined = true;
6154
+ } catch (error) {
6155
+ joinError = error;
6156
+ }
6157
+ if (this.epoch.isStale(epoch)) {
6158
+ this.tracker.recordAgentTopicJoinAmbiguous(topic, ticket);
6159
+ this.logger.debug("Agent topic join settled after session ended, marking ambiguous for reconciliation", {
6160
+ topic
6161
+ });
6162
+ } else {
6163
+ this.tracker.recordAgentTopicJoin(topic, ticket, joined);
6164
+ }
6165
+ if (!joined) {
6166
+ throw joinError;
6167
+ }
6168
+ }
6169
+ settleIdempotentTopicClaim(topic) {
6170
+ const status = this.tracker.agentTopicStatus(topic);
6171
+ return this.settleIdempotentClaim(
6172
+ status === "joined",
6173
+ () => `Topic ${topic} cannot be joined while it is "${status}"`
6174
+ );
6175
+ }
6176
+ async claimAgentTopicLeave(topic) {
6177
+ const epoch = this.epoch.current;
6178
+ const ticket = this.tracker.leaveAgentTopic(topic);
6179
+ if (ticket === void 0) {
6180
+ return;
6181
+ }
6182
+ let left = true;
6183
+ let leaveError;
6184
+ try {
6185
+ await this.transport.leave(topic);
6186
+ } catch (error) {
6187
+ left = false;
6188
+ leaveError = error;
6189
+ }
6190
+ const outcome = this.leaveOutcome(epoch, left);
6191
+ if (outcome === "unknown") {
6192
+ this.logger.debug("Agent topic unsubscribe settled after session ended, outcome ambiguous", { topic });
6193
+ }
6194
+ if (this.tracker.markAgentTopicLeaveComplete(topic, ticket, outcome) && outcome !== "left") {
6195
+ this.reconciliation.markAgentTopicNeedsReconciliation(topic);
6196
+ }
6197
+ if (!left) {
6198
+ throw leaveError;
6199
+ }
6200
+ }
6201
+ };
6202
+
6203
+ // src/platform/BandLink.ts
4807
6204
  var DEFAULT_WS_URL = "wss://app.band.ai/api/v1/socket";
4808
6205
  function deriveDefaultRestUrl(wsUrl) {
4809
6206
  const parsed = new URL(wsUrl);
4810
6207
  const protocol = parsed.protocol === "ws:" ? "http:" : "https:";
4811
6208
  return `${protocol}//${parsed.host}`;
4812
6209
  }
4813
- function roomTopics(roomId) {
4814
- return {
4815
- chat: (0, import_band_sdk_core5.chatRoomTopic)(roomId),
4816
- participants: (0, import_band_sdk_core5.roomParticipantsTopic)(roomId)
4817
- };
4818
- }
4819
6210
  function toPlatformMessage(roomId, message) {
4820
6211
  return {
4821
6212
  id: message.id,
@@ -4829,6 +6220,19 @@ function toPlatformMessage(roomId, message) {
4829
6220
  createdAt: new Date(message.inserted_at)
4830
6221
  };
4831
6222
  }
6223
+ function validationErrorDetails(error) {
6224
+ if (typeof error !== "object" || error === null) {
6225
+ return {};
6226
+ }
6227
+ const details = {};
6228
+ if ("issues" in error) {
6229
+ details.issues = error.issues;
6230
+ }
6231
+ if ("traceContext" in error) {
6232
+ details.traceContext = error.traceContext;
6233
+ }
6234
+ return details;
6235
+ }
4832
6236
  var BandLink = class {
4833
6237
  agentId;
4834
6238
  apiKey;
@@ -4838,18 +6242,21 @@ var BandLink = class {
4838
6242
  capabilities;
4839
6243
  logger;
4840
6244
  transport;
4841
- subscribedRooms = /* @__PURE__ */ new Set();
6245
+ subscriptionManager;
4842
6246
  eventQueue = [];
4843
6247
  waiters = [];
4844
6248
  connected = false;
4845
6249
  lastDisconnectReason = null;
4846
6250
  terminalDisconnectError = null;
6251
+ connectFlight = new SingleFlight2();
6252
+ disconnectFlight = new SingleFlight2();
6253
+ session = new Session();
4847
6254
  constructor(options) {
4848
6255
  this.agentId = options.agentId;
4849
6256
  this.apiKey = options.apiKey;
4850
6257
  this.wsUrl = options.wsUrl ?? DEFAULT_WS_URL;
4851
6258
  this.restUrl = options.restUrl ?? deriveDefaultRestUrl(this.wsUrl);
4852
- this.logger = options.logger ?? new NoopLogger();
6259
+ this.logger = resolveLogger(options.logger);
4853
6260
  this.capabilities = {
4854
6261
  ...DEFAULT_AGENT_TOOLS_CAPABILITIES,
4855
6262
  ...options.capabilities
@@ -4871,6 +6278,10 @@ var BandLink = class {
4871
6278
  this.recordDisconnectReason(reason);
4872
6279
  }
4873
6280
  });
6281
+ this.subscriptionManager = new SubscriptionManager({
6282
+ transport: this.transport,
6283
+ logger: this.logger
6284
+ });
4874
6285
  }
4875
6286
  isConnected() {
4876
6287
  return this.connected;
@@ -4879,12 +6290,35 @@ var BandLink = class {
4879
6290
  return this.lastDisconnectReason;
4880
6291
  }
4881
6292
  async connect() {
6293
+ if (this.disconnectFlight.current) {
6294
+ await this.disconnectFlight.current.catch(() => void 0);
6295
+ }
4882
6296
  if (this.connected) {
4883
6297
  return;
4884
6298
  }
6299
+ await this.connectFlight.run(() => {
6300
+ const epoch = this.session.begin();
6301
+ return this.connectSession(epoch);
6302
+ });
6303
+ }
6304
+ async connectSession(epoch) {
6305
+ this.session.reconnectObserverTeardown = this.transport.onReconnected?.(async (snapshot) => {
6306
+ await this.subscriptionManager.reconcileReconnect(snapshot);
6307
+ if (this.session.isStale(epoch)) {
6308
+ this.logger.debug(
6309
+ "Reconnect reconciliation settled after session ended, discarding reconnected event"
6310
+ );
6311
+ return;
6312
+ }
6313
+ this.queueEvent({ type: "reconnected", roomId: null, payload: {} });
6314
+ }) ?? null;
4885
6315
  try {
4886
6316
  await this.transport.connect();
4887
6317
  } catch (error) {
6318
+ this.session.clearReconnectObserver();
6319
+ this.session.deactivate();
6320
+ await this.transport.disconnect().catch(() => void 0);
6321
+ this.subscriptionManager.endSession();
4888
6322
  if (error instanceof WebSocketDisconnectError) {
4889
6323
  if (error.reason.retryable) {
4890
6324
  this.lastDisconnectReason = error.reason;
@@ -4897,14 +6331,21 @@ var BandLink = class {
4897
6331
  this.connected = true;
4898
6332
  }
4899
6333
  async disconnect() {
4900
- if (!this.connected) {
6334
+ await this.disconnectFlight.run(() => this.disconnectSession());
6335
+ }
6336
+ async disconnectSession() {
6337
+ await this.connectFlight.current?.catch(() => void 0);
6338
+ if (!this.session.isActive) {
4901
6339
  return;
4902
6340
  }
4903
- await Promise.allSettled(
4904
- [...this.subscribedRooms].map((roomId) => this.unsubscribeRoom(roomId))
4905
- );
4906
- await this.transport.disconnect();
4907
- this.connected = false;
6341
+ this.session.deactivate();
6342
+ try {
6343
+ await this.transport.disconnect();
6344
+ } finally {
6345
+ this.connected = false;
6346
+ this.session.clearReconnectObserver();
6347
+ this.subscriptionManager.endSession();
6348
+ }
4908
6349
  }
4909
6350
  async runForever(signal) {
4910
6351
  if (this.terminalDisconnectError) {
@@ -4933,7 +6374,7 @@ var BandLink = class {
4933
6374
  this.eventQueue.push(event);
4934
6375
  }
4935
6376
  async subscribeAgentRooms() {
4936
- await this.transport.join((0, import_band_sdk_core5.agentRoomsTopic)(this.agentId), {
6377
+ await this.subscriptionManager.subscribeAgentTopic((0, import_band_sdk_core9.agentRoomsTopic)(this.agentId), {
4937
6378
  room_added: (payload) => {
4938
6379
  const roomId = typeof payload.id === "string" ? payload.id : "";
4939
6380
  this.emit("room_added", payload, roomId);
@@ -4945,30 +6386,13 @@ var BandLink = class {
4945
6386
  });
4946
6387
  }
4947
6388
  async subscribeRoom(roomId) {
4948
- if (this.subscribedRooms.has(roomId)) {
4949
- return;
4950
- }
4951
- await this.joinRoomTopics(roomId);
4952
- this.subscribedRooms.add(roomId);
4953
- }
4954
- async unsubscribeRoom(roomId) {
4955
- if (!this.subscribedRooms.has(roomId)) {
4956
- return;
4957
- }
4958
- const topics = roomTopics(roomId);
4959
- await this.transport.leave(topics.chat);
4960
- await this.transport.leave(topics.participants);
4961
- this.subscribedRooms.delete(roomId);
4962
- }
4963
- async joinRoomTopics(roomId) {
4964
- const topics = roomTopics(roomId);
4965
- await this.transport.join(topics.chat, {
4966
- message_created: (payload) => {
4967
- this.emit("message_created", payload, roomId);
4968
- }
4969
- });
4970
- try {
4971
- await this.transport.join(topics.participants, {
6389
+ await this.subscriptionManager.subscribeRoom(roomId, {
6390
+ chat: {
6391
+ message_created: (payload) => {
6392
+ this.emit("message_created", payload, roomId);
6393
+ }
6394
+ },
6395
+ participants: {
4972
6396
  participant_added: (payload) => {
4973
6397
  this.emit("participant_added", payload, roomId);
4974
6398
  },
@@ -4978,15 +6402,15 @@ var BandLink = class {
4978
6402
  room_deleted: (payload) => {
4979
6403
  this.emit("room_deleted", payload, roomId);
4980
6404
  }
4981
- });
4982
- } catch (error) {
4983
- await this.transport.leave(topics.chat);
4984
- throw error;
4985
- }
6405
+ }
6406
+ });
6407
+ }
6408
+ async unsubscribeRoom(roomId) {
6409
+ await this.subscriptionManager.unsubscribeRoom(roomId);
4986
6410
  }
4987
6411
  async subscribeAgentContacts() {
4988
6412
  assertCapability(this.capabilities, "contacts", "Contacts streaming");
4989
- await this.transport.join((0, import_band_sdk_core5.agentContactsTopic)(this.agentId), {
6413
+ await this.subscriptionManager.subscribeAgentTopic((0, import_band_sdk_core9.agentContactsTopic)(this.agentId), {
4990
6414
  contact_request_received: (payload) => {
4991
6415
  this.emit("contact_request_received", payload, null);
4992
6416
  },
@@ -5002,7 +6426,7 @@ var BandLink = class {
5002
6426
  });
5003
6427
  }
5004
6428
  async unsubscribeAgentContacts() {
5005
- await this.transport.leave((0, import_band_sdk_core5.agentContactsTopic)(this.agentId));
6429
+ await this.subscriptionManager.unsubscribeAgentTopic((0, import_band_sdk_core9.agentContactsTopic)(this.agentId));
5006
6430
  }
5007
6431
  async nextEvent(signal) {
5008
6432
  if (this.terminalDisconnectError) {
@@ -5158,21 +6582,20 @@ var BandLink = class {
5158
6582
  });
5159
6583
  }
5160
6584
  emit(eventType, payload, roomId) {
5161
- const schema = payloadSchemas[eventType];
5162
- const parsed = schema.safeParse(payload);
5163
- if (!parsed.success) {
6585
+ try {
6586
+ const normalizedPayload = (0, import_band_sdk_core9.validateEventPayload)(eventType, payload);
6587
+ this.queueEvent({
6588
+ type: eventType,
6589
+ roomId,
6590
+ payload: normalizedPayload,
6591
+ raw: payload
6592
+ });
6593
+ } catch (error) {
5164
6594
  this.logger.warn(`Invalid ${eventType} payload, dropping event`, {
5165
- error: parsed.error.message,
6595
+ ...validationErrorDetails(error),
5166
6596
  roomId
5167
6597
  });
5168
- return;
5169
6598
  }
5170
- this.queueEvent({
5171
- type: eventType,
5172
- roomId,
5173
- payload: parsed.data,
5174
- raw: payload
5175
- });
5176
6599
  }
5177
6600
  };
5178
6601
 
@@ -5230,6 +6653,7 @@ var DefaultPreprocessor = class {
5230
6653
  // src/runtime/PlatformRuntime.ts
5231
6654
  var MAX_MESSAGE_RETRIES = 4294967295;
5232
6655
  var isValidRetryCount = (value) => Number.isInteger(value) && value >= 0 && value <= MAX_MESSAGE_RETRIES;
6656
+ var START_CLEANUP_TRIGGER = "start-failed";
5233
6657
  var PlatformRuntime = class {
5234
6658
  _agentId;
5235
6659
  _apiKey;
@@ -5246,12 +6670,14 @@ var PlatformRuntime = class {
5246
6670
  _onParticipantRemoved;
5247
6671
  _roomFilter;
5248
6672
  _contextFactory;
6673
+ lifecycle;
5249
6674
  linkInstance;
5250
6675
  initPromise = null;
5251
6676
  runtime;
5252
6677
  contactHandler;
5253
6678
  activeAdapter;
5254
- stopping = false;
6679
+ startGate = new SingleFlight();
6680
+ stopGate = new SingleFlight();
5255
6681
  _agentName = "";
5256
6682
  _agentDescription = "";
5257
6683
  contactsSubscribed = false;
@@ -5277,17 +6703,30 @@ var PlatformRuntime = class {
5277
6703
  this._wsUrl = options.wsUrl;
5278
6704
  this._restUrl = options.restUrl;
5279
6705
  this.linkInstance = options.link;
5280
- this.linkOptions = options.linkOptions;
6706
+ this.linkOptions = {
6707
+ ...options.linkOptions,
6708
+ logger: options.logger ?? options.linkOptions?.logger
6709
+ };
5281
6710
  this.preprocessor = options.preprocessor ?? new DefaultPreprocessor();
5282
6711
  this.sessionConfig = options.sessionConfig;
5283
6712
  this.contactConfig = options.contactConfig;
5284
6713
  this.agentConfig = options.agentConfig;
5285
- this.logger = options.logger ?? new NoopLogger();
6714
+ this.logger = resolveLogger(options.logger);
5286
6715
  this.configuredIdentity = options.identity;
5287
6716
  this._onParticipantAdded = options.onParticipantAdded;
5288
6717
  this._onParticipantRemoved = options.onParticipantRemoved;
5289
6718
  this._roomFilter = options.roomFilter;
5290
6719
  this._contextFactory = options.contextFactory;
6720
+ this.lifecycle = new LifecycleTracker({ status: "not_started" }, {
6721
+ owner: "PlatformRuntime",
6722
+ logContext: { agentId: this._agentId },
6723
+ logger: this.logger,
6724
+ isLegalTransition: isLegalRuntimeTransition
6725
+ });
6726
+ }
6727
+ /** Current lifecycle state of this runtime. */
6728
+ get state() {
6729
+ return this.lifecycle.state;
5291
6730
  }
5292
6731
  get link() {
5293
6732
  if (!this.linkInstance) {
@@ -5307,6 +6746,14 @@ var PlatformRuntime = class {
5307
6746
  get contactConfiguration() {
5308
6747
  return this.contactConfig;
5309
6748
  }
6749
+ /**
6750
+ * Whether the contacts channel subscription is currently held.
6751
+ *
6752
+ * @deprecated Read {@link PlatformRuntime.state} for lifecycle questions: this
6753
+ * flag is only ever `true` while the runtime is running and is cleared on
6754
+ * teardown, so it conflates "contacts are subscribed" with "the runtime is
6755
+ * alive".
6756
+ */
5310
6757
  get isContactsSubscribed() {
5311
6758
  return this.contactsSubscribed;
5312
6759
  }
@@ -5329,8 +6776,7 @@ var PlatformRuntime = class {
5329
6776
  agentId: this._agentId,
5330
6777
  apiKey: this._apiKey,
5331
6778
  wsUrl: this._wsUrl,
5332
- restUrl: this._restUrl,
5333
- logger: this.logger
6779
+ restUrl: this._restUrl
5334
6780
  });
5335
6781
  }
5336
6782
  if (this.configuredIdentity) {
@@ -5342,11 +6788,49 @@ var PlatformRuntime = class {
5342
6788
  this._agentName = me.name;
5343
6789
  this._agentDescription = me.description ?? "";
5344
6790
  }
6791
+ /**
6792
+ * Connect the adapter to the platform.
6793
+ *
6794
+ * Repeated or concurrent calls join the in-flight start. Calling `start()`
6795
+ * while a `stop()` is still in flight rejects with a {@link RuntimeStateError}.
6796
+ * A successful start also re-arms teardown, so a runtime whose previous
6797
+ * `stop()` failed can be shut down properly on the next attempt.
6798
+ */
5345
6799
  async start(adapter) {
6800
+ await startWithGate({
6801
+ lifecycle: this.lifecycle,
6802
+ startGate: this.startGate,
6803
+ stopGate: this.stopGate,
6804
+ ownerName: "PlatformRuntime",
6805
+ runStart: () => this.runStart(adapter)
6806
+ });
6807
+ }
6808
+ async runStart(adapter) {
6809
+ const startState = this.lifecycle.state;
6810
+ try {
6811
+ await this.doStart(adapter, startState);
6812
+ } catch (error) {
6813
+ if (this.lifecycle.isCurrent(startState)) {
6814
+ this.lifecycle.fail(error, START_CLEANUP_TRIGGER);
6815
+ }
6816
+ throw error;
6817
+ }
6818
+ if (this.lifecycle.isCurrent(startState)) {
6819
+ this.lifecycle.transition({ status: "running" }, "started");
6820
+ }
6821
+ }
6822
+ async doStart(adapter, startState) {
6823
+ const assertNotSuperseded = () => {
6824
+ if (!this.lifecycle.isCurrent(startState)) {
6825
+ throw new RuntimeStateError("PlatformRuntime start was superseded by stop()");
6826
+ }
6827
+ };
5346
6828
  await this.initialize();
5347
- await adapter.onStarted(this._agentName, this._agentDescription);
6829
+ assertNotSuperseded();
5348
6830
  this.activeAdapter = adapter;
5349
6831
  try {
6832
+ await adapter.onStarted(this._agentName, this._agentDescription);
6833
+ assertNotSuperseded();
5350
6834
  this.contactHandler = new ContactEventHandler({
5351
6835
  config: this.contactConfig ?? { strategy: "disabled" },
5352
6836
  rest: this.link.rest,
@@ -5360,7 +6844,18 @@ var PlatformRuntime = class {
5360
6844
  onHubEvent: async (roomId, event) => {
5361
6845
  const runtime = this.runtime;
5362
6846
  if (!runtime) return;
5363
- await runtime.enqueueEvent(roomId, event);
6847
+ try {
6848
+ await runtime.enqueueEvent(roomId, event);
6849
+ } catch (error) {
6850
+ if (!(error instanceof RuntimeStateError)) {
6851
+ throw error;
6852
+ }
6853
+ this.logger.error("Dropped contact hub event for a stopped room execution", {
6854
+ roomId,
6855
+ eventType: event.type,
6856
+ error: error.message
6857
+ });
6858
+ }
5364
6859
  },
5365
6860
  onHubInit: async (roomId, systemPrompt) => {
5366
6861
  const runtime = this.runtime;
@@ -5383,10 +6878,14 @@ var PlatformRuntime = class {
5383
6878
  contextFactory: this._contextFactory
5384
6879
  });
5385
6880
  await this.runtime.start();
6881
+ assertNotSuperseded();
5386
6882
  this.contactsSubscribed = Boolean(this.link.capabilities.contacts);
5387
6883
  } catch (error) {
6884
+ if (!this.lifecycle.isCurrent(startState)) {
6885
+ throw error;
6886
+ }
5388
6887
  try {
5389
- await this.stop();
6888
+ await this.beginStop(void 0, START_CLEANUP_TRIGGER);
5390
6889
  } catch (stopError) {
5391
6890
  throw new AggregateError(
5392
6891
  [error, stopError],
@@ -5396,49 +6895,63 @@ var PlatformRuntime = class {
5396
6895
  throw error;
5397
6896
  }
5398
6897
  }
6898
+ /**
6899
+ * Tear the runtime down and release the platform connection.
6900
+ *
6901
+ * A concurrent second call joins the in-flight teardown and mirrors its
6902
+ * outcome — including rejecting with the *same* `Error` instance — rather than
6903
+ * reporting a graceful shutdown it did not perform. A failed teardown does not
6904
+ * disable future ones: a subsequent `start()` re-arms `stop()`.
6905
+ */
5399
6906
  async stop(timeoutMs) {
5400
- if (this.stopping) {
5401
- return true;
5402
- }
6907
+ return await this.beginStop(timeoutMs, "stop");
6908
+ }
6909
+ async beginStop(timeoutMs, trigger) {
6910
+ return await this.stopGate.run(() => this.runStop(timeoutMs, trigger));
6911
+ }
6912
+ async runStop(timeoutMs, trigger) {
6913
+ this.startGate.reset();
5403
6914
  const runtime = this.runtime;
5404
6915
  const adapter = this.activeAdapter;
5405
6916
  if (!runtime && !adapter) {
6917
+ if (this.lifecycle.is("failed")) {
6918
+ this.logger.debug("PlatformRuntime stop is resurfacing the recorded start failure", { error: this.lifecycle.state.error });
6919
+ throw this.lifecycle.state.error;
6920
+ }
6921
+ if (this.lifecycle.is("starting")) {
6922
+ this.lifecycle.transition({ status: "stopped" }, trigger);
6923
+ }
5406
6924
  return true;
5407
6925
  }
5408
- this.stopping = true;
6926
+ this.lifecycle.transition({ status: "stopping" }, trigger);
5409
6927
  this.runtime = void 0;
5410
6928
  this.contactHandler = void 0;
5411
6929
  this.contactsSubscribed = false;
5412
6930
  this.activeAdapter = void 0;
5413
6931
  let graceful = true;
5414
- let runtimeError = null;
6932
+ const errors = [];
5415
6933
  if (runtime) {
5416
- try {
6934
+ await isolateTeardown(errors, async () => {
5417
6935
  graceful = await runtime.stop(timeoutMs);
5418
- } catch (error) {
5419
- runtimeError = error;
5420
- }
5421
- }
5422
- try {
5423
- await adapter?.onRuntimeStop?.();
5424
- } catch (error) {
5425
- if (runtimeError) {
5426
- throw new AggregateError(
5427
- [runtimeError, error],
5428
- "PlatformRuntime stop failed and adapter cleanup also failed"
5429
- );
5430
- }
5431
- throw error;
6936
+ });
5432
6937
  }
5433
- if (runtimeError) {
5434
- throw runtimeError instanceof Error ? runtimeError : new Error(String(runtimeError));
6938
+ await isolateTeardown(errors, () => Promise.resolve(adapter?.onRuntimeStop?.()));
6939
+ if (errors.length > 0) {
6940
+ throw this.recordStopFailure(
6941
+ combineTeardownErrors(errors, "PlatformRuntime stop failed and adapter cleanup also failed")
6942
+ );
5435
6943
  }
5436
- this.stopping = false;
6944
+ this.lifecycle.transition({ status: "stopped" }, "stopped");
5437
6945
  return graceful;
5438
6946
  }
5439
6947
  async [Symbol.asyncDispose]() {
5440
6948
  await this.stop();
5441
6949
  }
6950
+ recordStopFailure(error) {
6951
+ const failure = toLifecycleError(error);
6952
+ this.lifecycle.fail(failure, "stop-failed");
6953
+ return failure;
6954
+ }
5442
6955
  async runForever() {
5443
6956
  if (!this.runtime) {
5444
6957
  throw new RuntimeStateError("Runtime not started");
@@ -5490,7 +7003,7 @@ var PlatformRuntime = class {
5490
7003
  };
5491
7004
 
5492
7005
  // src/runtime/index.ts
5493
- var import_band_sdk_core6 = require("@band-ai/band-sdk-core");
7006
+ var import_band_sdk_core10 = require("@band-ai/band-sdk-core");
5494
7007
 
5495
7008
  // src/runtime/prompts/base.ts
5496
7009
  var ENVIRONMENT_SECTION = `
@@ -5590,6 +7103,7 @@ function longTermPattern(label, type, segment, scope) {
5590
7103
  };
5591
7104
  }
5592
7105
  var COMMON_MEMORY_PATTERNS = [
7106
+ longTermPattern("Private notes for this agent only", MEMORY_TYPE.semantic, MEMORY_SEGMENT.guideline, MEMORY_STORE_SCOPE.agent),
5593
7107
  longTermPattern("Facts learned about a specific agent/entity", MEMORY_TYPE.semantic, MEMORY_SEGMENT.agent, MEMORY_STORE_SCOPE.subject),
5594
7108
  longTermPattern("Events involving a specific person/agent", MEMORY_TYPE.episodic, MEMORY_SEGMENT.agent, MEMORY_STORE_SCOPE.subject),
5595
7109
  longTermPattern("A user's preferences or profile info", MEMORY_TYPE.semantic, MEMORY_SEGMENT.user, MEMORY_STORE_SCOPE.subject),
@@ -5602,7 +7116,9 @@ var MEMORY_COMMON_PATTERNS = [
5602
7116
  "Common patterns:",
5603
7117
  ...COMMON_MEMORY_PATTERNS.map(renderMemoryPattern)
5604
7118
  ].join("\n");
5605
- var MEMORY_SCOPE_GUIDANCE = `Prefer \`scope="${MEMORY_STORE_SCOPE.subject}"\` whenever the memory is about a specific person or agent, so it
7119
+ var MEMORY_SCOPE_GUIDANCE = `Use \`scope="${MEMORY_STORE_SCOPE.agent}"\` for information private to this agent that is not about an
7120
+ identified subject. Omitting \`scope\` and \`subject_id\` on store defaults to agent scope on the platform.
7121
+ Prefer \`scope="${MEMORY_STORE_SCOPE.subject}"\` whenever the memory is about a specific person or agent, so it
5606
7122
  stays attached to that subject rather than leaking org-wide. Storing with \`scope="${MEMORY_STORE_SCOPE.subject}"\` requires a
5607
7123
  real \`subject_id\` UUID, so resolve it first via \`band_lookup_peers\` or the participant list.
5608
7124
  Reserve \`scope="${MEMORY_STORE_SCOPE.organization}"\` for knowledge that is genuinely shared across the whole organization and