@band-ai/sdk 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +54 -1
  2. package/dist/{ClaudeSDKAdapter-CXud2DBE.d.ts → CopilotACPAdapter-B_AVyAl6.d.ts} +249 -19
  3. package/dist/{ClaudeSDKAdapter-Cx6zhSaG.d.cts → CopilotACPAdapter-Clid9xGR.d.cts} +249 -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 +2257 -969
  9. package/dist/adapters.d.cts +21 -132
  10. package/dist/adapters.d.ts +21 -132
  11. package/dist/adapters.js +26 -1922
  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-MQBBWYRP.js → chunk-UFO6XMCV.js} +3404 -572
  29. package/dist/{chunk-WA7CHXUS.js → chunk-UL3Y5C4J.js} +2 -2
  30. package/dist/{chunk-WL2HNJZ6.js → chunk-VOMJRQOM.js} +3 -3
  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 +5289 -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
@@ -4,21 +4,22 @@ import {
4
4
  SYNTHETIC_CONTACT_EVENTS_SENDER_NAME,
5
5
  SYNTHETIC_SENDER_TYPE,
6
6
  buildParticipantsMessage,
7
+ createDeferred,
7
8
  toParticipantRecord,
8
9
  toParticipantRecordFromRest
9
- } from "./chunk-OH6VVVKT.js";
10
+ } from "./chunk-CBCHTTUY.js";
10
11
  import {
11
12
  CHAT_TOOL_NAMES,
12
13
  MEMORY_LIST_SCOPES,
13
14
  MEMORY_SEGMENTS,
14
15
  MEMORY_STATUSES,
16
+ MEMORY_STORE_SCOPE,
15
17
  MEMORY_STORE_SCOPES,
16
18
  MEMORY_SYSTEMS,
17
19
  MEMORY_TOOL_NAMES,
18
20
  MEMORY_TYPES,
19
21
  TOOL_MODELS,
20
22
  expectedList,
21
- expectedMemoryTypesForSystem,
22
23
  getToolDescription,
23
24
  isMemoryListScope,
24
25
  isMemorySegment,
@@ -28,37 +29,148 @@ import {
28
29
  isMemoryType,
29
30
  isMemoryTypeForSystem,
30
31
  memoryTypeForSystemError
31
- } from "./chunk-MQY74R7D.js";
32
- import {
33
- CHAT_EVENT_TYPES,
34
- assertChatEventType
35
- } from "./chunk-F63TPOI3.js";
32
+ } from "./chunk-SHZBLLDQ.js";
36
33
  import {
37
34
  WebSocketDisconnectError,
38
35
  genericCloseReason,
39
36
  parseSupersedeDisconnectReason,
40
37
  parseUpgradeDisconnectReason
41
- } from "./chunk-US226AS4.js";
38
+ } from "./chunk-UENIC5FJ.js";
42
39
  import {
43
40
  DEFAULT_REQUEST_OPTIONS,
44
41
  FernRestAdapter,
45
42
  fetchPaginated
46
- } from "./chunk-W5IAI6Z4.js";
43
+ } from "./chunk-OVXVZUPO.js";
44
+ import {
45
+ asErrorMessage
46
+ } from "./chunk-6BE2EG5P.js";
47
47
  import {
48
+ CHAT_EVENT_TYPES,
48
49
  DEFAULT_AGENT_TOOLS_CAPABILITIES,
50
+ assertChatEventType,
49
51
  createToolExecutorError,
50
52
  isStructuredToolFailure,
51
- isToolExecutorError
52
- } from "./chunk-JADCAMZY.js";
53
+ isToolExecutorError,
54
+ sendFailureViaEvent
55
+ } from "./chunk-TGDDFBYS.js";
53
56
  import {
54
- NoopLogger
55
- } from "./chunk-OD2G5LFQ.js";
57
+ resolveLogger
58
+ } from "./chunk-ZYOXI4JL.js";
56
59
  import {
60
+ RecoverableTurnError,
57
61
  RuntimeStateError,
58
62
  TransportError,
59
63
  UnsupportedFeatureError,
60
64
  ValidationError
61
- } from "./chunk-JY75VSQK.js";
65
+ } from "./chunk-4OVNFDD7.js";
66
+
67
+ // src/core/singleFlight.ts
68
+ var SingleFlight = class {
69
+ pending = null;
70
+ /** The currently in-flight operation's promise, if any is running. */
71
+ get current() {
72
+ return this.pending;
73
+ }
74
+ run(start) {
75
+ if (!this.pending) {
76
+ const promise = start();
77
+ this.pending = promise;
78
+ const clear = () => {
79
+ if (this.pending === promise) {
80
+ this.pending = null;
81
+ }
82
+ };
83
+ void promise.then(clear, clear);
84
+ }
85
+ return this.pending;
86
+ }
87
+ };
88
+ var KeyedSingleFlight = class {
89
+ flights = /* @__PURE__ */ new Map();
90
+ /** The currently in-flight operation's promise for `key`, if any is running. */
91
+ current(key) {
92
+ return this.flights.get(key)?.current ?? null;
93
+ }
94
+ run(key, start) {
95
+ let flight = this.flights.get(key);
96
+ if (!flight) {
97
+ flight = new SingleFlight();
98
+ this.flights.set(key, flight);
99
+ }
100
+ const promise = flight.run(start);
101
+ const forget = () => {
102
+ if (this.flights.get(key) === flight && !flight.current) {
103
+ this.flights.delete(key);
104
+ }
105
+ };
106
+ void promise.then(forget, forget);
107
+ return promise;
108
+ }
109
+ /** Drops every key without waiting for its in-flight operation to settle. */
110
+ clear() {
111
+ this.flights.clear();
112
+ }
113
+ };
114
+ var Serializer = class {
115
+ tail = Promise.resolve();
116
+ run(body) {
117
+ const run = this.tail.then(body, body);
118
+ this.tail = run.then(
119
+ () => void 0,
120
+ () => void 0
121
+ );
122
+ return run;
123
+ }
124
+ };
125
+
126
+ // src/core/epoch.ts
127
+ var Epoch = class {
128
+ value = 0;
129
+ get current() {
130
+ return this.value;
131
+ }
132
+ /** Advances to the next generation and returns it. */
133
+ bump() {
134
+ this.value += 1;
135
+ return this.value;
136
+ }
137
+ isStale(captured) {
138
+ return captured !== this.value;
139
+ }
140
+ };
141
+
142
+ // src/platform/Session.ts
143
+ var Session = class {
144
+ epoch = new Epoch();
145
+ active = false;
146
+ /**
147
+ * Teardown for whatever reconnect observer this session registered on the
148
+ * transport — a plain field since a caller just assigns whatever
149
+ * `onReconnected()` handed back (or `null` if the transport has none).
150
+ */
151
+ reconnectObserverTeardown = null;
152
+ get isActive() {
153
+ return this.active;
154
+ }
155
+ isStale(epoch) {
156
+ return this.epoch.isStale(epoch);
157
+ }
158
+ /** Starts a new session and returns its epoch. */
159
+ begin() {
160
+ const epoch = this.epoch.bump();
161
+ this.active = true;
162
+ return epoch;
163
+ }
164
+ /** Marks the session inactive and advances the epoch, so any work still in flight for it is now stale. */
165
+ deactivate() {
166
+ this.active = false;
167
+ this.epoch.bump();
168
+ }
169
+ clearReconnectObserver() {
170
+ this.reconnectObserverTeardown?.();
171
+ this.reconnectObserverTeardown = null;
172
+ }
173
+ };
62
174
 
63
175
  // src/contracts/capabilities.ts
64
176
  var CAPABILITY_LABELS = {
@@ -72,104 +184,213 @@ function assertCapability(capabilities, capability, label = CAPABILITY_LABELS[ca
72
184
  }
73
185
  }
74
186
 
75
- // src/platform/streaming/payloadSchemas.ts
76
- import { z } from "zod";
77
- var mentionSchema = z.object({
78
- id: z.string(),
79
- handle: z.string().nullish(),
80
- name: z.string().nullish(),
81
- username: z.string().nullish()
82
- }).passthrough();
83
- var messageMetadataSchema = z.object({
84
- mentions: z.array(mentionSchema).nullish()
85
- }).passthrough();
86
- var messageCreatedPayloadSchema = z.object({
87
- id: z.string(),
88
- content: z.string(),
89
- message_type: z.string(),
90
- metadata: messageMetadataSchema.nullish(),
91
- sender_id: z.string(),
92
- sender_type: z.string(),
93
- sender_name: z.string().nullish(),
94
- chat_room_id: z.string().nullish(),
95
- inserted_at: z.string(),
96
- updated_at: z.string()
97
- }).passthrough();
98
- var roomOwnerSchema = z.object({
99
- id: z.string(),
100
- name: z.string(),
101
- type: z.string()
102
- }).passthrough();
103
- var roomAddedPayloadSchema = z.object({
104
- id: z.string(),
105
- title: z.string().nullish(),
106
- task_id: z.string().nullish(),
107
- inserted_at: z.string().nullish(),
108
- updated_at: z.string().nullish(),
109
- owner: roomOwnerSchema.nullish(),
110
- status: z.string().nullish(),
111
- type: z.string().nullish(),
112
- created_at: z.string().nullish(),
113
- participant_role: z.string().nullish()
114
- }).passthrough();
115
- var roomRemovedPayloadSchema = z.object({
116
- id: z.string(),
117
- status: z.string(),
118
- type: z.string(),
119
- title: z.string(),
120
- removed_at: z.string()
121
- }).passthrough();
122
- var participantAddedPayloadSchema = z.object({
123
- id: z.string(),
124
- name: z.string(),
125
- type: z.string(),
126
- handle: z.string().nullish()
127
- }).passthrough();
128
- var participantRemovedPayloadSchema = z.object({
129
- id: z.string()
130
- }).passthrough();
131
- var roomDeletedPayloadSchema = z.object({
132
- id: z.string()
133
- }).passthrough();
134
- var contactRequestReceivedPayloadSchema = z.object({
135
- id: z.string(),
136
- from_handle: z.string(),
137
- from_name: z.string(),
138
- message: z.string().nullish(),
139
- status: z.string(),
140
- inserted_at: z.string()
141
- }).passthrough();
142
- var contactRequestUpdatedPayloadSchema = z.object({
143
- id: z.string(),
144
- status: z.string()
145
- }).passthrough();
146
- var contactAddedPayloadSchema = z.object({
147
- id: z.string(),
148
- handle: z.string(),
149
- name: z.string(),
150
- type: z.string(),
151
- description: z.string().nullish(),
152
- is_external: z.boolean().nullish(),
153
- inserted_at: z.string()
154
- }).passthrough();
155
- var contactRemovedPayloadSchema = z.object({
156
- id: z.string()
157
- }).passthrough();
158
- var payloadSchemas = {
159
- message_created: messageCreatedPayloadSchema,
160
- room_added: roomAddedPayloadSchema,
161
- room_removed: roomRemovedPayloadSchema,
162
- participant_added: participantAddedPayloadSchema,
163
- participant_removed: participantRemovedPayloadSchema,
164
- room_deleted: roomDeletedPayloadSchema,
165
- contact_request_received: contactRequestReceivedPayloadSchema,
166
- contact_request_updated: contactRequestUpdatedPayloadSchema,
167
- contact_added: contactAddedPayloadSchema,
168
- contact_removed: contactRemovedPayloadSchema
169
- };
170
-
171
187
  // src/platform/streaming/PhoenixChannelsTransport.ts
172
- import { Socket } from "phoenix";
188
+ import { Socket as Socket2 } from "phoenix";
189
+
190
+ // src/core/teardown.ts
191
+ async function isolateTeardown(errors, step) {
192
+ try {
193
+ await step();
194
+ } catch (error) {
195
+ errors.push(error);
196
+ }
197
+ }
198
+ function combineTeardownErrors(errors, message) {
199
+ const distinct = [...new Set(errors)];
200
+ return distinct.length === 1 ? distinct[0] : new AggregateError(distinct, message);
201
+ }
202
+
203
+ // src/platform/streaming/ChannelRegistry.ts
204
+ import "phoenix";
205
+ function supersededJoinError(topic) {
206
+ return new TransportError(`Join superseded by transport disconnect for topic ${topic}`);
207
+ }
208
+ var ChannelRegistry = class {
209
+ constructor(socket, epoch, logger, hooks) {
210
+ this.socket = socket;
211
+ this.epoch = epoch;
212
+ this.logger = logger;
213
+ this.hooks = hooks;
214
+ }
215
+ channels = /* @__PURE__ */ new Map();
216
+ // A join's Channel and handler bindings, tracked from the moment doJoin
217
+ // creates them — before the join Push settles — so a teardown mid-join can
218
+ // find and detach it too, not only joins already promoted into `channels`.
219
+ // Left untracked here, the underlying Phoenix Channel would survive
220
+ // teardown unnoticed, keep its handlers bound, and could later be
221
+ // resurrected by Phoenix's own reconnect machinery, redelivering live
222
+ // events with no dedup anywhere upstream.
223
+ pendingChannels = /* @__PURE__ */ new Map();
224
+ joinFlights = new KeyedSingleFlight();
225
+ leaveFlights = new KeyedSingleFlight();
226
+ topics() {
227
+ return this.channels.keys();
228
+ }
229
+ isJoined(topic) {
230
+ return this.channels.has(topic);
231
+ }
232
+ /**
233
+ * A promise for `topic` if it's already joined or has a join in flight,
234
+ * without starting a new one. A topic mid-leave is never reported as
235
+ * already joined — `channels` still holds it until the leave's Push
236
+ * settles, but its handlers are already unbound and the channel is about
237
+ * to be removed, so treating that window as "joined" would hand the
238
+ * caller a promise that resolves into a channel already gone.
239
+ */
240
+ existingJoin(topic) {
241
+ if (this.channels.has(topic) && !this.leaveFlights.current(topic)) {
242
+ return Promise.resolve();
243
+ }
244
+ return this.joinFlights.current(topic) ?? void 0;
245
+ }
246
+ /**
247
+ * Starts a new join for `topic`. Callers check `existingJoin()` first.
248
+ * Waits out a leave already in flight for the same topic before starting,
249
+ * so the new join's channel is never raced by the old one's teardown.
250
+ */
251
+ async join(topic, handlers) {
252
+ const pendingLeave = this.leaveFlights.current(topic);
253
+ if (pendingLeave) {
254
+ await pendingLeave.catch(() => void 0);
255
+ }
256
+ return this.joinFlights.run(topic, () => this.doJoin(topic, handlers));
257
+ }
258
+ async doJoin(topic, handlers) {
259
+ const epoch = this.epoch.current;
260
+ const channel = this.socket.channel(topic, {});
261
+ const refs = [];
262
+ for (const [event, handler] of Object.entries(handlers)) {
263
+ const ref = channel.on(event, this.hooks.wrapHandler(topic, event, handler));
264
+ refs.push([event, ref]);
265
+ }
266
+ const joinPush = channel.join();
267
+ try {
268
+ await new Promise((resolve, reject) => {
269
+ this.pendingChannels.set(topic, { channel, refs, reject });
270
+ joinPush.receive("ok", () => {
271
+ this.hooks.onJoinSettled(topic, true);
272
+ resolve();
273
+ }).receive("error", (error) => {
274
+ this.hooks.onJoinSettled(topic, false);
275
+ reject(new TransportError(`Failed to join topic ${topic}`, error));
276
+ }).receive("timeout", () => {
277
+ this.hooks.onJoinSettled(topic, false);
278
+ reject(new TransportError(`Timeout joining topic ${topic}`));
279
+ });
280
+ });
281
+ } catch (error) {
282
+ const pending2 = this.forgetPendingChannel(topic, channel);
283
+ if (pending2) {
284
+ this.abandonChannel(topic, pending2);
285
+ }
286
+ throw error;
287
+ }
288
+ const pending = this.forgetPendingChannel(topic, channel);
289
+ if (!pending || this.epoch.isStale(epoch)) {
290
+ if (pending) {
291
+ this.abandonChannel(topic, pending);
292
+ }
293
+ this.logger.debug("Join superseded by transport disconnect/reconnect", { topic });
294
+ throw supersededJoinError(topic);
295
+ }
296
+ this.channels.set(topic, { channel, refs });
297
+ this.logger.debug("Joined topic", { topic });
298
+ }
299
+ /**
300
+ * Removes and returns `topic`'s pendingChannels entry only if it still
301
+ * points at `channel`. A topic-keyed delete without this identity check
302
+ * can drop a *different*, still-genuinely-pending join for the same topic
303
+ * — reachable because a teardown clears `joinFlights`, so a later join for
304
+ * a topic whose earlier join is still unsettled is possible, and that
305
+ * earlier join's eventual (stale) settlement must not touch a slot it no
306
+ * longer owns.
307
+ */
308
+ forgetPendingChannel(topic, channel) {
309
+ const pending = this.pendingChannels.get(topic);
310
+ if (pending?.channel !== channel) {
311
+ return void 0;
312
+ }
313
+ this.pendingChannels.delete(topic);
314
+ return pending;
315
+ }
316
+ /**
317
+ * Detaches a join's channel and — via `pending.reject` — settles its
318
+ * still-outstanding join promise directly, so abandoning it can never
319
+ * leave `doJoin()`'s awaiter hanging on a Phoenix reply that a `leave()`
320
+ * just made unroutable.
321
+ */
322
+ abandonChannel(topic, pending) {
323
+ for (const [event, ref] of pending.refs) {
324
+ pending.channel.off(event, ref);
325
+ }
326
+ pending.channel.leave();
327
+ removeSocketChannel(this.socket, pending.channel);
328
+ pending.reject(supersededJoinError(topic));
329
+ }
330
+ async leave(topic) {
331
+ const pendingLeave = this.leaveFlights.current(topic);
332
+ if (pendingLeave) {
333
+ return pendingLeave;
334
+ }
335
+ const tracked = this.channels.get(topic);
336
+ if (!tracked) {
337
+ return;
338
+ }
339
+ return this.leaveFlights.run(topic, () => this.doLeave(topic, tracked));
340
+ }
341
+ async doLeave(topic, tracked) {
342
+ const { channel, refs } = tracked;
343
+ for (const [event, ref] of refs) {
344
+ channel.off(event, ref);
345
+ }
346
+ await new Promise((resolve, reject) => {
347
+ channel.leave().receive("ok", () => resolve()).receive(
348
+ "error",
349
+ (error) => reject(new TransportError(`Failed to leave topic ${topic}`, error))
350
+ ).receive(
351
+ "timeout",
352
+ () => reject(new TransportError(`Timeout leaving topic ${topic}`))
353
+ );
354
+ });
355
+ this.channels.delete(topic);
356
+ this.hooks.onLeft(topic);
357
+ this.logger.debug("Left topic", { topic });
358
+ }
359
+ /**
360
+ * Attempts a graceful leave for every currently joined topic. A leave that
361
+ * fails leaves its channel registered until `forceTeardown()` sweeps it.
362
+ * Returns the rejection reasons of any leaves that failed.
363
+ */
364
+ async leaveAll() {
365
+ const topics = [...this.channels.keys()];
366
+ const results = await Promise.allSettled(topics.map((topic) => this.leave(topic)));
367
+ return results.filter((result) => result.status === "rejected").map((result) => result.reason);
368
+ }
369
+ /**
370
+ * Forcibly detaches anything still registered — a leave that failed above,
371
+ * or a join still in flight — and clears all coalescing state. Call after
372
+ * `leaveAll()` has settled.
373
+ */
374
+ forceTeardown() {
375
+ for (const { channel, refs } of this.channels.values()) {
376
+ for (const [event, ref] of refs) {
377
+ channel.off(event, ref);
378
+ }
379
+ removeSocketChannel(this.socket, channel);
380
+ }
381
+ this.channels.clear();
382
+ for (const [topic, pending] of this.pendingChannels) {
383
+ this.abandonChannel(topic, pending);
384
+ }
385
+ this.pendingChannels.clear();
386
+ this.joinFlights.clear();
387
+ this.leaveFlights.clear();
388
+ }
389
+ };
390
+ function removeSocketChannel(socket, channel) {
391
+ const candidate = socket;
392
+ candidate.remove?.(channel);
393
+ }
173
394
 
174
395
  // src/platform/streaming/nodeWebSocketFactory.ts
175
396
  import { WebSocket as NodeWebSocket } from "ws";
@@ -206,19 +427,90 @@ async function readResponseBody(response) {
206
427
  return Buffer.concat(chunks).toString("utf8");
207
428
  }
208
429
 
430
+ // src/platform/streaming/ReconnectGenerationTracker.ts
431
+ var ReconnectGenerationTracker = class {
432
+ constructor(onSettled, onGenerationDropped) {
433
+ this.onSettled = onSettled;
434
+ this.onGenerationDropped = onGenerationDropped;
435
+ }
436
+ generations = /* @__PURE__ */ new Map();
437
+ currentGeneration = 0;
438
+ beginGeneration(topics) {
439
+ const generation = ++this.currentGeneration;
440
+ const attempted = new Set(topics);
441
+ for (const staleGeneration of this.generations.keys()) {
442
+ if (staleGeneration < generation) {
443
+ const stale = this.generations.get(staleGeneration);
444
+ if (stale) {
445
+ this.onGenerationDropped?.(staleGeneration, stale.pending.size);
446
+ this.generations.delete(staleGeneration);
447
+ }
448
+ }
449
+ }
450
+ this.generations.set(generation, { pending: new Set(attempted), attempted, joined: /* @__PURE__ */ new Set() });
451
+ this.maybeFinalize(generation);
452
+ return generation;
453
+ }
454
+ recordSettled(topic, joined) {
455
+ const record = this.generations.get(this.currentGeneration);
456
+ if (!record?.pending.delete(topic)) {
457
+ return;
458
+ }
459
+ if (joined) {
460
+ record.joined.add(topic);
461
+ }
462
+ this.maybeFinalize(this.currentGeneration);
463
+ }
464
+ removeTopic(topic) {
465
+ for (const [generation, record] of this.generations) {
466
+ if (record.pending.delete(topic)) {
467
+ this.maybeFinalize(generation);
468
+ }
469
+ }
470
+ }
471
+ reset() {
472
+ this.generations.clear();
473
+ this.currentGeneration = 0;
474
+ }
475
+ maybeFinalize(generation) {
476
+ const record = this.generations.get(generation);
477
+ if (!record || record.pending.size > 0) {
478
+ return;
479
+ }
480
+ this.generations.delete(generation);
481
+ this.onSettled({ generation, attemptedTopics: record.attempted, joinedTopics: record.joined });
482
+ }
483
+ };
484
+
209
485
  // src/platform/streaming/PhoenixChannelsTransport.ts
210
486
  import { agentControlTopic } from "@band-ai/band-sdk-core";
211
487
  var PhoenixChannelsTransport = class {
212
488
  socket;
213
489
  agentId;
214
- channels = /* @__PURE__ */ new Map();
215
- channelRefs = /* @__PURE__ */ new Map();
216
- pendingJoins = /* @__PURE__ */ new Map();
490
+ registry;
491
+ reconnectObservers = /* @__PURE__ */ new Set();
492
+ // Topics joined with `{ exemptFromBuffering: true }`, recorded here so
493
+ // `wrapHandler` can check by name on every delivered event rather than
494
+ // threading the flag through the channel/handler plumbing.
495
+ bufferingExemptTopics = /* @__PURE__ */ new Set();
496
+ generationTracker = new ReconnectGenerationTracker(
497
+ (snapshot) => this.notifyReconnectObservers(snapshot),
498
+ (generation, pendingTopics) => this.logger.debug("Superseded reconnect generation before it fully settled", {
499
+ generation,
500
+ pendingTopics
501
+ })
502
+ );
503
+ bufferedTopicEvents = [];
504
+ hasOpenedOnce = false;
505
+ epoch = new Epoch();
506
+ bufferingGeneration = null;
507
+ reconnectBarrier = null;
508
+ observerChain = new Serializer();
217
509
  logger;
218
510
  onTerminalDisconnect;
219
511
  onHandlerError;
220
512
  connected = false;
221
- connectPromise = null;
513
+ connectFlight = new SingleFlight();
222
514
  connectResolve = null;
223
515
  connectReject = null;
224
516
  lastDisconnectReason = null;
@@ -227,7 +519,7 @@ var PhoenixChannelsTransport = class {
227
519
  stoppingReconnect = false;
228
520
  suppressNextCloseReason = false;
229
521
  constructor(options) {
230
- this.logger = options.logger ?? new NoopLogger();
522
+ this.logger = resolveLogger(options.logger);
231
523
  this.agentId = options.agentId;
232
524
  this.onTerminalDisconnect = options.onTerminalDisconnect;
233
525
  let wsUrl = options.wsUrl;
@@ -235,7 +527,7 @@ var PhoenixChannelsTransport = class {
235
527
  wsUrl = wsUrl.slice(0, -"/websocket".length);
236
528
  }
237
529
  const reconnectAfterMs = options.reconnectAfterMs ?? ((tries) => [1e3, 2e3, 5e3, 1e4, 3e4][tries - 1] ?? 3e4);
238
- this.socket = new Socket(wsUrl, {
530
+ this.socket = new Socket2(wsUrl, {
239
531
  params: {
240
532
  agent_id: options.agentId,
241
533
  ...options.conflictPolicy ? { on_conflict: options.conflictPolicy } : {}
@@ -249,6 +541,35 @@ var PhoenixChannelsTransport = class {
249
541
  },
250
542
  transport: options.websocketFactory ?? resolveWebSocketFactory(options.apiKey)
251
543
  });
544
+ this.registry = new ChannelRegistry(this.socket, this.epoch, this.logger, {
545
+ wrapHandler: (topic, event, handler) => (payload) => {
546
+ const reportError = (error) => {
547
+ this.logger.error("Unhandled topic handler error", { topic, event, error });
548
+ this.onHandlerError?.(error);
549
+ };
550
+ const deliver = () => {
551
+ try {
552
+ void Promise.resolve(handler(payload)).catch(reportError);
553
+ } catch (error) {
554
+ reportError(error);
555
+ }
556
+ };
557
+ if (this.bufferingGeneration !== null && !this.bufferingExemptTopics.has(topic)) {
558
+ this.bufferedTopicEvents.push({ topic, deliver });
559
+ } else {
560
+ deliver();
561
+ }
562
+ },
563
+ onJoinSettled: (topic, joined) => this.generationTracker.recordSettled(topic, joined),
564
+ onLeft: (topic) => {
565
+ this.generationTracker.removeTopic(topic);
566
+ for (let index = this.bufferedTopicEvents.length - 1; index >= 0; index -= 1) {
567
+ if (this.bufferedTopicEvents[index]?.topic === topic) {
568
+ this.bufferedTopicEvents.splice(index, 1);
569
+ }
570
+ }
571
+ }
572
+ });
252
573
  this.socket.onOpen(() => {
253
574
  void this.handleOpen();
254
575
  });
@@ -285,42 +606,25 @@ var PhoenixChannelsTransport = class {
285
606
  if (this.connected) {
286
607
  return;
287
608
  }
288
- if (!this.connectPromise) {
609
+ await this.connectFlight.run(() => {
289
610
  this.socket.connect();
290
- const pending = this.waitForConnection();
291
- this.connectPromise = pending;
292
- void pending.then(
293
- () => {
294
- if (this.connectPromise === pending) {
295
- this.connectPromise = null;
296
- }
297
- },
298
- () => {
299
- if (this.connectPromise === pending) {
300
- this.connectPromise = null;
301
- }
302
- }
303
- );
304
- }
305
- await this.connectPromise;
611
+ return this.waitForConnection();
612
+ });
306
613
  }
307
614
  async disconnect() {
308
- const results = await Promise.allSettled(
309
- [...this.channels.keys()].map((topic) => this.leave(topic))
310
- );
615
+ this.epoch.bump();
616
+ const failures = await this.registry.leaveAll();
311
617
  this.socket.disconnect();
312
618
  this.connected = false;
313
- const failures = [];
314
- for (const result of results) {
315
- if (result.status === "rejected") {
316
- failures.push(result.reason);
317
- }
318
- }
619
+ this.registry.forceTeardown();
620
+ this.hasOpenedOnce = false;
621
+ this.bufferingGeneration = null;
622
+ this.reconnectBarrier?.resolve();
623
+ this.reconnectBarrier = null;
624
+ this.bufferedTopicEvents.splice(0);
625
+ this.generationTracker.reset();
319
626
  if (failures.length > 0) {
320
- throw new AggregateError(
321
- failures,
322
- "Failed to leave one or more Phoenix topics during disconnect"
323
- );
627
+ throw combineTeardownErrors(failures, "Failed to leave one or more Phoenix topics during disconnect");
324
628
  }
325
629
  }
326
630
  isConnected() {
@@ -329,79 +633,28 @@ var PhoenixChannelsTransport = class {
329
633
  getDisconnectReason() {
330
634
  return this.lastDisconnectReason;
331
635
  }
332
- async join(topic, handlers) {
333
- if (this.channels.has(topic)) {
334
- return;
636
+ async join(topic, handlers, options) {
637
+ if (options?.exemptFromBuffering) {
638
+ this.bufferingExemptTopics.add(topic);
335
639
  }
336
- const pendingJoin = this.pendingJoins.get(topic);
337
- if (pendingJoin) {
338
- return pendingJoin;
640
+ const existing = this.registry.existingJoin(topic);
641
+ if (existing) {
642
+ return existing;
339
643
  }
340
- const joinPromise = this.doJoin(topic, handlers).finally(() => {
341
- this.pendingJoins.delete(topic);
342
- });
343
- this.pendingJoins.set(topic, joinPromise);
344
- return joinPromise;
345
- }
346
- async doJoin(topic, handlers) {
347
- const channel = this.socket.channel(topic, {});
348
- const refs = [];
349
- for (const [event, handler] of Object.entries(handlers)) {
350
- const ref = channel.on(event, (payload) => {
351
- Promise.resolve(handler(payload)).catch((error) => {
352
- this.logger.error("Unhandled topic handler error", {
353
- topic,
354
- event,
355
- error
356
- });
357
- this.onHandlerError?.(error);
358
- });
359
- });
360
- refs.push([event, ref]);
644
+ const epoch = this.epoch.current;
645
+ await this.reconnectBarrier?.promise;
646
+ if (this.epoch.isStale(epoch)) {
647
+ this.logger.debug("Join superseded by transport disconnect/reconnect", { topic });
648
+ throw supersededJoinError(topic);
361
649
  }
362
- try {
363
- await new Promise((resolve, reject) => {
364
- channel.join().receive("ok", () => resolve()).receive(
365
- "error",
366
- (error) => reject(new TransportError(`Failed to join topic ${topic}`, error))
367
- ).receive(
368
- "timeout",
369
- () => reject(new TransportError(`Timeout joining topic ${topic}`))
370
- );
371
- });
372
- } catch (error) {
373
- for (const [event, ref] of refs) {
374
- channel.off(event, ref);
375
- }
376
- channel.leave();
377
- removeSocketChannel(this.socket, channel);
378
- throw error;
650
+ const resumed = this.registry.existingJoin(topic);
651
+ if (resumed) {
652
+ return resumed;
379
653
  }
380
- this.channels.set(topic, channel);
381
- this.channelRefs.set(topic, refs);
382
- this.logger.debug("Joined topic", { topic });
654
+ return this.registry.join(topic, handlers);
383
655
  }
384
656
  async leave(topic) {
385
- const channel = this.channels.get(topic);
386
- if (!channel) {
387
- return;
388
- }
389
- const refs = this.channelRefs.get(topic) ?? [];
390
- for (const [event, ref] of refs) {
391
- channel.off(event, ref);
392
- }
393
- this.channelRefs.delete(topic);
394
- await new Promise((resolve, reject) => {
395
- channel.leave().receive("ok", () => resolve()).receive(
396
- "error",
397
- (error) => reject(new TransportError(`Failed to leave topic ${topic}`, error))
398
- ).receive(
399
- "timeout",
400
- () => reject(new TransportError(`Timeout leaving topic ${topic}`))
401
- );
402
- });
403
- this.channels.delete(topic);
404
- this.logger.debug("Left topic", { topic });
657
+ return this.registry.leave(topic);
405
658
  }
406
659
  async runForever(signal) {
407
660
  if (this.terminalDisconnectError) {
@@ -439,7 +692,21 @@ var PhoenixChannelsTransport = class {
439
692
  );
440
693
  });
441
694
  }
695
+ onReconnected(observer) {
696
+ this.reconnectObservers.add(observer);
697
+ return () => {
698
+ this.reconnectObservers.delete(observer);
699
+ };
700
+ }
442
701
  async handleOpen() {
702
+ if (this.hasOpenedOnce) {
703
+ this.bufferingGeneration = this.generationTracker.beginGeneration(this.registry.topics());
704
+ if (!this.reconnectBarrier) {
705
+ this.reconnectBarrier = createDeferred();
706
+ }
707
+ } else {
708
+ this.hasOpenedOnce = true;
709
+ }
443
710
  try {
444
711
  await this.subscribeAgentControl();
445
712
  } catch (error) {
@@ -461,6 +728,40 @@ var PhoenixChannelsTransport = class {
461
728
  channels: getSocketChannelCount(this.socket)
462
729
  });
463
730
  }
731
+ notifyReconnectObservers(snapshot) {
732
+ const epoch = this.epoch.current;
733
+ const observers = [...this.reconnectObservers];
734
+ void this.observerChain.run(async () => {
735
+ if (this.epoch.isStale(epoch)) {
736
+ return;
737
+ }
738
+ for (const observer of observers) {
739
+ if (this.epoch.isStale(epoch)) {
740
+ return;
741
+ }
742
+ if (!this.reconnectObservers.has(observer)) {
743
+ continue;
744
+ }
745
+ try {
746
+ await observer(snapshot);
747
+ } catch (error) {
748
+ this.logger.error("Reconnect observer failed", {
749
+ generation: snapshot.generation,
750
+ error
751
+ });
752
+ }
753
+ }
754
+ if (!this.epoch.isStale(epoch) && snapshot.generation === this.bufferingGeneration) {
755
+ this.bufferingGeneration = null;
756
+ const events = this.bufferedTopicEvents.splice(0);
757
+ for (const { deliver } of events) {
758
+ deliver();
759
+ }
760
+ this.reconnectBarrier?.resolve();
761
+ this.reconnectBarrier = null;
762
+ }
763
+ });
764
+ }
464
765
  stopReconnectIfNoChannels(options = {}) {
465
766
  if (this.stoppingReconnect || getSocketChannelCount(this.socket) !== 0) {
466
767
  return;
@@ -474,18 +775,23 @@ var PhoenixChannelsTransport = class {
474
775
  if (!this.agentId) {
475
776
  return;
476
777
  }
477
- await this.join(agentControlTopic(this.agentId), {
478
- supersede: (payload) => {
479
- const reason = parseSupersedeDisconnectReason(payload);
480
- if (!reason) {
481
- this.logger.warn("Invalid agent_control supersede payload", {
482
- payload
483
- });
484
- return;
778
+ const topic = agentControlTopic(this.agentId);
779
+ await this.join(
780
+ topic,
781
+ {
782
+ supersede: (payload) => {
783
+ const reason = parseSupersedeDisconnectReason(payload);
784
+ if (!reason) {
785
+ this.logger.warn("Invalid agent_control supersede payload", {
786
+ payload
787
+ });
788
+ return;
789
+ }
790
+ this.recordTerminalDisconnect(reason);
485
791
  }
486
- this.recordTerminalDisconnect(reason);
487
- }
488
- });
792
+ },
793
+ { exemptFromBuffering: true }
794
+ );
489
795
  }
490
796
  recordSocketClose(event) {
491
797
  this.connected = false;
@@ -561,10 +867,6 @@ function unwrapErrorEvent(event) {
561
867
  function isErrorEvent(event) {
562
868
  return typeof event === "object" && event !== null && "error" in event;
563
869
  }
564
- function removeSocketChannel(socket, channel) {
565
- const candidate = socket;
566
- candidate.remove?.(channel);
567
- }
568
870
  function getSocketChannelCount(socket) {
569
871
  const candidate = socket;
570
872
  if (!Array.isArray(candidate.channels)) {
@@ -578,21 +880,413 @@ import { BandClient } from "@band-ai/rest-client";
578
880
  import {
579
881
  agentContactsTopic,
580
882
  agentRoomsTopic,
581
- chatRoomTopic,
582
- roomParticipantsTopic
883
+ validateEventPayload
583
884
  } from "@band-ai/band-sdk-core";
885
+
886
+ // src/platform/SubscriptionManager.ts
887
+ import { SubscriptionTracker } from "@band-ai/band-sdk-core";
888
+
889
+ // src/platform/roomTopics.ts
890
+ import { chatRoomTopic, roomParticipantsTopic } from "@band-ai/band-sdk-core";
891
+ function roomTopics(roomId) {
892
+ return { chat: chatRoomTopic(roomId), participants: roomParticipantsTopic(roomId) };
893
+ }
894
+ function isRejected(result) {
895
+ return result.status === "rejected";
896
+ }
897
+ async function settleRoomLeaves(transport, roomId) {
898
+ const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
899
+ const results = await Promise.allSettled([
900
+ transport.leave(chatTopic),
901
+ transport.leave(participantsTopic)
902
+ ]);
903
+ return results.filter(isRejected);
904
+ }
905
+
906
+ // src/platform/ReconciliationCoordinator.ts
907
+ var ReconciliationCoordinator = class {
908
+ constructor(tracker, transport, logger, epoch) {
909
+ this.tracker = tracker;
910
+ this.transport = transport;
911
+ this.logger = logger;
912
+ this.epoch = epoch;
913
+ }
914
+ roomsNeedingReconciliation = /* @__PURE__ */ new Set();
915
+ agentTopicsNeedingReconciliation = /* @__PURE__ */ new Set();
916
+ reconcileTail = new Serializer();
917
+ lastReconciledGeneration = 0;
918
+ markRoomNeedsReconciliation(roomId) {
919
+ this.roomsNeedingReconciliation.add(roomId);
920
+ }
921
+ markAgentTopicNeedsReconciliation(topic) {
922
+ this.agentTopicsNeedingReconciliation.add(topic);
923
+ }
924
+ /** Called on session end: every pending sweep is moot against a tracker that has itself just been reset. */
925
+ reset() {
926
+ this.roomsNeedingReconciliation.clear();
927
+ this.agentTopicsNeedingReconciliation.clear();
928
+ this.lastReconciledGeneration = 0;
929
+ }
930
+ /** Serialized: each generation's reconciliation completes before the next begins. */
931
+ reconcileReconnect(snapshot) {
932
+ if (snapshot.generation <= this.lastReconciledGeneration) {
933
+ this.logger.debug("Ignoring stale or duplicate reconnect snapshot", {
934
+ generation: snapshot.generation,
935
+ lastReconciledGeneration: this.lastReconciledGeneration
936
+ });
937
+ return Promise.resolve();
938
+ }
939
+ this.lastReconciledGeneration = snapshot.generation;
940
+ const epoch = this.epoch.current;
941
+ this.tracker.onReconnected();
942
+ const work = {
943
+ snapshot,
944
+ epoch,
945
+ roomCandidates: this.tracker.roomRejoinCandidates(),
946
+ agentTopicCandidates: this.tracker.agentTopicRejoinCandidates()
947
+ };
948
+ return this.reconcileTail.run(() => this.runReconcile(work));
949
+ }
950
+ async runReconcile(work) {
951
+ const { snapshot, epoch, roomCandidates, agentTopicCandidates } = work;
952
+ if (this.epoch.isStale(epoch)) {
953
+ this.logger.debug("Reconnect reconciliation settled after session ended, skipping rejoin evaluation", {
954
+ generation: snapshot.generation
955
+ });
956
+ return;
957
+ }
958
+ for (const [roomId, ticket] of roomCandidates) {
959
+ const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
960
+ if (!snapshot.attemptedTopics.has(chatTopic) || !snapshot.attemptedTopics.has(participantsTopic)) {
961
+ continue;
962
+ }
963
+ const present = snapshot.joinedTopics.has(chatTopic) && snapshot.joinedTopics.has(participantsTopic);
964
+ if (present) {
965
+ continue;
966
+ }
967
+ if (this.tracker.markRoomRejoinFailed(roomId, ticket)) {
968
+ this.markRoomNeedsReconciliation(roomId);
969
+ }
970
+ }
971
+ for (const [topic, ticket] of agentTopicCandidates) {
972
+ if (!snapshot.attemptedTopics.has(topic)) {
973
+ continue;
974
+ }
975
+ if (snapshot.joinedTopics.has(topic)) {
976
+ continue;
977
+ }
978
+ if (this.tracker.markAgentTopicRejoinFailed(topic, ticket)) {
979
+ this.markAgentTopicNeedsReconciliation(topic);
980
+ }
981
+ }
982
+ if (this.epoch.isStale(epoch)) {
983
+ this.logger.debug("Reconnect reconciliation settled after session ended, skipping cleanup drain", {
984
+ generation: snapshot.generation
985
+ });
986
+ return;
987
+ }
988
+ await this.drainReconciliation(epoch);
989
+ }
990
+ async drainReconciliation(epoch) {
991
+ const rooms = [...this.roomsNeedingReconciliation];
992
+ const topics = [...this.agentTopicsNeedingReconciliation];
993
+ const [roomLeaveResults, topicLeaveResults] = await Promise.all([
994
+ Promise.allSettled(rooms.map((roomId) => this.leaveRoomTopicsCleanly(roomId))),
995
+ Promise.allSettled(topics.map((topic) => this.transport.leave(topic)))
996
+ ]);
997
+ if (this.epoch.isStale(epoch)) {
998
+ this.logger.debug("Reconnect reconciliation cleanup settled after session ended, leaving tracker acknowledgements pending for next reconnect", {
999
+ rooms: rooms.length,
1000
+ topics: topics.length
1001
+ });
1002
+ return;
1003
+ }
1004
+ this.acknowledgeCleanup({
1005
+ ids: rooms,
1006
+ results: roomLeaveResults,
1007
+ pending: this.roomsNeedingReconciliation,
1008
+ acknowledge: (roomId) => this.tracker.acknowledgeRoomReconciled(roomId),
1009
+ failureMessage: "Room reconciliation cleanup failed, retrying on next reconnect",
1010
+ logContext: (roomId) => ({ roomId })
1011
+ });
1012
+ this.acknowledgeCleanup({
1013
+ ids: topics,
1014
+ results: topicLeaveResults,
1015
+ pending: this.agentTopicsNeedingReconciliation,
1016
+ acknowledge: (topic) => this.tracker.acknowledgeAgentTopicReconciled(topic),
1017
+ failureMessage: "Agent topic reconciliation cleanup failed, retrying on next reconnect",
1018
+ logContext: (topic) => ({ topic })
1019
+ });
1020
+ }
1021
+ acknowledgeCleanup(options) {
1022
+ options.ids.forEach((id, index) => {
1023
+ if (options.results[index]?.status !== "fulfilled") {
1024
+ this.logger.warn(options.failureMessage, options.logContext(id));
1025
+ return;
1026
+ }
1027
+ if (options.acknowledge(id)) {
1028
+ options.pending.delete(id);
1029
+ }
1030
+ });
1031
+ }
1032
+ async leaveRoomTopicsCleanly(roomId) {
1033
+ const failures = await settleRoomLeaves(this.transport, roomId);
1034
+ if (failures.length > 0) {
1035
+ throw new AggregateError(
1036
+ failures.map((failure) => failure.reason),
1037
+ `Failed to clean up room ${roomId} during reconnect reconciliation`
1038
+ );
1039
+ }
1040
+ }
1041
+ };
1042
+
1043
+ // src/platform/SubscriptionManager.ts
1044
+ function roomOperationKey(roomId) {
1045
+ return `room:${roomId}`;
1046
+ }
1047
+ function topicOperationKey(topic) {
1048
+ return `topic:${topic}`;
1049
+ }
1050
+ var SubscriptionManager = class {
1051
+ tracker = new SubscriptionTracker();
1052
+ transport;
1053
+ logger;
1054
+ operations = /* @__PURE__ */ new Map();
1055
+ epoch = new Epoch();
1056
+ reconciliation;
1057
+ constructor(options) {
1058
+ this.transport = options.transport;
1059
+ this.logger = resolveLogger(options.logger);
1060
+ this.reconciliation = new ReconciliationCoordinator(
1061
+ this.tracker,
1062
+ this.transport,
1063
+ this.logger,
1064
+ this.epoch
1065
+ );
1066
+ }
1067
+ subscribeRoom(roomId, handlers) {
1068
+ return this.runOperation(
1069
+ roomOperationKey(roomId),
1070
+ "up",
1071
+ () => this.claimRoomSubscribe(roomId, handlers)
1072
+ );
1073
+ }
1074
+ unsubscribeRoom(roomId) {
1075
+ return this.runOperation(
1076
+ roomOperationKey(roomId),
1077
+ "down",
1078
+ () => this.claimRoomUnsubscribe(roomId)
1079
+ );
1080
+ }
1081
+ subscribeAgentTopic(topic, handlers) {
1082
+ return this.runOperation(
1083
+ topicOperationKey(topic),
1084
+ "up",
1085
+ () => this.claimAgentTopicJoin(topic, handlers)
1086
+ );
1087
+ }
1088
+ unsubscribeAgentTopic(topic) {
1089
+ return this.runOperation(
1090
+ topicOperationKey(topic),
1091
+ "down",
1092
+ () => this.claimAgentTopicLeave(topic)
1093
+ );
1094
+ }
1095
+ /** Serialized: each generation's reconciliation completes before the next begins. */
1096
+ reconcileReconnect(snapshot) {
1097
+ return this.reconciliation.reconcileReconnect(snapshot);
1098
+ }
1099
+ /**
1100
+ * Ends this session: every ticket issued so far goes stale, every in-flight
1101
+ * operation's late completion becomes a no-op, and both reconciliation sets
1102
+ * start empty for the next session. The epoch guards host-side async work
1103
+ * and transport effects that can outlive the core session they started in.
1104
+ */
1105
+ endSession() {
1106
+ this.epoch.bump();
1107
+ this.tracker.endSession();
1108
+ this.operations.clear();
1109
+ this.reconciliation.reset();
1110
+ }
1111
+ leaveOutcome(epoch, succeeded) {
1112
+ return this.epoch.isStale(epoch) ? "unknown" : succeeded ? "left" : "failed";
1113
+ }
1114
+ // ---- generic operation coalescing -------------------------------------
1115
+ runOperation(key, kind, claim) {
1116
+ const existing = this.operations.get(key);
1117
+ if (existing?.kind === kind) {
1118
+ return existing.promise;
1119
+ }
1120
+ const promise = existing ? existing.promise.then(claim, claim) : claim();
1121
+ const operation = { kind, promise };
1122
+ this.operations.set(key, operation);
1123
+ const cleanup = () => {
1124
+ if (this.operations.get(key) === operation) {
1125
+ this.operations.delete(key);
1126
+ }
1127
+ };
1128
+ promise.then(cleanup, cleanup);
1129
+ return promise;
1130
+ }
1131
+ // ---- room subscribe/unsubscribe ----------------------------------------
1132
+ async claimRoomSubscribe(roomId, handlers) {
1133
+ const epoch = this.epoch.current;
1134
+ const ticket = this.tracker.beginRoomSubscribe(roomId);
1135
+ if (ticket === void 0) {
1136
+ return this.settleIdempotentRoomClaim(roomId);
1137
+ }
1138
+ const { chat: chatTopic, participants: participantsTopic } = roomTopics(roomId);
1139
+ try {
1140
+ await this.transport.join(chatTopic, handlers.chat);
1141
+ } catch (error) {
1142
+ if (!this.epoch.isStale(epoch)) {
1143
+ this.tracker.recordChatRoomJoinFailed(roomId, ticket);
1144
+ } else {
1145
+ this.logger.debug("Room chat-topic join settled after session ended, ignoring stale ticket", {
1146
+ roomId
1147
+ });
1148
+ }
1149
+ throw error;
1150
+ }
1151
+ try {
1152
+ await this.transport.join(participantsTopic, handlers.participants);
1153
+ } catch (participantError) {
1154
+ if (this.epoch.isStale(epoch)) {
1155
+ this.logger.debug(
1156
+ "Room participants-topic join settled after session ended, ignoring stale ticket",
1157
+ { roomId }
1158
+ );
1159
+ throw participantError;
1160
+ }
1161
+ let chatRoomLeft = false;
1162
+ let rollbackError;
1163
+ try {
1164
+ await this.transport.leave(chatTopic);
1165
+ chatRoomLeft = true;
1166
+ } catch (error) {
1167
+ rollbackError = error;
1168
+ }
1169
+ const result = this.tracker.recordRoomParticipantsJoinFailed(roomId, ticket, chatRoomLeft);
1170
+ if (result === "rollback_failed") {
1171
+ this.reconciliation.markRoomNeedsReconciliation(roomId);
1172
+ throw new AggregateError(
1173
+ [
1174
+ participantError,
1175
+ rollbackError ?? new TransportError(`Failed to roll back chat_room join for room ${roomId}`)
1176
+ ],
1177
+ `Failed to subscribe to room ${roomId} and roll back its chat_room join`
1178
+ );
1179
+ }
1180
+ throw participantError;
1181
+ }
1182
+ if (!this.epoch.isStale(epoch)) {
1183
+ this.tracker.recordBothRoomTopicsJoined(roomId, ticket);
1184
+ } else {
1185
+ this.logger.debug("Room subscribe settled after session ended, discarding stale success", {
1186
+ roomId
1187
+ });
1188
+ }
1189
+ }
1190
+ /** Resolves or rejects an idempotent no-op claim (the tracker reported no new ticket to act on). */
1191
+ settleIdempotentClaim(matched, describe) {
1192
+ return matched ? Promise.resolve() : Promise.reject(new RuntimeStateError(describe()));
1193
+ }
1194
+ settleIdempotentRoomClaim(roomId) {
1195
+ const status = this.tracker.roomStatus(roomId);
1196
+ return this.settleIdempotentClaim(
1197
+ status === "subscribed",
1198
+ () => `Room ${roomId} cannot be subscribed while it is "${status}"`
1199
+ );
1200
+ }
1201
+ async claimRoomUnsubscribe(roomId) {
1202
+ const epoch = this.epoch.current;
1203
+ const ticket = this.tracker.unsubscribeRoom(roomId);
1204
+ if (ticket === void 0) {
1205
+ return;
1206
+ }
1207
+ const failures = await settleRoomLeaves(this.transport, roomId);
1208
+ const outcome = this.leaveOutcome(epoch, failures.length === 0);
1209
+ if (outcome === "unknown") {
1210
+ this.logger.debug("Room unsubscribe settled after session ended, outcome ambiguous", { roomId });
1211
+ }
1212
+ if (this.tracker.markRoomLeaveComplete(roomId, ticket, outcome) && outcome !== "left") {
1213
+ this.reconciliation.markRoomNeedsReconciliation(roomId);
1214
+ }
1215
+ if (failures.length > 0) {
1216
+ throw new AggregateError(
1217
+ failures.map((failure) => failure.reason),
1218
+ `Failed to fully unsubscribe from room ${roomId}`
1219
+ );
1220
+ }
1221
+ }
1222
+ // ---- agent topic join/leave (agent_rooms, agent_contacts) --------------
1223
+ async claimAgentTopicJoin(topic, handlers) {
1224
+ const epoch = this.epoch.current;
1225
+ const ticket = this.tracker.beginAgentTopicJoin(topic);
1226
+ if (ticket === void 0) {
1227
+ return this.settleIdempotentTopicClaim(topic);
1228
+ }
1229
+ let joined = false;
1230
+ let joinError;
1231
+ try {
1232
+ await this.transport.join(topic, handlers);
1233
+ joined = true;
1234
+ } catch (error) {
1235
+ joinError = error;
1236
+ }
1237
+ if (this.epoch.isStale(epoch)) {
1238
+ this.tracker.recordAgentTopicJoinAmbiguous(topic, ticket);
1239
+ this.logger.debug("Agent topic join settled after session ended, marking ambiguous for reconciliation", {
1240
+ topic
1241
+ });
1242
+ } else {
1243
+ this.tracker.recordAgentTopicJoin(topic, ticket, joined);
1244
+ }
1245
+ if (!joined) {
1246
+ throw joinError;
1247
+ }
1248
+ }
1249
+ settleIdempotentTopicClaim(topic) {
1250
+ const status = this.tracker.agentTopicStatus(topic);
1251
+ return this.settleIdempotentClaim(
1252
+ status === "joined",
1253
+ () => `Topic ${topic} cannot be joined while it is "${status}"`
1254
+ );
1255
+ }
1256
+ async claimAgentTopicLeave(topic) {
1257
+ const epoch = this.epoch.current;
1258
+ const ticket = this.tracker.leaveAgentTopic(topic);
1259
+ if (ticket === void 0) {
1260
+ return;
1261
+ }
1262
+ let left = true;
1263
+ let leaveError;
1264
+ try {
1265
+ await this.transport.leave(topic);
1266
+ } catch (error) {
1267
+ left = false;
1268
+ leaveError = error;
1269
+ }
1270
+ const outcome = this.leaveOutcome(epoch, left);
1271
+ if (outcome === "unknown") {
1272
+ this.logger.debug("Agent topic unsubscribe settled after session ended, outcome ambiguous", { topic });
1273
+ }
1274
+ if (this.tracker.markAgentTopicLeaveComplete(topic, ticket, outcome) && outcome !== "left") {
1275
+ this.reconciliation.markAgentTopicNeedsReconciliation(topic);
1276
+ }
1277
+ if (!left) {
1278
+ throw leaveError;
1279
+ }
1280
+ }
1281
+ };
1282
+
1283
+ // src/platform/BandLink.ts
584
1284
  var DEFAULT_WS_URL = "wss://app.band.ai/api/v1/socket";
585
1285
  function deriveDefaultRestUrl(wsUrl) {
586
1286
  const parsed = new URL(wsUrl);
587
1287
  const protocol = parsed.protocol === "ws:" ? "http:" : "https:";
588
1288
  return `${protocol}//${parsed.host}`;
589
1289
  }
590
- function roomTopics(roomId) {
591
- return {
592
- chat: chatRoomTopic(roomId),
593
- participants: roomParticipantsTopic(roomId)
594
- };
595
- }
596
1290
  function toPlatformMessage(roomId, message) {
597
1291
  return {
598
1292
  id: message.id,
@@ -606,6 +1300,19 @@ function toPlatformMessage(roomId, message) {
606
1300
  createdAt: new Date(message.inserted_at)
607
1301
  };
608
1302
  }
1303
+ function validationErrorDetails(error) {
1304
+ if (typeof error !== "object" || error === null) {
1305
+ return {};
1306
+ }
1307
+ const details = {};
1308
+ if ("issues" in error) {
1309
+ details.issues = error.issues;
1310
+ }
1311
+ if ("traceContext" in error) {
1312
+ details.traceContext = error.traceContext;
1313
+ }
1314
+ return details;
1315
+ }
609
1316
  var BandLink = class {
610
1317
  agentId;
611
1318
  apiKey;
@@ -615,18 +1322,21 @@ var BandLink = class {
615
1322
  capabilities;
616
1323
  logger;
617
1324
  transport;
618
- subscribedRooms = /* @__PURE__ */ new Set();
1325
+ subscriptionManager;
619
1326
  eventQueue = [];
620
1327
  waiters = [];
621
1328
  connected = false;
622
1329
  lastDisconnectReason = null;
623
1330
  terminalDisconnectError = null;
1331
+ connectFlight = new SingleFlight();
1332
+ disconnectFlight = new SingleFlight();
1333
+ session = new Session();
624
1334
  constructor(options) {
625
1335
  this.agentId = options.agentId;
626
1336
  this.apiKey = options.apiKey;
627
1337
  this.wsUrl = options.wsUrl ?? DEFAULT_WS_URL;
628
1338
  this.restUrl = options.restUrl ?? deriveDefaultRestUrl(this.wsUrl);
629
- this.logger = options.logger ?? new NoopLogger();
1339
+ this.logger = resolveLogger(options.logger);
630
1340
  this.capabilities = {
631
1341
  ...DEFAULT_AGENT_TOOLS_CAPABILITIES,
632
1342
  ...options.capabilities
@@ -648,6 +1358,10 @@ var BandLink = class {
648
1358
  this.recordDisconnectReason(reason);
649
1359
  }
650
1360
  });
1361
+ this.subscriptionManager = new SubscriptionManager({
1362
+ transport: this.transport,
1363
+ logger: this.logger
1364
+ });
651
1365
  }
652
1366
  isConnected() {
653
1367
  return this.connected;
@@ -656,12 +1370,35 @@ var BandLink = class {
656
1370
  return this.lastDisconnectReason;
657
1371
  }
658
1372
  async connect() {
1373
+ if (this.disconnectFlight.current) {
1374
+ await this.disconnectFlight.current.catch(() => void 0);
1375
+ }
659
1376
  if (this.connected) {
660
1377
  return;
661
1378
  }
1379
+ await this.connectFlight.run(() => {
1380
+ const epoch = this.session.begin();
1381
+ return this.connectSession(epoch);
1382
+ });
1383
+ }
1384
+ async connectSession(epoch) {
1385
+ this.session.reconnectObserverTeardown = this.transport.onReconnected?.(async (snapshot) => {
1386
+ await this.subscriptionManager.reconcileReconnect(snapshot);
1387
+ if (this.session.isStale(epoch)) {
1388
+ this.logger.debug(
1389
+ "Reconnect reconciliation settled after session ended, discarding reconnected event"
1390
+ );
1391
+ return;
1392
+ }
1393
+ this.queueEvent({ type: "reconnected", roomId: null, payload: {} });
1394
+ }) ?? null;
662
1395
  try {
663
1396
  await this.transport.connect();
664
1397
  } catch (error) {
1398
+ this.session.clearReconnectObserver();
1399
+ this.session.deactivate();
1400
+ await this.transport.disconnect().catch(() => void 0);
1401
+ this.subscriptionManager.endSession();
665
1402
  if (error instanceof WebSocketDisconnectError) {
666
1403
  if (error.reason.retryable) {
667
1404
  this.lastDisconnectReason = error.reason;
@@ -674,14 +1411,21 @@ var BandLink = class {
674
1411
  this.connected = true;
675
1412
  }
676
1413
  async disconnect() {
677
- if (!this.connected) {
1414
+ await this.disconnectFlight.run(() => this.disconnectSession());
1415
+ }
1416
+ async disconnectSession() {
1417
+ await this.connectFlight.current?.catch(() => void 0);
1418
+ if (!this.session.isActive) {
678
1419
  return;
679
1420
  }
680
- await Promise.allSettled(
681
- [...this.subscribedRooms].map((roomId) => this.unsubscribeRoom(roomId))
682
- );
683
- await this.transport.disconnect();
684
- this.connected = false;
1421
+ this.session.deactivate();
1422
+ try {
1423
+ await this.transport.disconnect();
1424
+ } finally {
1425
+ this.connected = false;
1426
+ this.session.clearReconnectObserver();
1427
+ this.subscriptionManager.endSession();
1428
+ }
685
1429
  }
686
1430
  async runForever(signal) {
687
1431
  if (this.terminalDisconnectError) {
@@ -710,42 +1454,25 @@ var BandLink = class {
710
1454
  this.eventQueue.push(event);
711
1455
  }
712
1456
  async subscribeAgentRooms() {
713
- await this.transport.join(agentRoomsTopic(this.agentId), {
1457
+ await this.subscriptionManager.subscribeAgentTopic(agentRoomsTopic(this.agentId), {
714
1458
  room_added: (payload) => {
715
1459
  const roomId = typeof payload.id === "string" ? payload.id : "";
716
- this.emit("room_added", payload, roomId);
717
- },
718
- room_removed: (payload) => {
719
- const roomId = typeof payload.id === "string" ? payload.id : "";
720
- this.emit("room_removed", payload, roomId);
721
- }
722
- });
723
- }
724
- async subscribeRoom(roomId) {
725
- if (this.subscribedRooms.has(roomId)) {
726
- return;
727
- }
728
- await this.joinRoomTopics(roomId);
729
- this.subscribedRooms.add(roomId);
730
- }
731
- async unsubscribeRoom(roomId) {
732
- if (!this.subscribedRooms.has(roomId)) {
733
- return;
734
- }
735
- const topics = roomTopics(roomId);
736
- await this.transport.leave(topics.chat);
737
- await this.transport.leave(topics.participants);
738
- this.subscribedRooms.delete(roomId);
739
- }
740
- async joinRoomTopics(roomId) {
741
- const topics = roomTopics(roomId);
742
- await this.transport.join(topics.chat, {
743
- message_created: (payload) => {
744
- this.emit("message_created", payload, roomId);
1460
+ this.emit("room_added", payload, roomId);
1461
+ },
1462
+ room_removed: (payload) => {
1463
+ const roomId = typeof payload.id === "string" ? payload.id : "";
1464
+ this.emit("room_removed", payload, roomId);
745
1465
  }
746
1466
  });
747
- try {
748
- await this.transport.join(topics.participants, {
1467
+ }
1468
+ async subscribeRoom(roomId) {
1469
+ await this.subscriptionManager.subscribeRoom(roomId, {
1470
+ chat: {
1471
+ message_created: (payload) => {
1472
+ this.emit("message_created", payload, roomId);
1473
+ }
1474
+ },
1475
+ participants: {
749
1476
  participant_added: (payload) => {
750
1477
  this.emit("participant_added", payload, roomId);
751
1478
  },
@@ -755,15 +1482,15 @@ var BandLink = class {
755
1482
  room_deleted: (payload) => {
756
1483
  this.emit("room_deleted", payload, roomId);
757
1484
  }
758
- });
759
- } catch (error) {
760
- await this.transport.leave(topics.chat);
761
- throw error;
762
- }
1485
+ }
1486
+ });
1487
+ }
1488
+ async unsubscribeRoom(roomId) {
1489
+ await this.subscriptionManager.unsubscribeRoom(roomId);
763
1490
  }
764
1491
  async subscribeAgentContacts() {
765
1492
  assertCapability(this.capabilities, "contacts", "Contacts streaming");
766
- await this.transport.join(agentContactsTopic(this.agentId), {
1493
+ await this.subscriptionManager.subscribeAgentTopic(agentContactsTopic(this.agentId), {
767
1494
  contact_request_received: (payload) => {
768
1495
  this.emit("contact_request_received", payload, null);
769
1496
  },
@@ -779,7 +1506,7 @@ var BandLink = class {
779
1506
  });
780
1507
  }
781
1508
  async unsubscribeAgentContacts() {
782
- await this.transport.leave(agentContactsTopic(this.agentId));
1509
+ await this.subscriptionManager.unsubscribeAgentTopic(agentContactsTopic(this.agentId));
783
1510
  }
784
1511
  async nextEvent(signal) {
785
1512
  if (this.terminalDisconnectError) {
@@ -935,21 +1662,265 @@ var BandLink = class {
935
1662
  });
936
1663
  }
937
1664
  emit(eventType, payload, roomId) {
938
- const schema = payloadSchemas[eventType];
939
- const parsed = schema.safeParse(payload);
940
- if (!parsed.success) {
1665
+ try {
1666
+ const normalizedPayload = validateEventPayload(eventType, payload);
1667
+ this.queueEvent({
1668
+ type: eventType,
1669
+ roomId,
1670
+ payload: normalizedPayload,
1671
+ raw: payload
1672
+ });
1673
+ } catch (error) {
941
1674
  this.logger.warn(`Invalid ${eventType} payload, dropping event`, {
942
- error: parsed.error.message,
1675
+ ...validationErrorDetails(error),
943
1676
  roomId
944
1677
  });
1678
+ }
1679
+ }
1680
+ };
1681
+
1682
+ // src/runtime/lifecycle.ts
1683
+ var RUNTIME_LIFECYCLE_TRANSITIONS = {
1684
+ not_started: ["starting"],
1685
+ starting: ["running", "stopping", "stopped", "failed"],
1686
+ running: ["stopping", "failed"],
1687
+ stopping: ["stopped", "failed"],
1688
+ stopped: ["starting"],
1689
+ failed: ["starting", "stopping", "failed"]
1690
+ };
1691
+ function isLegalRuntimeTransition(from, to) {
1692
+ return RUNTIME_LIFECYCLE_TRANSITIONS[from].includes(to);
1693
+ }
1694
+ var EXECUTION_LIFECYCLE_TRANSITIONS = {
1695
+ running: ["stopping", "failed"],
1696
+ stopping: ["stopped", "failed"],
1697
+ stopped: [],
1698
+ failed: []
1699
+ };
1700
+ function isLegalExecutionTransition(from, to) {
1701
+ return EXECUTION_LIFECYCLE_TRANSITIONS[from].includes(to);
1702
+ }
1703
+ function toLifecycleError(value) {
1704
+ return value instanceof Error ? value : new Error(String(value));
1705
+ }
1706
+ var TerminalSignal = class {
1707
+ waiters = /* @__PURE__ */ new Set();
1708
+ outcome = null;
1709
+ /**
1710
+ * Resolves once {@link settle} has been called with `null`, rejects once it has
1711
+ * been called with an error — including for callers that arrive afterwards.
1712
+ */
1713
+ async wait() {
1714
+ if (this.outcome) {
1715
+ if (this.outcome.error) {
1716
+ throw this.outcome.error;
1717
+ }
945
1718
  return;
946
1719
  }
947
- this.queueEvent({
948
- type: eventType,
949
- roomId,
950
- payload: parsed.data,
951
- raw: payload
1720
+ await new Promise((resolve, reject) => {
1721
+ this.waiters.add({ resolve, reject });
1722
+ });
1723
+ }
1724
+ /**
1725
+ * Forget the recorded outcome so a restarted owner can settle again.
1726
+ *
1727
+ * Callers already parked in {@link wait} stay parked — a wait started before a
1728
+ * restart still resolves only when the owner actually stops or fails.
1729
+ */
1730
+ rearm() {
1731
+ this.outcome = null;
1732
+ }
1733
+ /** First settle wins; later ones are ignored, so a terminal state stays terminal. */
1734
+ settle(error) {
1735
+ if (this.outcome) {
1736
+ return;
1737
+ }
1738
+ this.outcome = { error };
1739
+ const pending = [...this.waiters];
1740
+ this.waiters.clear();
1741
+ for (const waiter of pending) {
1742
+ if (error) {
1743
+ waiter.reject(error);
1744
+ } else {
1745
+ waiter.resolve();
1746
+ }
1747
+ }
1748
+ }
1749
+ };
1750
+ var SingleFlight2 = class {
1751
+ operation = null;
1752
+ /** The in-flight (or last latched) operation, if any. */
1753
+ get pending() {
1754
+ return this.operation;
1755
+ }
1756
+ /** Forget the latched operation so the next {@link run}/{@link runOrRetry} starts fresh. */
1757
+ reset() {
1758
+ this.operation = null;
1759
+ }
1760
+ /**
1761
+ * Runs `factory()` once; concurrent and later callers get the same promise.
1762
+ * The result — success or failure — stays latched until {@link reset}.
1763
+ */
1764
+ run(factory) {
1765
+ if (this.operation) {
1766
+ return this.operation;
1767
+ }
1768
+ const operation = factory();
1769
+ this.operation = operation;
1770
+ return operation;
1771
+ }
1772
+ /**
1773
+ * Like {@link run}, but unlatches on rejection so the next call retries
1774
+ * instead of replaying the same failure. Only unlatches its own operation —
1775
+ * one already replaced by a {@link reset} or a subsequent call is left alone.
1776
+ */
1777
+ async runOrRetry(factory) {
1778
+ if (this.operation) {
1779
+ return this.operation;
1780
+ }
1781
+ const operation = factory();
1782
+ this.operation = operation;
1783
+ try {
1784
+ return await operation;
1785
+ } catch (error) {
1786
+ if (this.operation === operation) {
1787
+ this.operation = null;
1788
+ }
1789
+ throw error;
1790
+ }
1791
+ }
1792
+ };
1793
+ var LifecycleTracker = class {
1794
+ options;
1795
+ current;
1796
+ constructor(initial, options) {
1797
+ this.options = options;
1798
+ Object.freeze(initial);
1799
+ this.current = initial;
1800
+ }
1801
+ /** The current state. Frozen, so callers cannot mutate the instance's lifecycle. */
1802
+ get state() {
1803
+ return this.current;
1804
+ }
1805
+ /**
1806
+ * Whether `state` is still the instance this tracker holds.
1807
+ *
1808
+ * Every accepted {@link transition} installs a *new* frozen object, so an
1809
+ * async operation that captured the state it began under can ask whether the
1810
+ * lifecycle moved on while it was awaiting — "was I superseded?" — without a
1811
+ * second, parallel generation counter living outside the tracker. A status
1812
+ * comparison cannot answer it: a `stop()` followed by a fresh `start()`
1813
+ * returns to `"starting"`, but not to the *same* `"starting"`.
1814
+ */
1815
+ isCurrent(state) {
1816
+ return this.current === state;
1817
+ }
1818
+ /** Records `next` as the state. Callers pass a fresh object — see {@link isCurrent}. */
1819
+ transition(next, trigger) {
1820
+ const from = this.current.status;
1821
+ if (!this.options.isLegalTransition(from, next.status)) {
1822
+ throw new RuntimeStateError(
1823
+ `${this.options.owner} cannot transition from "${from}" to "${next.status}" (trigger: ${trigger})`
1824
+ );
1825
+ }
1826
+ Object.freeze(next);
1827
+ this.current = next;
1828
+ this.options.logger?.debug(`${this.options.owner} lifecycle transition`, {
1829
+ ...this.options.logContext,
1830
+ from,
1831
+ to: next.status,
1832
+ trigger
952
1833
  });
1834
+ this.options.onTransition?.(next);
1835
+ }
1836
+ /**
1837
+ * Type-guard check against the current status: narrows `this` (and so
1838
+ * `this.state`) to the matching union member on success, e.g.
1839
+ * `if (tracker.is("failed")) { tracker.state.error }`.
1840
+ *
1841
+ * Prefer this over a raw `tracker.state.status === "..."` comparison —
1842
+ * same check, but the payload stays reachable without a separate
1843
+ * destructured snapshot.
1844
+ */
1845
+ is(status) {
1846
+ return this.current.status === status;
1847
+ }
1848
+ /**
1849
+ * Transition to a `"failed"` state carrying `error`, unless already
1850
+ * terminal (`"stopped"` or `"failed"`), in which case this is a no-op.
1851
+ * Returns whether the transition happened.
1852
+ *
1853
+ * Assumes `S` has a `{ status: "failed", error: Error }` member — true for
1854
+ * both `RuntimeLifecycleState` and `ExecutionLifecycleState`, the only two
1855
+ * states this tracker is used with.
1856
+ */
1857
+ fail(error, trigger) {
1858
+ if (this.is("stopped") || this.is("failed")) {
1859
+ return false;
1860
+ }
1861
+ this.transition({ status: "failed", error: toLifecycleError(error) }, trigger);
1862
+ return true;
1863
+ }
1864
+ };
1865
+ async function startWithGate(options) {
1866
+ if (options.lifecycle.state.status === "stopping") {
1867
+ throw new RuntimeStateError(`${options.ownerName} cannot start while a stop is in progress`);
1868
+ }
1869
+ if (options.startGate.pending) {
1870
+ return await options.startGate.pending;
1871
+ }
1872
+ options.stopGate.reset();
1873
+ options.stoppedSignal?.rearm();
1874
+ options.lifecycle.transition({ status: "starting" }, "start");
1875
+ await options.startGate.runOrRetry(options.runStart);
1876
+ }
1877
+
1878
+ // src/runtime/SyncBoundaryTracker.ts
1879
+ var SyncBoundaryTracker = class {
1880
+ // Every message id a sync scan has ever executed. Entries are never
1881
+ // removed: evicting on a message's own live redelivery was tried and
1882
+ // proved unsafe, because the backend can still return that same id from
1883
+ // `getNextMessage()` on a *later* reconnect's scan before its
1884
+ // mark-as-processed effect has propagated — an eviction keyed on "we saw
1885
+ // it once already" reopens exactly that race, just on a different
1886
+ // trigger. Permanent membership is what makes "already executed"
1887
+ // unconditional, at the cost of one entry per message ever synced via a
1888
+ // backlog scan for the life of this tracker — a real, bounded quantity.
1889
+ executedMessageIds = /* @__PURE__ */ new Set();
1890
+ initial = { messageId: null };
1891
+ // Ordered, oldest first, matching the order scans run in.
1892
+ queue = [this.initial];
1893
+ /** Whether every queued boundary's scan has finished. */
1894
+ get isComplete() {
1895
+ return this.queue.length === 0;
1896
+ }
1897
+ recordExecuted(messageId) {
1898
+ this.executedMessageIds.add(messageId);
1899
+ }
1900
+ isExecuted(messageId) {
1901
+ return this.executedMessageIds.has(messageId);
1902
+ }
1903
+ isSyncPoint(boundary, messageId) {
1904
+ return boundary.messageId !== null && messageId === boundary.messageId;
1905
+ }
1906
+ /** Anchors the oldest not-yet-anchored boundary to a live message's id, if one is open. */
1907
+ anchor(messageId) {
1908
+ const openBoundary = this.queue.find((boundary) => boundary.messageId === null);
1909
+ if (openBoundary) {
1910
+ openBoundary.messageId = messageId;
1911
+ }
1912
+ }
1913
+ /** Starts a new scan boundary for a reconnect and returns it. */
1914
+ beginBoundary() {
1915
+ const boundary = { messageId: null };
1916
+ this.queue.push(boundary);
1917
+ return boundary;
1918
+ }
1919
+ /** Marks `boundary`'s scan finished, dequeuing it only if it's still the oldest. */
1920
+ completeBoundary(boundary) {
1921
+ if (this.queue[0] === boundary) {
1922
+ this.queue.shift();
1923
+ }
953
1924
  }
954
1925
  };
955
1926
 
@@ -983,13 +1954,18 @@ var Execution = class {
983
1954
  eventQueue = [];
984
1955
  waiters = [];
985
1956
  idleWaiters = /* @__PURE__ */ new Set();
986
- drainedWsMessageIds = /* @__PURE__ */ new Set();
987
- syncProcessedIds = /* @__PURE__ */ new Set();
1957
+ stoppedSignal = new TerminalSignal();
1958
+ lifecycle;
988
1959
  processTask;
989
- firstWsMessageId = null;
990
- syncComplete = false;
991
- running = true;
1960
+ stopGate = new SingleFlight2();
1961
+ syncBoundaries = new SyncBoundaryTracker();
992
1962
  inFlight = 0;
1963
+ /**
1964
+ * Set once the queue stops accepting new events, ahead of the lifecycle
1965
+ * itself reporting `"stopped"` (see {@link runStop}) — closing the queue
1966
+ * cannot wait on the drain it is closing for.
1967
+ */
1968
+ closed = false;
993
1969
  constructor(options) {
994
1970
  this.roomId = options.roomId;
995
1971
  this.link = options.link;
@@ -997,27 +1973,61 @@ var Execution = class {
997
1973
  this.retryTracker = this.context.getRetryTracker();
998
1974
  this.onExecute = options.onExecute;
999
1975
  this.onFailure = options.onFailure;
1000
- this.logger = options.logger ?? new NoopLogger();
1001
- this.processTask = this.processLoop();
1976
+ this.logger = resolveLogger(options.logger);
1977
+ this.lifecycle = new LifecycleTracker({ status: "running" }, {
1978
+ owner: "Execution",
1979
+ logContext: { roomId: this.roomId },
1980
+ logger: this.logger,
1981
+ isLegalTransition: isLegalExecutionTransition,
1982
+ onTransition: (state) => {
1983
+ if (state.status === "stopped") {
1984
+ this.stoppedSignal.settle(null);
1985
+ } else if (state.status === "failed") {
1986
+ this.stoppedSignal.settle(state.error);
1987
+ }
1988
+ }
1989
+ });
1990
+ this.processTask = this.runProcessLoop();
1991
+ void this.processTask.catch(() => void 0);
1992
+ }
1993
+ /**
1994
+ * Whether this `Execution` is still alive, and if not, why it ended.
1995
+ *
1996
+ * This is the *lifecycle* axis. For "is this turn's handler currently
1997
+ * executing" read `ExecutionContext.state` instead, which reports
1998
+ * `"starting" | "idle" | "processing"` for the room's context.
1999
+ *
2000
+ * @see ExecutionContext.state
2001
+ */
2002
+ get state() {
2003
+ return this.lifecycle.state;
1002
2004
  }
1003
- enqueue(event) {
1004
- if (event.type === "message_created" && !this.syncComplete && this.firstWsMessageId === null) {
1005
- this.firstWsMessageId = event.payload.id;
2005
+ async enqueue(event) {
2006
+ if (this.lifecycle.is("stopped") || this.lifecycle.is("failed") || this.closed) {
2007
+ throw new RuntimeStateError(
2008
+ `Execution for room ${this.roomId} has already ended or is stopping (status: ${this.lifecycle.state.status}); enqueue() is a no-op after stop()`
2009
+ );
1006
2010
  }
2011
+ let syncBoundary = null;
2012
+ if (event.type === "reconnected") {
2013
+ syncBoundary = this.syncBoundaries.beginBoundary();
2014
+ } else if (event.type === "message_created") {
2015
+ this.syncBoundaries.anchor(event.payload.id);
2016
+ }
2017
+ const queued = { event, syncBoundary };
1007
2018
  const waiter = this.waiters.shift();
1008
2019
  if (waiter) {
1009
- waiter(event);
2020
+ waiter(queued);
1010
2021
  } else {
1011
- this.eventQueue.push(event);
2022
+ this.eventQueue.push(queued);
1012
2023
  }
1013
- return Promise.resolve();
1014
2024
  }
1015
2025
  async bootstrapMessage(message) {
1016
- this.syncProcessedIds.add(message.id);
2026
+ this.syncBoundaries.recordExecuted(message.id);
1017
2027
  await this.executeSyncMessage(toMessageEvent(message), message.id);
1018
2028
  }
1019
2029
  isIdle() {
1020
- return this.syncComplete && this.inFlight === 0 && this.eventQueue.length === 0;
2030
+ return this.syncBoundaries.isComplete && this.inFlight === 0 && this.eventQueue.length === 0;
1021
2031
  }
1022
2032
  async waitForIdle(timeoutMs) {
1023
2033
  if (this.isIdle()) {
@@ -1052,27 +2062,58 @@ var Execution = class {
1052
2062
  });
1053
2063
  }
1054
2064
  async stop(timeoutMs) {
2065
+ return await this.stopGate.run(() => this.runStop(timeoutMs));
2066
+ }
2067
+ async runStop(timeoutMs) {
2068
+ if (this.lifecycle.is("failed")) {
2069
+ throw this.lifecycle.state.error;
2070
+ }
2071
+ if (this.lifecycle.is("stopped")) {
2072
+ return this.lifecycle.state.graceful;
2073
+ }
2074
+ this.lifecycle.transition({ status: "stopping" }, "stop");
1055
2075
  const graceful = await this.waitForIdle(timeoutMs);
1056
- this.running = false;
2076
+ if (this.lifecycle.is("failed")) {
2077
+ throw this.lifecycle.state.error;
2078
+ }
2079
+ this.closed = true;
1057
2080
  this.resolveEventWaiters(null);
1058
2081
  if (graceful || timeoutMs === void 0) {
1059
2082
  await this.processTask;
1060
2083
  }
2084
+ this.lifecycle.transition({ status: "stopped", graceful }, graceful ? "stopped" : "stopped-forced");
1061
2085
  return graceful;
1062
2086
  }
2087
+ /**
2088
+ * Resolve once this `Execution` has reached a terminal state — including a
2089
+ * forced, non-graceful stop that detached the process loop — or reject with
2090
+ * the error that ended it.
2091
+ */
1063
2092
  async waitUntilStopped() {
1064
- await this.processTask;
2093
+ await this.stoppedSignal.wait();
2094
+ }
2095
+ async runProcessLoop() {
2096
+ try {
2097
+ await this.processLoop();
2098
+ } catch (error) {
2099
+ this.markFailed(error, "process-loop-failed");
2100
+ throw error;
2101
+ }
1065
2102
  }
1066
2103
  async processLoop() {
1067
2104
  await this.recoverStaleProcessingMessages();
1068
- await this.synchronizeWithNext();
1069
- while (this.running) {
1070
- const event = await this.nextQueuedEvent();
1071
- if (!event) {
2105
+ await this.synchronizeWithNext(this.syncBoundaries.initial);
2106
+ while (this.isActive()) {
2107
+ const queued = await this.nextQueuedEvent();
2108
+ if (!queued) {
1072
2109
  return;
1073
2110
  }
1074
- if (event.type === "message_created" && this.drainedWsMessageIds.has(event.payload.id)) {
1075
- this.drainedWsMessageIds.delete(event.payload.id);
2111
+ const { event } = queued;
2112
+ if (event.type === "reconnected") {
2113
+ await this.synchronizeWithNext(queued.syncBoundary ?? { messageId: null });
2114
+ continue;
2115
+ }
2116
+ if (event.type === "message_created" && this.syncBoundaries.isExecuted(event.payload.id)) {
1076
2117
  this.notifyIfIdle();
1077
2118
  continue;
1078
2119
  }
@@ -1097,7 +2138,7 @@ var Execution = class {
1097
2138
  count: staleMessages.length
1098
2139
  });
1099
2140
  for (const message of staleMessages) {
1100
- if (!this.running) {
2141
+ if (!this.isActive()) {
1101
2142
  break;
1102
2143
  }
1103
2144
  if (this.retryTracker.isPermanentlyFailed(message.id)) {
@@ -1108,20 +2149,17 @@ var Execution = class {
1108
2149
  continue;
1109
2150
  }
1110
2151
  await this.executeSyncMessage(toMessageEvent(message), message.id);
1111
- this.syncProcessedIds.add(message.id);
2152
+ this.syncBoundaries.recordExecuted(message.id);
1112
2153
  }
1113
2154
  }
1114
- async synchronizeWithNext() {
1115
- while (this.running) {
2155
+ async synchronizeWithNext(boundary) {
2156
+ while (this.isActive()) {
1116
2157
  const nextMessage = await this.link.getNextMessage(this.roomId);
1117
2158
  if (!nextMessage) {
1118
2159
  break;
1119
2160
  }
1120
- if (this.syncProcessedIds.has(nextMessage.id)) {
1121
- const isSyncPoint2 = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
1122
- if (isSyncPoint2) {
1123
- this.drainedWsMessageIds.add(nextMessage.id);
1124
- this.firstWsMessageId = null;
2161
+ if (this.syncBoundaries.isExecuted(nextMessage.id)) {
2162
+ if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
1125
2163
  break;
1126
2164
  }
1127
2165
  continue;
@@ -1132,25 +2170,19 @@ var Execution = class {
1132
2170
  messageId: nextMessage.id
1133
2171
  });
1134
2172
  await this.markMessageFailed(nextMessage.id, "Message permanently failed after max retries");
1135
- const isSyncPoint2 = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
1136
- if (isSyncPoint2) {
1137
- this.drainedWsMessageIds.add(nextMessage.id);
1138
- this.firstWsMessageId = null;
2173
+ this.syncBoundaries.recordExecuted(nextMessage.id);
2174
+ if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
1139
2175
  break;
1140
2176
  }
1141
2177
  continue;
1142
2178
  }
1143
- const isSyncPoint = this.firstWsMessageId !== null && nextMessage.id === this.firstWsMessageId;
1144
2179
  await this.executeSyncMessage(toMessageEvent(nextMessage), nextMessage.id);
1145
- this.syncProcessedIds.add(nextMessage.id);
1146
- if (isSyncPoint) {
1147
- this.drainedWsMessageIds.add(nextMessage.id);
1148
- this.firstWsMessageId = null;
2180
+ this.syncBoundaries.recordExecuted(nextMessage.id);
2181
+ if (this.syncBoundaries.isSyncPoint(boundary, nextMessage.id)) {
1149
2182
  break;
1150
2183
  }
1151
2184
  }
1152
- this.syncProcessedIds.clear();
1153
- this.syncComplete = true;
2185
+ this.syncBoundaries.completeBoundary(boundary);
1154
2186
  this.notifyIfIdle();
1155
2187
  }
1156
2188
  async executeSyncMessage(event, messageId) {
@@ -1187,6 +2219,14 @@ var Execution = class {
1187
2219
  try {
1188
2220
  await this.onExecute(this.context, event);
1189
2221
  } catch (error) {
2222
+ if (error instanceof RecoverableTurnError) {
2223
+ this.logger.warn("Turn failed without stopping the room", {
2224
+ roomId: this.roomId,
2225
+ eventType: event.type,
2226
+ error
2227
+ });
2228
+ return;
2229
+ }
1190
2230
  if (this.onFailure) {
1191
2231
  await this.onFailure(error, event);
1192
2232
  } else {
@@ -1196,7 +2236,7 @@ var Execution = class {
1196
2236
  error
1197
2237
  });
1198
2238
  }
1199
- this.running = false;
2239
+ this.markFailed(error, "execution-failed");
1200
2240
  this.eventQueue.splice(0, this.eventQueue.length);
1201
2241
  this.resolveEventWaiters(null);
1202
2242
  throw error;
@@ -1211,13 +2251,21 @@ var Execution = class {
1211
2251
  if (queued) {
1212
2252
  return queued;
1213
2253
  }
1214
- if (!this.running) {
2254
+ if (this.closed || !this.isActive()) {
1215
2255
  return null;
1216
2256
  }
1217
2257
  return new Promise((resolve) => {
1218
2258
  this.waiters.push(resolve);
1219
2259
  });
1220
2260
  }
2261
+ /** True while the process loop should keep draining (`running` or mid-`stop()`). */
2262
+ isActive() {
2263
+ const status = this.lifecycle.state.status;
2264
+ return status === "running" || status === "stopping";
2265
+ }
2266
+ markFailed(error, trigger) {
2267
+ this.lifecycle.fail(error, trigger);
2268
+ }
1221
2269
  notifyIfIdle() {
1222
2270
  if (!this.isIdle()) {
1223
2271
  return;
@@ -1248,34 +2296,29 @@ var Execution = class {
1248
2296
 
1249
2297
  // src/runtime/tools/AgentTools.ts
1250
2298
  import { ParticipantRoster } from "@band-ai/band-sdk-core";
1251
- var REQUIRED_ADAPTER_TOOL_METHODS = [
1252
- "sendMessage",
1253
- "sendEvent",
1254
- "addParticipant",
1255
- "removeParticipant",
1256
- "getParticipants",
1257
- "createChatroom",
1258
- "getToolSchemas",
1259
- "getAnthropicToolSchemas",
1260
- "getOpenAIToolSchemas",
1261
- "executeToolCall"
1262
- ];
1263
- var OPTIONAL_ADAPTER_TOOL_METHODS = {
1264
- peers: ["lookupPeers"],
1265
- contacts: [
1266
- "listContacts",
1267
- "addContact",
1268
- "removeContact",
1269
- "listContactRequests",
1270
- "respondContactRequest"
1271
- ],
1272
- memory: [
1273
- "listMemories",
1274
- "storeMemory",
1275
- "getMemory",
1276
- "supersedeMemory",
1277
- "archiveMemory"
1278
- ]
2299
+ var ADAPTER_TOOL_METHODS = {
2300
+ sendMessage: null,
2301
+ sendEvent: null,
2302
+ sendFailure: null,
2303
+ addParticipant: null,
2304
+ removeParticipant: null,
2305
+ getParticipants: null,
2306
+ createChatroom: null,
2307
+ getToolSchemas: null,
2308
+ getAnthropicToolSchemas: null,
2309
+ getOpenAIToolSchemas: null,
2310
+ executeToolCall: null,
2311
+ lookupPeers: "peers",
2312
+ listContacts: "contacts",
2313
+ addContact: "contacts",
2314
+ removeContact: "contacts",
2315
+ listContactRequests: "contacts",
2316
+ respondContactRequest: "contacts",
2317
+ listMemories: "memory",
2318
+ storeMemory: "memory",
2319
+ getMemory: "memory",
2320
+ supersedeMemory: "memory",
2321
+ archiveMemory: "memory"
1279
2322
  };
1280
2323
  var CONTACT_REQUEST_ACTIONS = /* @__PURE__ */ new Set([
1281
2324
  "approve",
@@ -1294,7 +2337,7 @@ var AgentTools = class {
1294
2337
  this.roomId = options.roomId;
1295
2338
  this.rest = options.rest;
1296
2339
  this.roster = options.roster ?? new ParticipantRoster();
1297
- this.logger = options.logger ?? new NoopLogger();
2340
+ this.logger = resolveLogger(options.logger);
1298
2341
  this.capabilities = {
1299
2342
  ...DEFAULT_AGENT_TOOLS_CAPABILITIES,
1300
2343
  ...options.capabilities
@@ -1334,14 +2377,14 @@ var AgentTools = class {
1334
2377
  }
1335
2378
  );
1336
2379
  } catch (error) {
1337
- try {
1338
- this.logger.warn("chat event send failed", { roomId: this.roomId, messageType, error });
1339
- } catch {
1340
- }
2380
+ this.logger.warn("chat event send failed", { roomId: this.roomId, messageType, error });
1341
2381
  const message = error instanceof Error ? error.message : String(error);
1342
2382
  return { ok: false, status: "failed", message };
1343
2383
  }
1344
2384
  }
2385
+ async sendFailure(failure) {
2386
+ return sendFailureViaEvent(this.sendEvent.bind(this), failure);
2387
+ }
1345
2388
  async createChatroom(taskId) {
1346
2389
  const room = await this.rest.createChat(taskId, DEFAULT_REQUEST_OPTIONS);
1347
2390
  return room.id;
@@ -1465,6 +2508,7 @@ var AgentTools = class {
1465
2508
  legacyMessage: `Invalid arguments for ${toolName}: ${error.message}`
1466
2509
  });
1467
2510
  }
2511
+ this.logger.error("unexpected tool execution error", { toolName, error });
1468
2512
  const message = error instanceof Error ? error.message : String(error);
1469
2513
  return createToolExecutorError({
1470
2514
  errorType: "ToolExecutionError",
@@ -1751,16 +2795,11 @@ var AgentTools = class {
1751
2795
  const tools = {
1752
2796
  capabilities: this.capabilities
1753
2797
  };
1754
- for (const methodName of REQUIRED_ADAPTER_TOOL_METHODS) {
1755
- tools[methodName] = this.bindAdapterToolMethod(methodName);
1756
- }
1757
- for (const [capabilityKey, methodNames] of Object.entries(OPTIONAL_ADAPTER_TOOL_METHODS)) {
1758
- if (!this.capabilities[capabilityKey]) {
2798
+ for (const [methodName, capability] of Object.entries(ADAPTER_TOOL_METHODS)) {
2799
+ if (capability !== null && !this.capabilities[capability]) {
1759
2800
  continue;
1760
2801
  }
1761
- for (const methodName of methodNames) {
1762
- tools[methodName] = this.bindAdapterToolMethod(methodName);
1763
- }
2802
+ tools[methodName] = this.bindAdapterToolMethod(methodName);
1764
2803
  }
1765
2804
  return Object.freeze(tools);
1766
2805
  }
@@ -1908,9 +2947,9 @@ var AgentTools = class {
1908
2947
  if (!segment) {
1909
2948
  throw new ValidationError(`segment must be one of: ${expectedList(MEMORY_SEGMENTS)}`);
1910
2949
  }
1911
- if (scope === "subject" && !subjectId) {
2950
+ if (scope === MEMORY_STORE_SCOPE.subject && !subjectId) {
1912
2951
  throw new ValidationError(
1913
- '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.'
2952
+ `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.`
1914
2953
  );
1915
2954
  }
1916
2955
  return {
@@ -2088,22 +3127,6 @@ function validateToolArgs(toolName, args) {
2088
3127
  );
2089
3128
  }
2090
3129
  }
2091
- if (toolName === "band_store_memory") {
2092
- if (typeof args.system === "string" && !isMemorySystem(args.system)) {
2093
- errors.push(`system: Invalid value '${args.system}'. Expected one of: ${expectedList(MEMORY_SYSTEMS)}`);
2094
- }
2095
- if (typeof args.type === "string" && !isMemoryType(args.type)) {
2096
- errors.push(`type: Invalid value '${args.type}'. Expected one of: ${expectedList(MEMORY_TYPES)}`);
2097
- }
2098
- if (typeof args.system === "string" && isMemorySystem(args.system) && typeof args.type === "string" && isMemoryType(args.type) && !isMemoryTypeForSystem(args.system, args.type)) {
2099
- errors.push(
2100
- `type: Invalid value '${args.type}' for system '${args.system}'. Expected one of: ${expectedMemoryTypesForSystem(args.system)}`
2101
- );
2102
- }
2103
- if (typeof args.segment === "string" && !isMemorySegment(args.segment)) {
2104
- errors.push(`segment: Invalid value '${args.segment}'. Expected one of: ${expectedList(MEMORY_SEGMENTS)}`);
2105
- }
2106
- }
2107
3130
  if (errors.length > 0) {
2108
3131
  const message = `Invalid arguments for ${toolName}: ${errors.join("; ")}`;
2109
3132
  return createToolExecutorError({
@@ -2154,10 +3177,21 @@ var ExecutionContext = class {
2154
3177
  rest: this.link.rest,
2155
3178
  roster: this.roster,
2156
3179
  capabilities: this.link.capabilities,
2157
- logger: options.logger ?? new NoopLogger()
3180
+ logger: resolveLogger(options.logger)
2158
3181
  });
2159
3182
  this.adapterTools = this.tools.getAdapterTools();
2160
3183
  }
3184
+ /**
3185
+ * What this room's turn handler is currently doing.
3186
+ *
3187
+ * This is the *per-turn activity* axis, not a lifecycle: it flips to
3188
+ * `"processing"` while the adapter handles an event and back to `"idle"`
3189
+ * afterwards. To ask whether the room's `Execution` is still alive at all,
3190
+ * read `Execution.state` instead, which reports
3191
+ * `"running" | "stopping" | "stopped" | "failed"`.
3192
+ *
3193
+ * @see Execution.state
3194
+ */
2161
3195
  get state() {
2162
3196
  return this._state;
2163
3197
  }
@@ -2406,18 +3440,26 @@ var ExecutionContext = class {
2406
3440
  import { RoomRoster } from "@band-ai/band-sdk-core";
2407
3441
 
2408
3442
  // src/runtime/rooms/subscriptions.ts
3443
+ async function listExistingRooms(options) {
3444
+ const rooms = await options.link.listAllChats(void 0, options.requestOptions);
3445
+ const accepted = /* @__PURE__ */ new Map();
3446
+ for (const room of rooms) {
3447
+ const roomId = typeof room.id === "string" ? room.id : null;
3448
+ if (!roomId) {
3449
+ continue;
3450
+ }
3451
+ if (options.roomFilter && !options.roomFilter(room)) {
3452
+ continue;
3453
+ }
3454
+ accepted.set(roomId, room);
3455
+ }
3456
+ return accepted;
3457
+ }
2409
3458
  async function hydrateExistingRooms(options) {
2410
3459
  try {
2411
- const rooms = await options.link.listAllChats(void 0, options.requestOptions);
2412
- for (const room of rooms) {
2413
- const roomId = typeof room.id === "string" ? room.id : null;
2414
- if (!roomId) {
2415
- continue;
2416
- }
2417
- if (options.roomFilter && !options.roomFilter(room)) {
2418
- continue;
2419
- }
2420
- await options.onRoom(roomId, room);
3460
+ const rooms = await listExistingRooms(options);
3461
+ for (const [roomId, payload] of rooms) {
3462
+ await options.onRoom(roomId, payload);
2421
3463
  }
2422
3464
  } catch (error) {
2423
3465
  if (error instanceof UnsupportedFeatureError) {
@@ -2445,7 +3487,7 @@ var RoomPresence = class {
2445
3487
  eventController = null;
2446
3488
  eventTask = null;
2447
3489
  contactsSubscribed = false;
2448
- lifecycle = Promise.resolve();
3490
+ lifecycle = new Serializer();
2449
3491
  admissionInFlight = /* @__PURE__ */ new Map();
2450
3492
  // Read once by `admitRoomOrThrow` right after a failed admission; a
2451
3493
  // subsequent successful subscribe clears it so a caller never attributes
@@ -2455,13 +3497,13 @@ var RoomPresence = class {
2455
3497
  this.link = options.link;
2456
3498
  this.roomFilter = options.roomFilter;
2457
3499
  this.autoSubscribeExistingRooms = options.autoSubscribeExistingRooms ?? true;
2458
- this.logger = options.logger ?? new NoopLogger();
3500
+ this.logger = resolveLogger(options.logger);
2459
3501
  }
2460
3502
  async start() {
2461
- return this.serialize(() => this.startBody());
3503
+ return this.lifecycle.run(() => this.startBody());
2462
3504
  }
2463
3505
  async stop() {
2464
- return this.serialize(() => this.stopBody());
3506
+ return this.lifecycle.run(() => this.stopBody());
2465
3507
  }
2466
3508
  async [Symbol.asyncDispose]() {
2467
3509
  await this.stop();
@@ -2526,14 +3568,6 @@ var RoomPresence = class {
2526
3568
  }
2527
3569
  return roomIsAdmitted;
2528
3570
  }
2529
- async serialize(body) {
2530
- const run = this.lifecycle.then(body, body);
2531
- this.lifecycle = run.then(
2532
- () => void 0,
2533
- () => void 0
2534
- );
2535
- return run;
2536
- }
2537
3571
  async startBody() {
2538
3572
  if (this.eventTask) {
2539
3573
  throw new RuntimeStateError("RoomPresence is already started");
@@ -2541,14 +3575,8 @@ var RoomPresence = class {
2541
3575
  if (!this.link.isConnected()) {
2542
3576
  await this.link.connect();
2543
3577
  }
2544
- const contactsReady = this.subscribeContacts();
2545
- try {
2546
- await this.link.subscribeAgentRooms();
2547
- } catch (error) {
2548
- this.logger.warn("RoomPresence failed to subscribe agent_rooms channel, continuing without it", {
2549
- error
2550
- });
2551
- }
3578
+ const contactsReady = this.subscribeContacts("start");
3579
+ await this.subscribeAgentRoomsChannel("start");
2552
3580
  if (this.autoSubscribeExistingRooms) {
2553
3581
  await this.subscribeExistingRooms();
2554
3582
  }
@@ -2556,7 +3584,17 @@ var RoomPresence = class {
2556
3584
  this.eventController = new AbortController();
2557
3585
  this.eventTask = this.consumeEvents(this.eventController.signal);
2558
3586
  }
2559
- async subscribeContacts() {
3587
+ contextualWarnMessage(channel, context) {
3588
+ return context === "reconnect" ? `RoomPresence failed to resubscribe ${channel} channel after reconnect` : `RoomPresence failed to subscribe ${channel} channel, continuing without it`;
3589
+ }
3590
+ async subscribeAgentRoomsChannel(context) {
3591
+ try {
3592
+ await this.link.subscribeAgentRooms();
3593
+ } catch (error) {
3594
+ this.logger.warn(this.contextualWarnMessage("agent_rooms", context), { error });
3595
+ }
3596
+ }
3597
+ async subscribeContacts(context) {
2560
3598
  if (!this.link.capabilities.contacts) {
2561
3599
  return;
2562
3600
  }
@@ -2564,9 +3602,7 @@ var RoomPresence = class {
2564
3602
  await this.link.subscribeAgentContacts();
2565
3603
  this.contactsSubscribed = true;
2566
3604
  } catch (error) {
2567
- this.logger.warn("RoomPresence failed to subscribe agent_contacts channel, continuing without it", {
2568
- error
2569
- });
3605
+ this.logger.warn(this.contextualWarnMessage("agent_contacts", context), { error });
2570
3606
  }
2571
3607
  }
2572
3608
  async stopBody() {
@@ -2620,6 +3656,9 @@ var RoomPresence = class {
2620
3656
  await this.onRoomEvent?.(event.roomId, event);
2621
3657
  }
2622
3658
  break;
3659
+ case "reconnected":
3660
+ await this.handleReconnected(event);
3661
+ break;
2623
3662
  default:
2624
3663
  assertNever(event);
2625
3664
  }
@@ -2638,14 +3677,74 @@ var RoomPresence = class {
2638
3677
  if (!roomId) {
2639
3678
  return;
2640
3679
  }
2641
- await this.unsubscribeRoom(roomId);
2642
- this.lastSubscribeError.delete(roomId);
3680
+ await this.leaveRoomTracking(roomId);
2643
3681
  if (!this.roster.recordRoomRemoved(roomId)) {
2644
3682
  this.logger.debug("RoomPresence ignoring removal for untracked room", { roomId });
2645
3683
  return;
2646
3684
  }
2647
3685
  await this.onRoomLeft?.(roomId);
2648
3686
  }
3687
+ /** Unsubscribes the transport topic and clears any remembered subscribe failure for `roomId`. */
3688
+ async leaveRoomTracking(roomId) {
3689
+ await this.unsubscribeRoom(roomId);
3690
+ this.lastSubscribeError.delete(roomId);
3691
+ }
3692
+ /**
3693
+ * A REST fetch failure here must not blank the roster — it forwards the
3694
+ * reconnect to every currently tracked room regardless, so each room's
3695
+ * `Execution` can still re-run its `/next` synchronization even when
3696
+ * membership reconciliation itself has to wait for the next reconnect.
3697
+ */
3698
+ async handleReconnected(event) {
3699
+ const [, , accepted] = await Promise.all([
3700
+ this.subscribeAgentRoomsChannel("reconnect"),
3701
+ this.subscribeContacts("reconnect"),
3702
+ listExistingRooms({
3703
+ link: this.link,
3704
+ roomFilter: this.roomFilter,
3705
+ requestOptions: DEFAULT_REQUEST_OPTIONS
3706
+ }).catch((error) => {
3707
+ this.logger.warn("RoomPresence failed to fetch room snapshot after reconnect", { error });
3708
+ return null;
3709
+ })
3710
+ ]);
3711
+ if (accepted) {
3712
+ await this.reconcileRoomsWithSnapshot(accepted);
3713
+ }
3714
+ await Promise.all(
3715
+ this.roster.trackedRoomIds().map((roomId) => this.onRoomEvent?.(roomId, event) ?? Promise.resolve())
3716
+ );
3717
+ }
3718
+ async reconcileRoomsWithSnapshot(accepted) {
3719
+ const acceptedIds = this.autoSubscribeExistingRooms ? [...accepted.keys()] : this.roster.trackedRoomIds().filter((roomId) => accepted.has(roomId));
3720
+ const reconciliation = this.roster.reconcile(acceptedIds);
3721
+ await Promise.all(
3722
+ reconciliation.removed.map(async (roomId) => {
3723
+ await this.leaveRoomTracking(roomId);
3724
+ await this.onRoomLeft?.(roomId);
3725
+ })
3726
+ );
3727
+ await Promise.all(
3728
+ reconciliation.admitting.map(async ([roomId, ticket]) => {
3729
+ const admitted = await this.performAdmission(roomId, ticket);
3730
+ if (admitted) {
3731
+ await this.onRoomJoined?.(roomId, accepted.get(roomId) ?? {});
3732
+ }
3733
+ })
3734
+ );
3735
+ await Promise.all(
3736
+ reconciliation.resync.map(async (roomId) => {
3737
+ try {
3738
+ await this.link.subscribeRoom(roomId);
3739
+ } catch (error) {
3740
+ this.logger.warn("RoomPresence failed to resubscribe surviving room after reconnect", {
3741
+ roomId,
3742
+ error
3743
+ });
3744
+ }
3745
+ })
3746
+ );
3747
+ }
2649
3748
  async unsubscribeRoom(roomId) {
2650
3749
  try {
2651
3750
  await this.link.unsubscribeRoom(roomId);
@@ -2690,9 +3789,10 @@ var AgentRuntime = class {
2690
3789
  executions = /* @__PURE__ */ new Map();
2691
3790
  executionWatchers = /* @__PURE__ */ new Map();
2692
3791
  logger;
2693
- running = false;
2694
- stopping = false;
2695
- fatalError = null;
3792
+ stoppedSignal = new TerminalSignal();
3793
+ lifecycle;
3794
+ startGate = new SingleFlight2();
3795
+ stopGate = new SingleFlight2();
2696
3796
  constructor(options) {
2697
3797
  this.link = options.link;
2698
3798
  this.agentId = options.agentId;
@@ -2701,7 +3801,7 @@ var AgentRuntime = class {
2701
3801
  this.onRoomJoined = options.onRoomJoined;
2702
3802
  this.onRoomLeft = options.onRoomLeft;
2703
3803
  this.onError = options.onError;
2704
- this.logger = options.logger ?? new NoopLogger();
3804
+ this.logger = resolveLogger(options.logger);
2705
3805
  this.onContactEvent = options.onContactEvent;
2706
3806
  this.onParticipantAdded = options.onParticipantAdded;
2707
3807
  this.onParticipantRemoved = options.onParticipantRemoved;
@@ -2713,6 +3813,19 @@ var AgentRuntime = class {
2713
3813
  maxMessageRetries: options.sessionConfig?.maxMessageRetries ?? 1,
2714
3814
  enableContextHydration: options.sessionConfig?.enableContextHydration ?? true
2715
3815
  };
3816
+ this.lifecycle = new LifecycleTracker({ status: "not_started" }, {
3817
+ owner: "AgentRuntime",
3818
+ logContext: { agentId: this.agentId },
3819
+ logger: this.logger,
3820
+ isLegalTransition: isLegalRuntimeTransition,
3821
+ onTransition: (state) => {
3822
+ if (state.status === "stopped") {
3823
+ this.stoppedSignal.settle(null);
3824
+ } else if (state.status === "failed") {
3825
+ this.stoppedSignal.settle(state.error);
3826
+ }
3827
+ }
3828
+ });
2716
3829
  this.presence = new RoomPresence({
2717
3830
  link: this.link,
2718
3831
  roomFilter: options.roomFilter,
@@ -2748,6 +3861,7 @@ var AgentRuntime = class {
2748
3861
  return;
2749
3862
  }
2750
3863
  case "message_created":
3864
+ case "reconnected":
2751
3865
  await this.getOrCreateExecution(roomId).enqueue(event);
2752
3866
  return;
2753
3867
  default:
@@ -2756,65 +3870,138 @@ var AgentRuntime = class {
2756
3870
  };
2757
3871
  this.presence.onContactEvent = this.onContactEvent ?? null;
2758
3872
  }
3873
+ /** Current lifecycle state of this runtime. */
3874
+ get state() {
3875
+ return this.lifecycle.state;
3876
+ }
3877
+ /**
3878
+ * Connect, subscribe, and begin consuming platform events.
3879
+ *
3880
+ * Repeated or concurrent calls join the in-flight start instead of starting a
3881
+ * second consume loop. Calling `start()` while a `stop()` is still in flight
3882
+ * rejects with a `RuntimeStateError`.
3883
+ */
2759
3884
  async start() {
2760
- if (this.running) {
2761
- return;
2762
- }
2763
- this.running = true;
2764
- this.stopping = false;
2765
- this.fatalError = null;
3885
+ await startWithGate({
3886
+ lifecycle: this.lifecycle,
3887
+ startGate: this.startGate,
3888
+ stopGate: this.stopGate,
3889
+ stoppedSignal: this.stoppedSignal,
3890
+ ownerName: "AgentRuntime",
3891
+ runStart: () => this.runStart()
3892
+ });
3893
+ }
3894
+ async runStart() {
2766
3895
  try {
2767
3896
  await this.presence.start();
2768
3897
  } catch (error) {
2769
- await this.handleStartFailure();
3898
+ await this.finishFailedStart();
2770
3899
  throw error;
2771
3900
  }
3901
+ if (this.lifecycle.is("starting")) {
3902
+ this.lifecycle.transition({ status: "running" }, "started");
3903
+ }
2772
3904
  void this.presence.waitUntilStopped().catch((error) => this.failRuntime(error, syntheticRuntimeFailureEvent(this.agentId)));
2773
3905
  }
3906
+ async finishFailedStart() {
3907
+ try {
3908
+ await this.handleStartFailure();
3909
+ } catch (cleanupError) {
3910
+ this.markFailed(cleanupError, "start-cleanup-failed");
3911
+ throw cleanupError;
3912
+ }
3913
+ if (this.lifecycle.is("starting")) {
3914
+ this.lifecycle.transition({ status: "stopped" }, "start-failed");
3915
+ }
3916
+ }
2774
3917
  async handleStartFailure() {
2775
- this.running = false;
2776
- this.stopping = false;
3918
+ this.presence.abortEventLoop();
2777
3919
  await this.link.disconnect();
2778
3920
  }
3921
+ /**
3922
+ * Tear the runtime down.
3923
+ *
3924
+ * A concurrent second call joins the in-flight teardown and mirrors its
3925
+ * outcome — including rejecting with the *same* `Error` instance — instead of
3926
+ * reporting a shutdown it did not perform.
3927
+ */
2779
3928
  async stop(timeoutMs) {
2780
- if (this.stopping || !this.running && !this.fatalError) {
3929
+ return await this.stopGate.run(() => this.runStop(timeoutMs));
3930
+ }
3931
+ async runStop(timeoutMs) {
3932
+ const pendingStart = this.lifecycle.is("starting") ? this.startGate.pending : null;
3933
+ if (pendingStart) {
3934
+ try {
3935
+ await pendingStart;
3936
+ } catch (error) {
3937
+ this.logger.debug("AgentRuntime stop is proceeding after the in-flight start failed", { error });
3938
+ }
3939
+ }
3940
+ if (this.lifecycle.is("not_started") || this.lifecycle.is("stopped")) {
2781
3941
  return true;
2782
3942
  }
2783
- this.stopping = true;
2784
- this.running = false;
3943
+ const fatalError = this.lifecycle.is("failed") ? this.lifecycle.state.error : null;
3944
+ this.startGate.reset();
3945
+ this.lifecycle.transition({ status: "stopping" }, "stop");
3946
+ try {
3947
+ return await this.performStop(timeoutMs, fatalError);
3948
+ } catch (error) {
3949
+ this.markFailed(error, "stop-failed");
3950
+ throw error;
3951
+ }
3952
+ }
3953
+ async performStop(timeoutMs, fatalError) {
3954
+ const errors = [];
2785
3955
  this.presence.abortEventLoop();
2786
- await this.presence.waitUntilStopped().catch(() => void 0);
3956
+ await isolateTeardown(errors, () => this.presence.waitUntilStopped());
2787
3957
  let graceful = true;
2788
3958
  await Promise.all(
2789
3959
  [...this.executions].map(async ([roomId, execution]) => {
2790
- const stopped = await execution.stop(timeoutMs);
2791
- if (!stopped) {
2792
- graceful = false;
2793
- }
3960
+ await isolateTeardown(errors, async () => {
3961
+ graceful = await execution.stop(timeoutMs) && graceful;
3962
+ });
2794
3963
  this.executions.delete(roomId);
2795
3964
  })
2796
3965
  );
2797
- await this.presence.stop();
3966
+ await isolateTeardown(errors, () => this.presence.stop());
2798
3967
  for (const roomId of [...this.contexts.keys()]) {
2799
- await this.onSessionCleanup(roomId);
3968
+ await isolateTeardown(errors, () => this.onSessionCleanup(roomId));
2800
3969
  }
2801
3970
  this.contexts.clear();
2802
3971
  this.executions.clear();
2803
3972
  this.executionWatchers.clear();
2804
- await this.link.disconnect();
2805
- if (this.fatalError) {
2806
- throw this.fatalError instanceof Error ? this.fatalError : new Error(String(this.fatalError));
3973
+ await isolateTeardown(errors, () => this.link.disconnect());
3974
+ const failure = this.lifecycle.is("failed") ? this.lifecycle.state.error : fatalError;
3975
+ if (failure) {
3976
+ if (!this.lifecycle.is("failed")) {
3977
+ this.lifecycle.transition({ status: "failed", error: failure }, "stopped-after-failure");
3978
+ }
3979
+ errors.unshift(failure);
3980
+ }
3981
+ if (errors.length > 0) {
3982
+ throw combineTeardownErrors(errors, "AgentRuntime failed to tear down cleanly");
2807
3983
  }
3984
+ this.lifecycle.transition({ status: "stopped" }, "stopped");
2808
3985
  return graceful;
2809
3986
  }
2810
3987
  getContext(roomId) {
2811
3988
  return this.contexts.get(roomId);
2812
3989
  }
3990
+ /**
3991
+ * Resolve once the runtime has actually stopped, or reject with the fatal
3992
+ * error that ended it.
3993
+ *
3994
+ * A runtime that was never started stays pending until it stops or fails;
3995
+ * starting it does not resolve a pending wait.
3996
+ */
2813
3997
  async waitUntilStopped() {
2814
- await this.presence.waitUntilStopped().catch(() => void 0);
2815
- if (this.fatalError) {
2816
- throw this.fatalError instanceof Error ? this.fatalError : new Error(String(this.fatalError));
3998
+ await this.stoppedSignal.wait();
3999
+ }
4000
+ markFailed(error, trigger) {
4001
+ if (this.lifecycle.is("not_started")) {
4002
+ return false;
2817
4003
  }
4004
+ return this.lifecycle.fail(error, trigger);
2818
4005
  }
2819
4006
  getContexts() {
2820
4007
  return [...this.contexts.values()];
@@ -2827,14 +4014,23 @@ var AgentRuntime = class {
2827
4014
  await this.getOrCreateExecution(roomId).bootstrapMessage(message);
2828
4015
  }
2829
4016
  async resetRoomSession(roomId, timeoutMs) {
2830
- return this.teardownExecution(roomId, timeoutMs);
4017
+ return await this.teardownExecution(roomId, timeoutMs);
2831
4018
  }
2832
4019
  async teardownExecution(roomId, timeoutMs) {
2833
4020
  const execution = this.executions.get(roomId);
2834
- const graceful = execution ? await execution.stop(timeoutMs) : true;
4021
+ const errors = [];
4022
+ let graceful = true;
4023
+ if (execution) {
4024
+ await isolateTeardown(errors, async () => {
4025
+ graceful = await execution.stop(timeoutMs);
4026
+ });
4027
+ }
2835
4028
  this.executions.delete(roomId);
2836
4029
  this.contexts.delete(roomId);
2837
- await this.onSessionCleanup(roomId);
4030
+ await isolateTeardown(errors, () => this.onSessionCleanup(roomId));
4031
+ if (errors.length > 0) {
4032
+ throw combineTeardownErrors(errors, "AgentRuntime failed to tear down cleanly");
4033
+ }
2838
4034
  return graceful;
2839
4035
  }
2840
4036
  getOrCreateExecution(roomId) {
@@ -2895,15 +4091,20 @@ var AgentRuntime = class {
2895
4091
  return context;
2896
4092
  }
2897
4093
  async failRuntime(error, event) {
2898
- if (!this.fatalError) {
2899
- this.fatalError = error;
2900
- this.running = false;
4094
+ if (this.markFailed(error, "runtime-error")) {
2901
4095
  this.logger.error("Fatal runtime error handling platform event", {
2902
4096
  eventType: event.type,
2903
4097
  roomId: event.roomId,
2904
4098
  error
2905
4099
  });
2906
4100
  this.notifyOnError(error, event);
4101
+ } else {
4102
+ this.logger.debug("Runtime error after the lifecycle already ended", {
4103
+ status: this.lifecycle.state.status,
4104
+ eventType: event.type,
4105
+ roomId: event.roomId,
4106
+ error
4107
+ });
2907
4108
  }
2908
4109
  this.presence.abortEventLoop();
2909
4110
  }
@@ -3104,9 +4305,11 @@ var ContactCallbackTools = class {
3104
4305
  rest;
3105
4306
  roomId;
3106
4307
  contactTools;
3107
- constructor(rest, roomId) {
4308
+ logger;
4309
+ constructor(rest, roomId, logger) {
3108
4310
  this.rest = rest;
3109
4311
  this.roomId = roomId;
4312
+ this.logger = resolveLogger(logger);
3110
4313
  const hasContactMethods = Boolean(
3111
4314
  rest.listContacts || rest.addContact || rest.removeContact || rest.listContactRequests || rest.respondContactRequest
3112
4315
  );
@@ -3141,14 +4344,22 @@ var ContactCallbackTools = class {
3141
4344
  if (!this.rest.createChatEvent) {
3142
4345
  throw new UnsupportedFeatureError("Event sending is not available in current REST adapter");
3143
4346
  }
3144
- return this.rest.createChatEvent(
3145
- roomId,
3146
- {
3147
- content,
3148
- messageType,
3149
- ...metadata ? { metadata } : {}
3150
- }
3151
- );
4347
+ try {
4348
+ return await this.rest.createChatEvent(
4349
+ roomId,
4350
+ {
4351
+ content,
4352
+ messageType,
4353
+ ...metadata ? { metadata } : {}
4354
+ }
4355
+ );
4356
+ } catch (error) {
4357
+ this.logger.warn("contact callback chat event send failed", { roomId, messageType, error });
4358
+ return { ok: false, status: "failed", message: asErrorMessage(error) };
4359
+ }
4360
+ }
4361
+ async sendFailure(failure) {
4362
+ return sendFailureViaEvent(this.sendEvent.bind(this), failure);
3152
4363
  }
3153
4364
  async addParticipant() {
3154
4365
  throw new UnsupportedFeatureError("addParticipant is unavailable for contact callbacks");
@@ -3379,7 +4590,7 @@ var ContactEventHandler = class {
3379
4590
  constructor(options) {
3380
4591
  this.config = options.config;
3381
4592
  this.rest = options.rest;
3382
- this.logger = options.logger ?? new NoopLogger();
4593
+ this.logger = resolveLogger(options.logger);
3383
4594
  this.onBroadcast = options.onBroadcast;
3384
4595
  this.onHubEvent = options.onHubEvent;
3385
4596
  this.onHubInit = options.onHubInit;
@@ -3429,7 +4640,7 @@ var ContactEventHandler = class {
3429
4640
  this.logger.warn("Contact event callback strategy configured but no onEvent callback provided");
3430
4641
  return;
3431
4642
  }
3432
- const callbackTools = tools ?? new ContactCallbackTools(this.rest, event.roomId);
4643
+ const callbackTools = tools ?? new ContactCallbackTools(this.rest, event.roomId, this.logger);
3433
4644
  try {
3434
4645
  await callback(event, callbackTools);
3435
4646
  } catch (error) {
@@ -3514,7 +4725,8 @@ var ContactEventHandler = class {
3514
4725
  const msg = event.payload.message ? `
3515
4726
  Message: "${event.payload.message}"` : "";
3516
4727
  const handle = normalizeHandle(event.payload.from_handle);
3517
- return `[Contact Request] ${event.payload.from_name} (${handle}) wants to connect.${msg}
4728
+ const name = displayName(event.payload.from_name);
4729
+ return `[Contact Request] ${name} (${handle}) wants to connect.${msg}
3518
4730
  Request ID: ${event.payload.id}`;
3519
4731
  }
3520
4732
  case "contact_request_updated": {
@@ -3533,7 +4745,8 @@ Request ID: ${event.payload.id}`;
3533
4745
  }
3534
4746
  case "contact_added": {
3535
4747
  const handle = normalizeHandle(event.payload.handle);
3536
- return `[Contact Added] ${event.payload.name} (${handle}) is now a contact.
4748
+ const name = displayName(event.payload.name);
4749
+ return `[Contact Added] ${name} (${handle}) is now a contact.
3537
4750
  Type: ${event.payload.type}, ID: ${event.payload.id}`;
3538
4751
  }
3539
4752
  case "contact_removed":
@@ -3545,7 +4758,7 @@ Type: ${event.payload.type}, ID: ${event.payload.id}`;
3545
4758
  switch (event.type) {
3546
4759
  case "contact_added": {
3547
4760
  const handle = normalizeHandle(event.payload.handle);
3548
- return `[Contacts]: ${handle} (${event.payload.name}) is now a contact`;
4761
+ return `[Contacts]: ${handle} (${displayName(event.payload.name)}) is now a contact`;
3549
4762
  }
3550
4763
  case "contact_removed":
3551
4764
  return `[Contacts]: Contact ${event.payload.id} was removed`;
@@ -3676,10 +4889,14 @@ function normalizeHandle(handle) {
3676
4889
  if (!handle) return "@unknown";
3677
4890
  return handle.startsWith("@") ? handle : `@${handle}`;
3678
4891
  }
4892
+ function displayName(name) {
4893
+ return name?.trim() || "Unknown contact";
4894
+ }
3679
4895
 
3680
4896
  // src/runtime/PlatformRuntime.ts
3681
4897
  var MAX_MESSAGE_RETRIES = 4294967295;
3682
4898
  var isValidRetryCount = (value) => Number.isInteger(value) && value >= 0 && value <= MAX_MESSAGE_RETRIES;
4899
+ var START_CLEANUP_TRIGGER = "start-failed";
3683
4900
  var PlatformRuntime = class {
3684
4901
  _agentId;
3685
4902
  _apiKey;
@@ -3696,12 +4913,14 @@ var PlatformRuntime = class {
3696
4913
  _onParticipantRemoved;
3697
4914
  _roomFilter;
3698
4915
  _contextFactory;
4916
+ lifecycle;
3699
4917
  linkInstance;
3700
4918
  initPromise = null;
3701
4919
  runtime;
3702
4920
  contactHandler;
3703
4921
  activeAdapter;
3704
- stopping = false;
4922
+ startGate = new SingleFlight2();
4923
+ stopGate = new SingleFlight2();
3705
4924
  _agentName = "";
3706
4925
  _agentDescription = "";
3707
4926
  contactsSubscribed = false;
@@ -3727,17 +4946,30 @@ var PlatformRuntime = class {
3727
4946
  this._wsUrl = options.wsUrl;
3728
4947
  this._restUrl = options.restUrl;
3729
4948
  this.linkInstance = options.link;
3730
- this.linkOptions = options.linkOptions;
4949
+ this.linkOptions = {
4950
+ ...options.linkOptions,
4951
+ logger: options.logger ?? options.linkOptions?.logger
4952
+ };
3731
4953
  this.preprocessor = options.preprocessor ?? new DefaultPreprocessor();
3732
4954
  this.sessionConfig = options.sessionConfig;
3733
4955
  this.contactConfig = options.contactConfig;
3734
4956
  this.agentConfig = options.agentConfig;
3735
- this.logger = options.logger ?? new NoopLogger();
4957
+ this.logger = resolveLogger(options.logger);
3736
4958
  this.configuredIdentity = options.identity;
3737
4959
  this._onParticipantAdded = options.onParticipantAdded;
3738
4960
  this._onParticipantRemoved = options.onParticipantRemoved;
3739
4961
  this._roomFilter = options.roomFilter;
3740
4962
  this._contextFactory = options.contextFactory;
4963
+ this.lifecycle = new LifecycleTracker({ status: "not_started" }, {
4964
+ owner: "PlatformRuntime",
4965
+ logContext: { agentId: this._agentId },
4966
+ logger: this.logger,
4967
+ isLegalTransition: isLegalRuntimeTransition
4968
+ });
4969
+ }
4970
+ /** Current lifecycle state of this runtime. */
4971
+ get state() {
4972
+ return this.lifecycle.state;
3741
4973
  }
3742
4974
  get link() {
3743
4975
  if (!this.linkInstance) {
@@ -3757,6 +4989,14 @@ var PlatformRuntime = class {
3757
4989
  get contactConfiguration() {
3758
4990
  return this.contactConfig;
3759
4991
  }
4992
+ /**
4993
+ * Whether the contacts channel subscription is currently held.
4994
+ *
4995
+ * @deprecated Read {@link PlatformRuntime.state} for lifecycle questions: this
4996
+ * flag is only ever `true` while the runtime is running and is cleared on
4997
+ * teardown, so it conflates "contacts are subscribed" with "the runtime is
4998
+ * alive".
4999
+ */
3760
5000
  get isContactsSubscribed() {
3761
5001
  return this.contactsSubscribed;
3762
5002
  }
@@ -3779,8 +5019,7 @@ var PlatformRuntime = class {
3779
5019
  agentId: this._agentId,
3780
5020
  apiKey: this._apiKey,
3781
5021
  wsUrl: this._wsUrl,
3782
- restUrl: this._restUrl,
3783
- logger: this.logger
5022
+ restUrl: this._restUrl
3784
5023
  });
3785
5024
  }
3786
5025
  if (this.configuredIdentity) {
@@ -3792,11 +5031,49 @@ var PlatformRuntime = class {
3792
5031
  this._agentName = me.name;
3793
5032
  this._agentDescription = me.description ?? "";
3794
5033
  }
5034
+ /**
5035
+ * Connect the adapter to the platform.
5036
+ *
5037
+ * Repeated or concurrent calls join the in-flight start. Calling `start()`
5038
+ * while a `stop()` is still in flight rejects with a {@link RuntimeStateError}.
5039
+ * A successful start also re-arms teardown, so a runtime whose previous
5040
+ * `stop()` failed can be shut down properly on the next attempt.
5041
+ */
3795
5042
  async start(adapter) {
5043
+ await startWithGate({
5044
+ lifecycle: this.lifecycle,
5045
+ startGate: this.startGate,
5046
+ stopGate: this.stopGate,
5047
+ ownerName: "PlatformRuntime",
5048
+ runStart: () => this.runStart(adapter)
5049
+ });
5050
+ }
5051
+ async runStart(adapter) {
5052
+ const startState = this.lifecycle.state;
5053
+ try {
5054
+ await this.doStart(adapter, startState);
5055
+ } catch (error) {
5056
+ if (this.lifecycle.isCurrent(startState)) {
5057
+ this.lifecycle.fail(error, START_CLEANUP_TRIGGER);
5058
+ }
5059
+ throw error;
5060
+ }
5061
+ if (this.lifecycle.isCurrent(startState)) {
5062
+ this.lifecycle.transition({ status: "running" }, "started");
5063
+ }
5064
+ }
5065
+ async doStart(adapter, startState) {
5066
+ const assertNotSuperseded = () => {
5067
+ if (!this.lifecycle.isCurrent(startState)) {
5068
+ throw new RuntimeStateError("PlatformRuntime start was superseded by stop()");
5069
+ }
5070
+ };
3796
5071
  await this.initialize();
3797
- await adapter.onStarted(this._agentName, this._agentDescription);
5072
+ assertNotSuperseded();
3798
5073
  this.activeAdapter = adapter;
3799
5074
  try {
5075
+ await adapter.onStarted(this._agentName, this._agentDescription);
5076
+ assertNotSuperseded();
3800
5077
  this.contactHandler = new ContactEventHandler({
3801
5078
  config: this.contactConfig ?? { strategy: "disabled" },
3802
5079
  rest: this.link.rest,
@@ -3810,7 +5087,18 @@ var PlatformRuntime = class {
3810
5087
  onHubEvent: async (roomId, event) => {
3811
5088
  const runtime = this.runtime;
3812
5089
  if (!runtime) return;
3813
- await runtime.enqueueEvent(roomId, event);
5090
+ try {
5091
+ await runtime.enqueueEvent(roomId, event);
5092
+ } catch (error) {
5093
+ if (!(error instanceof RuntimeStateError)) {
5094
+ throw error;
5095
+ }
5096
+ this.logger.error("Dropped contact hub event for a stopped room execution", {
5097
+ roomId,
5098
+ eventType: event.type,
5099
+ error: error.message
5100
+ });
5101
+ }
3814
5102
  },
3815
5103
  onHubInit: async (roomId, systemPrompt) => {
3816
5104
  const runtime = this.runtime;
@@ -3833,10 +5121,14 @@ var PlatformRuntime = class {
3833
5121
  contextFactory: this._contextFactory
3834
5122
  });
3835
5123
  await this.runtime.start();
5124
+ assertNotSuperseded();
3836
5125
  this.contactsSubscribed = Boolean(this.link.capabilities.contacts);
3837
5126
  } catch (error) {
5127
+ if (!this.lifecycle.isCurrent(startState)) {
5128
+ throw error;
5129
+ }
3838
5130
  try {
3839
- await this.stop();
5131
+ await this.beginStop(void 0, START_CLEANUP_TRIGGER);
3840
5132
  } catch (stopError) {
3841
5133
  throw new AggregateError(
3842
5134
  [error, stopError],
@@ -3846,49 +5138,63 @@ var PlatformRuntime = class {
3846
5138
  throw error;
3847
5139
  }
3848
5140
  }
5141
+ /**
5142
+ * Tear the runtime down and release the platform connection.
5143
+ *
5144
+ * A concurrent second call joins the in-flight teardown and mirrors its
5145
+ * outcome — including rejecting with the *same* `Error` instance — rather than
5146
+ * reporting a graceful shutdown it did not perform. A failed teardown does not
5147
+ * disable future ones: a subsequent `start()` re-arms `stop()`.
5148
+ */
3849
5149
  async stop(timeoutMs) {
3850
- if (this.stopping) {
3851
- return true;
3852
- }
5150
+ return await this.beginStop(timeoutMs, "stop");
5151
+ }
5152
+ async beginStop(timeoutMs, trigger) {
5153
+ return await this.stopGate.run(() => this.runStop(timeoutMs, trigger));
5154
+ }
5155
+ async runStop(timeoutMs, trigger) {
5156
+ this.startGate.reset();
3853
5157
  const runtime = this.runtime;
3854
5158
  const adapter = this.activeAdapter;
3855
5159
  if (!runtime && !adapter) {
5160
+ if (this.lifecycle.is("failed")) {
5161
+ this.logger.debug("PlatformRuntime stop is resurfacing the recorded start failure", { error: this.lifecycle.state.error });
5162
+ throw this.lifecycle.state.error;
5163
+ }
5164
+ if (this.lifecycle.is("starting")) {
5165
+ this.lifecycle.transition({ status: "stopped" }, trigger);
5166
+ }
3856
5167
  return true;
3857
5168
  }
3858
- this.stopping = true;
5169
+ this.lifecycle.transition({ status: "stopping" }, trigger);
3859
5170
  this.runtime = void 0;
3860
5171
  this.contactHandler = void 0;
3861
5172
  this.contactsSubscribed = false;
3862
5173
  this.activeAdapter = void 0;
3863
5174
  let graceful = true;
3864
- let runtimeError = null;
5175
+ const errors = [];
3865
5176
  if (runtime) {
3866
- try {
5177
+ await isolateTeardown(errors, async () => {
3867
5178
  graceful = await runtime.stop(timeoutMs);
3868
- } catch (error) {
3869
- runtimeError = error;
3870
- }
3871
- }
3872
- try {
3873
- await adapter?.onRuntimeStop?.();
3874
- } catch (error) {
3875
- if (runtimeError) {
3876
- throw new AggregateError(
3877
- [runtimeError, error],
3878
- "PlatformRuntime stop failed and adapter cleanup also failed"
3879
- );
3880
- }
3881
- throw error;
5179
+ });
3882
5180
  }
3883
- if (runtimeError) {
3884
- throw runtimeError instanceof Error ? runtimeError : new Error(String(runtimeError));
5181
+ await isolateTeardown(errors, () => Promise.resolve(adapter?.onRuntimeStop?.()));
5182
+ if (errors.length > 0) {
5183
+ throw this.recordStopFailure(
5184
+ combineTeardownErrors(errors, "PlatformRuntime stop failed and adapter cleanup also failed")
5185
+ );
3885
5186
  }
3886
- this.stopping = false;
5187
+ this.lifecycle.transition({ status: "stopped" }, "stopped");
3887
5188
  return graceful;
3888
5189
  }
3889
5190
  async [Symbol.asyncDispose]() {
3890
5191
  await this.stop();
3891
5192
  }
5193
+ recordStopFailure(error) {
5194
+ const failure = toLifecycleError(error);
5195
+ this.lifecycle.fail(failure, "stop-failed");
5196
+ return failure;
5197
+ }
3892
5198
  async runForever() {
3893
5199
  if (!this.runtime) {
3894
5200
  throw new RuntimeStateError("Runtime not started");
@@ -4002,6 +5308,10 @@ async function runWithGracefulShutdown(agent, options) {
4002
5308
  export {
4003
5309
  deriveDefaultRestUrl,
4004
5310
  BandLink,
5311
+ isLegalRuntimeTransition,
5312
+ SingleFlight2 as SingleFlight,
5313
+ LifecycleTracker,
5314
+ startWithGate,
4005
5315
  Execution,
4006
5316
  AgentTools,
4007
5317
  ExecutionContext,