@band-ai/sdk 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapters.js CHANGED
@@ -35,7 +35,7 @@ import "./chunk-XWHWJP4I.js";
35
35
  import {
36
36
  ACPClientHistoryConverter,
37
37
  ACPServerHistoryConverter
38
- } from "./chunk-FYVLUGW7.js";
38
+ } from "./chunk-V5TSWS7P.js";
39
39
  import {
40
40
  A2AAdapter,
41
41
  A2AGatewayAdapter,
@@ -158,7 +158,13 @@ function normalizeMcpServers(mcpServers) {
158
158
  // src/adapters/acp/client.ts
159
159
  var BandACPClient = class {
160
160
  sessionChunks = /* @__PURE__ */ new Map();
161
- permissionHandlers = /* @__PURE__ */ new Map();
161
+ permissionHandler;
162
+ // The handler is connection-scoped and required at construction, so it is
163
+ // already in place before the agent process is spawned: there is no window
164
+ // in which a `session/request_permission` has nowhere to go.
165
+ constructor(permissionHandler) {
166
+ this.permissionHandler = permissionHandler;
167
+ }
162
168
  async sessionUpdate(params) {
163
169
  const chunk = toCollectedChunk(params.update);
164
170
  if (!chunk) {
@@ -169,35 +175,21 @@ var BandACPClient = class {
169
175
  this.sessionChunks.set(params.sessionId, existing);
170
176
  }
171
177
  async requestPermission(params) {
172
- const handler = this.permissionHandlers.get(params.sessionId);
173
- if (handler) {
174
- return handler(params);
175
- }
176
- return {
177
- outcome: {
178
- outcome: "cancelled"
179
- }
180
- };
178
+ return this.permissionHandler(params);
181
179
  }
182
- setPermissionHandler(sessionId, handler) {
183
- if (!handler) {
184
- this.permissionHandlers.delete(sessionId);
185
- return;
186
- }
187
- this.permissionHandlers.set(sessionId, handler);
188
- }
189
- resetSession(sessionId) {
180
+ // Named for the one thing it clears: collected chunks are per-turn, and a
181
+ // per-turn caller must not be able to reach anything with a longer life.
182
+ resetChunks(sessionId) {
190
183
  this.sessionChunks.delete(sessionId);
191
- this.permissionHandlers.delete(sessionId);
192
184
  }
193
185
  getCollectedText(sessionId) {
194
186
  return this.getCollectedChunks(sessionId).filter((chunk) => chunk.chunkType === "text").map((chunk) => chunk.content).join("");
195
187
  }
196
188
  getCollectedChunks(sessionId) {
197
189
  if (sessionId) {
198
- return [...this.sessionChunks.get(sessionId) ?? []];
190
+ return coalesceChunks(this.sessionChunks.get(sessionId) ?? []);
199
191
  }
200
- return [...this.sessionChunks.values()].flatMap((chunks) => chunks);
192
+ return [...this.sessionChunks.values()].flatMap((chunks) => coalesceChunks(chunks));
201
193
  }
202
194
  async extMethod(method, params) {
203
195
  if (method === "cursor/ask_question") {
@@ -240,7 +232,8 @@ var BandACPClient = class {
240
232
  this.appendChunk(sessionId, {
241
233
  chunkType: "plan",
242
234
  content: lines.join("\n"),
243
- metadata: {}
235
+ metadata: {},
236
+ streamed: false
244
237
  });
245
238
  }
246
239
  return;
@@ -251,7 +244,8 @@ var BandACPClient = class {
251
244
  this.appendChunk(sessionId, {
252
245
  chunkType: "text",
253
246
  content: `[Task completed] ${result}`,
254
- metadata: {}
247
+ metadata: {},
248
+ streamed: false
255
249
  });
256
250
  }
257
251
  }
@@ -262,19 +256,33 @@ var BandACPClient = class {
262
256
  this.sessionChunks.set(sessionId, existing);
263
257
  }
264
258
  };
259
+ function coalesceChunks(chunks) {
260
+ const result = [];
261
+ for (const chunk of chunks) {
262
+ const last = result[result.length - 1];
263
+ if (chunk.streamed && last?.streamed && last.chunkType === chunk.chunkType) {
264
+ last.content += chunk.content;
265
+ continue;
266
+ }
267
+ result.push({ ...chunk });
268
+ }
269
+ return result;
270
+ }
265
271
  function toCollectedChunk(update) {
266
272
  switch (update.sessionUpdate) {
267
273
  case "agent_message_chunk":
268
274
  return {
269
275
  chunkType: "text",
270
276
  content: extractTextFromContent(update.content),
271
- metadata: {}
277
+ metadata: {},
278
+ streamed: true
272
279
  };
273
280
  case "agent_thought_chunk":
274
281
  return {
275
282
  chunkType: "thought",
276
283
  content: extractTextFromContent(update.content),
277
- metadata: {}
284
+ metadata: {},
285
+ streamed: true
278
286
  };
279
287
  case "tool_call":
280
288
  return {
@@ -284,7 +292,8 @@ function toCollectedChunk(update) {
284
292
  tool_call_id: update.toolCallId,
285
293
  raw_input: update.rawInput,
286
294
  status: update.status ?? "pending"
287
- }
295
+ },
296
+ streamed: false
288
297
  };
289
298
  case "tool_call_update":
290
299
  return {
@@ -293,13 +302,15 @@ function toCollectedChunk(update) {
293
302
  metadata: {
294
303
  tool_call_id: update.toolCallId,
295
304
  status: update.status ?? "completed"
296
- }
305
+ },
306
+ streamed: false
297
307
  };
298
308
  case "plan":
299
309
  return {
300
310
  chunkType: "plan",
301
311
  content: update.entries.map((entry) => entry.content).join("\n"),
302
- metadata: {}
312
+ metadata: {},
313
+ streamed: false
303
314
  };
304
315
  default:
305
316
  return null;
@@ -382,10 +393,18 @@ var ACPClientAdapter = class extends SimpleAdapter {
382
393
  clientCapabilities;
383
394
  connectionFactory;
384
395
  roomToSession = /* @__PURE__ */ new Map();
396
+ sessionToRoom = /* @__PURE__ */ new Map();
385
397
  roomTools = /* @__PURE__ */ new Map();
386
398
  activeSessions = /* @__PURE__ */ new Set();
387
399
  bootstrappedSessions = /* @__PURE__ */ new Set();
388
400
  pendingPermissions = /* @__PURE__ */ new Map();
401
+ sessionsInFlight = /* @__PURE__ */ new Map();
402
+ roomTurnLocks = /* @__PURE__ */ new Map();
403
+ // Bumped each time a room starts a *new* establishment (never on a
404
+ // coalesced reuse) and whenever a room is torn down. An establishment
405
+ // captures its own value at the start; if the room has moved on by the
406
+ // time it would link/activate a session, it was superseded and must not.
407
+ roomGeneration = /* @__PURE__ */ new Map();
389
408
  resolvePermission;
390
409
  resolveSessionMode;
391
410
  permissionTimeoutMs;
@@ -439,17 +458,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
439
458
  this.rehydrate(history);
440
459
  }
441
460
  this.roomTools.set(context.roomId, tools);
461
+ await this.withRoomTurnLock(context.roomId, () => this.runTurn(message, tools, participantsMessage, contactsMessage, context));
462
+ }
463
+ async runTurn(message, tools, participantsMessage, contactsMessage, context) {
442
464
  const connection = await this.ensureConnection();
443
465
  const client = this.client;
444
466
  if (!client) {
445
467
  throw new Error("ACP client was not initialized");
446
468
  }
447
469
  const sessionId = await this.getOrCreateSession(context.roomId, connection);
448
- client.resetSession(sessionId);
449
- client.setPermissionHandler(
450
- sessionId,
451
- (params) => this.handlePermissionRequest(tools, context.roomId, params)
452
- );
470
+ client.resetChunks(sessionId);
453
471
  const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
454
472
  const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
455
473
  const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
@@ -482,15 +500,28 @@ ${messageWithContext}`;
482
500
  acp_client_room_id: context.roomId
483
501
  });
484
502
  }
503
+ // A per-room async mutex: `fn` for a given `roomId` never overlaps another
504
+ // call for that same room, while different rooms stay fully concurrent.
505
+ // The tracked tail (`this.roomTurnLocks`) always settles — via the
506
+ // trailing `.catch` — so one turn's failure can't wedge every later turn
507
+ // for the room; the real result/rejection is still `run`, returned to this
508
+ // call's own caller.
509
+ async withRoomTurnLock(roomId, fn) {
510
+ const previous = this.roomTurnLocks.get(roomId) ?? Promise.resolve();
511
+ const run = previous.then(fn, fn);
512
+ this.roomTurnLocks.set(roomId, run.catch(() => void 0));
513
+ return run;
514
+ }
485
515
  async onCleanup(roomId) {
486
- const sessionId = this.roomToSession.get(roomId);
487
- this.roomToSession.delete(roomId);
516
+ const sessionId = this.unlinkRoom(roomId);
488
517
  this.roomTools.delete(roomId);
518
+ this.sessionsInFlight.delete(roomId);
519
+ this.roomTurnLocks.delete(roomId);
520
+ this.nextRoomGeneration(roomId);
489
521
  if (sessionId) {
490
522
  this.activeSessions.delete(sessionId);
491
523
  this.bootstrappedSessions.delete(sessionId);
492
- this.client?.setPermissionHandler(sessionId, void 0);
493
- this.cancelPendingPermissions(sessionId);
524
+ this.cancelPendingPermissions(sessionId, "room-closed");
494
525
  }
495
526
  }
496
527
  async onRuntimeStop() {
@@ -502,8 +533,14 @@ ${messageWithContext}`;
502
533
  this.activeSessions.clear();
503
534
  this.bootstrappedSessions.clear();
504
535
  this.roomToSession.clear();
536
+ this.sessionToRoom.clear();
505
537
  this.roomTools.clear();
506
- this.cancelAllPendingPermissions();
538
+ this.sessionsInFlight.clear();
539
+ this.roomTurnLocks.clear();
540
+ for (const roomId of this.roomGeneration.keys()) {
541
+ this.nextRoomGeneration(roomId);
542
+ }
543
+ this.cancelAllPendingPermissions("adapter-stopped");
507
544
  this.client = null;
508
545
  this.connection = null;
509
546
  if (this.backend) {
@@ -520,10 +557,66 @@ ${messageWithContext}`;
520
557
  rehydrate(history) {
521
558
  for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
522
559
  if (!this.roomToSession.has(roomId)) {
523
- this.roomToSession.set(roomId, sessionId);
560
+ this.linkSession(roomId, sessionId);
524
561
  }
525
562
  }
526
563
  }
564
+ // The only writer of both session maps, so they cannot drift: replacing a
565
+ // room's session drops the old session's route, and a session id already
566
+ // routed to another room is refused rather than silently re-pointed — two
567
+ // rooms sharing one session id would make its permission requests
568
+ // unattributable. Returns whether the link was made — a caller that goes
569
+ // on to activate/configure/prompt a session regardless of a `false` here
570
+ // would use a session this room was refused, not just fail to route its
571
+ // permissions.
572
+ linkSession(roomId, sessionId) {
573
+ const routedRoomId = this.sessionToRoom.get(sessionId);
574
+ if (routedRoomId !== void 0 && routedRoomId !== roomId) {
575
+ this.safeWarn("refusing to route one ACP session to a second room", {
576
+ sessionId,
577
+ roomId,
578
+ routedRoomId
579
+ });
580
+ return false;
581
+ }
582
+ const replacedSessionId = this.roomToSession.get(roomId);
583
+ if (replacedSessionId !== void 0 && replacedSessionId !== sessionId) {
584
+ this.sessionToRoom.delete(replacedSessionId);
585
+ }
586
+ this.roomToSession.set(roomId, sessionId);
587
+ this.sessionToRoom.set(sessionId, roomId);
588
+ return true;
589
+ }
590
+ nextRoomGeneration(roomId) {
591
+ const next = (this.roomGeneration.get(roomId) ?? 0) + 1;
592
+ this.roomGeneration.set(roomId, next);
593
+ return next;
594
+ }
595
+ isCurrentGeneration(roomId, generation) {
596
+ return this.roomGeneration.get(roomId) === generation;
597
+ }
598
+ // The installed ACP SDK's `sendRequest` never rejects a pending call when
599
+ // its connection closes (no server response ever arrives to reject it
600
+ // with) — so a session-establishment RPC in flight when the subprocess
601
+ // dies would otherwise hang forever, wedging the room's `sessionsInFlight`
602
+ // entry along with it. Racing every such RPC against the connection's own
603
+ // `closed` promise gives it a real, prompt failure instead.
604
+ raceAgainstConnectionClose(connection, operation) {
605
+ let reject = () => void 0;
606
+ const closedRejection = new Promise((_resolve, rejectFn) => {
607
+ reject = rejectFn;
608
+ });
609
+ void connection.closed.then(() => reject(new Error("ACP connection closed while a session operation was still in flight")));
610
+ return Promise.race([operation, closedRejection]);
611
+ }
612
+ unlinkRoom(roomId) {
613
+ const sessionId = this.roomToSession.get(roomId);
614
+ this.roomToSession.delete(roomId);
615
+ if (sessionId !== void 0) {
616
+ this.sessionToRoom.delete(sessionId);
617
+ }
618
+ return sessionId;
619
+ }
527
620
  async ensureConnection() {
528
621
  if (this.connection && !this.connection.signal.aborted) {
529
622
  return this.connection;
@@ -543,7 +636,7 @@ ${messageWithContext}`;
543
636
  }
544
637
  async spawnConnection() {
545
638
  const acp = await acpModule.get();
546
- const client = new BandACPClient();
639
+ const client = new BandACPClient((params) => this.routePermissionRequest(params));
547
640
  const handle = await this.connectionFactory(client, {
548
641
  command: this.command,
549
642
  cwd: this.cwd,
@@ -569,6 +662,7 @@ ${messageWithContext}`;
569
662
  this.connectionHandle = null;
570
663
  this.connectionState = null;
571
664
  this.activeSessions.clear();
665
+ this.cancelAllPendingPermissions("connection-lost");
572
666
  }
573
667
  });
574
668
  return connection;
@@ -578,25 +672,56 @@ ${messageWithContext}`;
578
672
  if (existingSessionId && this.activeSessions.has(existingSessionId)) {
579
673
  return existingSessionId;
580
674
  }
675
+ const inFlight = this.sessionsInFlight.get(roomId);
676
+ if (inFlight) {
677
+ return inFlight;
678
+ }
679
+ const generation = this.nextRoomGeneration(roomId);
680
+ const establishing = this.establishSession(roomId, existingSessionId, connection, generation);
681
+ establishing.finally(() => {
682
+ if (this.sessionsInFlight.get(roomId) === establishing) {
683
+ this.sessionsInFlight.delete(roomId);
684
+ }
685
+ }).catch(() => void 0);
686
+ this.sessionsInFlight.set(roomId, establishing);
687
+ return establishing;
688
+ }
689
+ async establishSession(roomId, existingSessionId, connection, generation) {
581
690
  const mcpServers = await this.buildSessionMcpServers();
582
691
  if (existingSessionId) {
583
692
  const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
584
693
  if (restored.ok) {
694
+ this.linkOrAbandon(roomId, existingSessionId, generation);
585
695
  this.activeSessions.add(existingSessionId);
586
696
  this.bootstrappedSessions.add(existingSessionId);
587
697
  await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
588
698
  return existingSessionId;
589
699
  }
590
700
  }
591
- const created = await connection.newSession({
701
+ const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
592
702
  cwd: this.cwd,
593
703
  mcpServers
594
- });
595
- this.roomToSession.set(roomId, created.sessionId);
704
+ }));
705
+ this.linkOrAbandon(roomId, created.sessionId, generation);
596
706
  this.activeSessions.add(created.sessionId);
597
707
  await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
598
708
  return created.sessionId;
599
709
  }
710
+ // The single gate an establishment must pass before it's allowed to claim
711
+ // the room: it must still be the room's current generation (not
712
+ // superseded by a teardown or a fresher establishment while this one was
713
+ // awaiting an RPC), and its session id must not already belong to another
714
+ // room. Either failure throws — this establishment cannot silently
715
+ // continue to activate, configure, and prompt a session it has no right
716
+ // to use for this room.
717
+ linkOrAbandon(roomId, sessionId, generation) {
718
+ if (!this.isCurrentGeneration(roomId, generation)) {
719
+ throw new Error(`ACP session establishment for room "${roomId}" was superseded before it could be linked`);
720
+ }
721
+ if (!this.linkSession(roomId, sessionId)) {
722
+ throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
723
+ }
724
+ }
600
725
  // Best-effort: never throws, so a mode switch going wrong can't take a
601
726
  // session establishment down with it.
602
727
  async configureSessionMode(roomId, sessionId, modes, connection) {
@@ -675,7 +800,7 @@ ${messageWithContext}`;
675
800
  return { ok: false };
676
801
  }
677
802
  try {
678
- const restored = await restore();
803
+ const restored = await this.raceAgainstConnectionClose(connection, restore());
679
804
  return { ok: true, modes: restored?.modes };
680
805
  } catch {
681
806
  return { ok: false };
@@ -788,6 +913,36 @@ ${messageWithContext}`;
788
913
  "All Band MCP tool calls must include room_id."
789
914
  ].join("\n");
790
915
  }
916
+ // The connection's single permission entry point, and total by
917
+ // construction: every path resolves, nothing throws, and a request that
918
+ // can't be attributed to a live room is cancelled *and* warned rather than
919
+ // silently declined. `activeSessions` is the gate that keeps a dead
920
+ // session from raising a live prompt — `roomToSession` deliberately
921
+ // outlives a dropped connection so the session can be restored later.
922
+ async routePermissionRequest(params) {
923
+ const isActive = this.activeSessions.has(params.sessionId);
924
+ const roomId = isActive ? this.sessionToRoom.get(params.sessionId) : void 0;
925
+ const tools = roomId === void 0 ? void 0 : this.roomTools.get(roomId);
926
+ if (roomId === void 0 || !tools) {
927
+ this.safeWarn("cancelling a permission request that maps to no live room", {
928
+ sessionId: params.sessionId,
929
+ toolName: params.toolCall?.title,
930
+ sessionActive: isActive,
931
+ roomId
932
+ });
933
+ return { outcome: { outcome: "cancelled" } };
934
+ }
935
+ try {
936
+ return await this.handlePermissionRequest(tools, roomId, params);
937
+ } catch (error) {
938
+ this.safeWarn("permission handling failed; cancelling the request", {
939
+ sessionId: params.sessionId,
940
+ roomId,
941
+ error: String(error)
942
+ });
943
+ return { outcome: { outcome: "cancelled" } };
944
+ }
945
+ }
791
946
  async handlePermissionRequest(tools, roomId, params) {
792
947
  const toolName = params.toolCall.title ?? "unknown";
793
948
  const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
@@ -795,28 +950,52 @@ ${messageWithContext}`;
795
950
  if (controller) {
796
951
  this.trackPending(params.sessionId, controller);
797
952
  }
798
- const [, chosenId] = await Promise.all([
799
- // This is the room's only "a permission request is pending" signal,
800
- // and the only one other room participants ever see — started
801
- // immediately rather than serialized in front of a manual wait that
802
- // can take up to `permissionTimeoutMs`.
953
+ let requestEventFailed = false;
954
+ const [, resolvedChosenId] = await Promise.all([
955
+ // Started immediately rather than serialized in front of a manual
956
+ // wait that can take up to `permissionTimeoutMs`.
803
957
  tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
804
958
  permission_request: true,
805
959
  tool_name: toolName,
806
960
  tool_call_id: params.toolCall.toolCallId,
807
961
  acp_session_id: params.sessionId,
808
962
  auto_allowed: autoSelection !== void 0 && autoSelection !== null
963
+ }).catch((error) => {
964
+ requestEventFailed = true;
965
+ this.safeWarn("failed to post the permission-requested event; cancelling the request", {
966
+ roomId,
967
+ sessionId: params.sessionId,
968
+ error: String(error)
969
+ });
970
+ if (controller) {
971
+ this.abandon(controller, "no-answer");
972
+ }
809
973
  }),
810
- controller ? this.resolveManually(params.sessionId, params, controller) : Promise.resolve(autoSelection?.optionId)
974
+ controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
811
975
  ]);
812
- return this.toResponse(chosenId, params.options);
976
+ const chosenId = requestEventFailed ? void 0 : resolvedChosenId;
977
+ const response = this.toResponse(chosenId, params.options, { roomId, sessionId: params.sessionId });
978
+ if (controller && !controller.signal.aborted) {
979
+ this.abandon(controller, response.outcome.outcome === "selected" ? "settled" : "no-answer");
980
+ }
981
+ return response;
813
982
  }
814
983
  // `undefined`, or an id absent from this request's own `options` (a buggy
815
984
  // or stale caller), both map to `cancelled` — never silently treated as a
816
985
  // deny. A real match, reject-kind options included, maps to `selected`.
817
- toResponse(chosenId, options) {
818
- const matched = chosenId !== void 0 && options.some((option) => option.optionId === chosenId);
819
- return matched ? { outcome: { outcome: "selected", optionId: chosenId } } : { outcome: { outcome: "cancelled" } };
986
+ toResponse(chosenId, options, context) {
987
+ if (chosenId === void 0) {
988
+ return { outcome: { outcome: "cancelled" } };
989
+ }
990
+ if (!options.some((option) => option.optionId === chosenId)) {
991
+ this.safeWarn("resolvePermission chose an option this request does not offer", {
992
+ ...context,
993
+ chosenId,
994
+ optionIds: options.map((option) => option.optionId)
995
+ });
996
+ return { outcome: { outcome: "cancelled" } };
997
+ }
998
+ return { outcome: { outcome: "selected", optionId: chosenId } };
820
999
  }
821
1000
  // A caller-supplied `Logger` isn't guaranteed to be synchronous or
822
1001
  // non-throwing. Every best-effort warning in this file routes through here
@@ -830,41 +1009,56 @@ ${messageWithContext}`;
830
1009
  } catch {
831
1010
  }
832
1011
  }
833
- // Races the caller-supplied resolver against a timeout and against
834
- // `controller`'s own abort signal aborted externally by
835
- // `cancelPendingPermissions`/`cancelAllPendingPermissions` (fired from
836
- // `onCleanup`/`stop()` below) when a room or the whole adapter tears down
837
- // while this is still pending. `controller` is the same object tracked in
838
- // `pendingPermissions` by the caller, so there is exactly one cancellation
839
- // channel here, not a second hand-rolled one alongside it.
840
- async resolveManually(sessionId, params, controller) {
841
- let timer;
842
- const cancelled = new Promise((resolve) => {
1012
+ // Races the caller-supplied resolver against `controller`'s abort signal,
1013
+ // which is the request's single termination channel: the timeout below
1014
+ // fires it with `"timeout"`, and `cancelPendingPermissions` /
1015
+ // `cancelAllPendingPermissions` fire it with the reason their caller
1016
+ // supplies. `controller` is the same object tracked in `pendingPermissions`,
1017
+ // so there is exactly one cancellation channel here, not a second
1018
+ // hand-rolled one alongside it.
1019
+ async resolveManually(roomId, params, controller) {
1020
+ const abandoned = new Promise((resolve) => {
843
1021
  controller.signal.addEventListener("abort", () => resolve(void 0));
844
1022
  });
1023
+ const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
845
1024
  try {
846
- const timeout = new Promise((resolve) => {
847
- timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
848
- });
849
1025
  return await Promise.race([
850
1026
  // `resolvePermission` is caller-supplied; nothing guarantees it's
851
1027
  // `async` or otherwise well-behaved. `Promise.resolve().then(...)`
852
1028
  // normalizes a synchronous throw the same way it normalizes a
853
1029
  // rejected promise, so both land in the `.catch` below rather than
854
1030
  // escaping this race uncaught.
855
- Promise.resolve().then(() => this.resolvePermission(params, controller.signal)).catch((error) => {
1031
+ Promise.resolve().then(() => this.resolvePermission({ ...params, roomId }, controller.signal)).then((chosenId) => this.discardLateAnswer(chosenId, controller, roomId, params.sessionId)).catch((error) => {
856
1032
  this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
857
1033
  return void 0;
858
1034
  }),
859
- timeout,
860
- cancelled
1035
+ abandoned
861
1036
  ]);
862
1037
  } finally {
863
1038
  clearTimeout(timer);
864
- this.untrackPending(sessionId, controller);
865
- controller.abort();
1039
+ this.untrackPending(params.sessionId, controller);
866
1040
  }
867
1041
  }
1042
+ // An answer that lands after the request was given up on can no longer be
1043
+ // honoured — the response has already gone back to the agent. It is dropped
1044
+ // either way; warning is what makes "my click did nothing" explicable.
1045
+ discardLateAnswer(chosenId, controller, roomId, sessionId) {
1046
+ if (!controller.signal.aborted || chosenId === void 0) {
1047
+ return chosenId;
1048
+ }
1049
+ this.safeWarn("resolvePermission answered after the request was abandoned; discarding", {
1050
+ roomId,
1051
+ sessionId,
1052
+ chosenId,
1053
+ reason: String(controller.signal.reason)
1054
+ });
1055
+ return void 0;
1056
+ }
1057
+ // The only place a permission's controller is ever aborted, so every
1058
+ // `signal.reason` a consumer can observe comes from the documented union.
1059
+ abandon(controller, reason) {
1060
+ controller.abort(reason);
1061
+ }
868
1062
  trackPending(sessionId, controller) {
869
1063
  const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
870
1064
  pending.add(controller);
@@ -877,14 +1071,14 @@ ${messageWithContext}`;
877
1071
  this.pendingPermissions.delete(sessionId);
878
1072
  }
879
1073
  }
880
- cancelPendingPermissions(sessionId) {
1074
+ cancelPendingPermissions(sessionId, reason) {
881
1075
  for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
882
- controller.abort();
1076
+ this.abandon(controller, reason);
883
1077
  }
884
1078
  }
885
- cancelAllPendingPermissions() {
1079
+ cancelAllPendingPermissions(reason) {
886
1080
  for (const sessionId of this.pendingPermissions.keys()) {
887
- this.cancelPendingPermissions(sessionId);
1081
+ this.cancelPendingPermissions(sessionId, reason);
888
1082
  }
889
1083
  }
890
1084
  async flushChunks(input) {
@@ -207,6 +207,7 @@ async function readResponseBody(response) {
207
207
  }
208
208
 
209
209
  // src/platform/streaming/PhoenixChannelsTransport.ts
210
+ import { agentControlTopic } from "@band-ai/band-sdk-core";
210
211
  var PhoenixChannelsTransport = class {
211
212
  socket;
212
213
  agentId;
@@ -473,7 +474,7 @@ var PhoenixChannelsTransport = class {
473
474
  if (!this.agentId) {
474
475
  return;
475
476
  }
476
- await this.join(`agent_control:${this.agentId}`, {
477
+ await this.join(agentControlTopic(this.agentId), {
477
478
  supersede: (payload) => {
478
479
  const reason = parseSupersedeDisconnectReason(payload);
479
480
  if (!reason) {
@@ -574,6 +575,12 @@ function getSocketChannelCount(socket) {
574
575
 
575
576
  // src/platform/BandLink.ts
576
577
  import { BandClient } from "@band-ai/rest-client";
578
+ import {
579
+ agentContactsTopic,
580
+ agentRoomsTopic,
581
+ chatRoomTopic,
582
+ roomParticipantsTopic
583
+ } from "@band-ai/band-sdk-core";
577
584
  var DEFAULT_WS_URL = "wss://app.band.ai/api/v1/socket";
578
585
  function deriveDefaultRestUrl(wsUrl) {
579
586
  const parsed = new URL(wsUrl);
@@ -582,8 +589,8 @@ function deriveDefaultRestUrl(wsUrl) {
582
589
  }
583
590
  function roomTopics(roomId) {
584
591
  return {
585
- chat: `chat_room:${roomId}`,
586
- participants: `room_participants:${roomId}`
592
+ chat: chatRoomTopic(roomId),
593
+ participants: roomParticipantsTopic(roomId)
587
594
  };
588
595
  }
589
596
  function toPlatformMessage(roomId, message) {
@@ -703,7 +710,7 @@ var BandLink = class {
703
710
  this.eventQueue.push(event);
704
711
  }
705
712
  async subscribeAgentRooms() {
706
- await this.transport.join(`agent_rooms:${this.agentId}`, {
713
+ await this.transport.join(agentRoomsTopic(this.agentId), {
707
714
  room_added: (payload) => {
708
715
  const roomId = typeof payload.id === "string" ? payload.id : "";
709
716
  this.emit("room_added", payload, roomId);
@@ -756,7 +763,7 @@ var BandLink = class {
756
763
  }
757
764
  async subscribeAgentContacts() {
758
765
  assertCapability(this.capabilities, "contacts", "Contacts streaming");
759
- await this.transport.join(`agent_contacts:${this.agentId}`, {
766
+ await this.transport.join(agentContactsTopic(this.agentId), {
760
767
  contact_request_received: (payload) => {
761
768
  this.emit("contact_request_received", payload, null);
762
769
  },
@@ -772,7 +779,7 @@ var BandLink = class {
772
779
  });
773
780
  }
774
781
  async unsubscribeAgentContacts() {
775
- await this.transport.leave(`agent_contacts:${this.agentId}`);
782
+ await this.transport.leave(agentContactsTopic(this.agentId));
776
783
  }
777
784
  async nextEvent(signal) {
778
785
  if (this.terminalDisconnectError) {
@@ -3,6 +3,9 @@ var ACPClientHistoryConverter = class {
3
3
  convert(raw) {
4
4
  const roomToSession = {};
5
5
  for (const entry of raw) {
6
+ if (entry.message_type !== "task") {
7
+ continue;
8
+ }
6
9
  const metadataRaw = entry.metadata;
7
10
  if (!metadataRaw || typeof metadataRaw !== "object" || Array.isArray(metadataRaw)) {
8
11
  continue;
@@ -10,7 +13,7 @@ var ACPClientHistoryConverter = class {
10
13
  const metadata = metadataRaw;
11
14
  const sessionId = metadata.acp_client_session_id;
12
15
  const roomId = metadata.acp_client_room_id;
13
- if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId) {
16
+ if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId && roomId === entry.room_id) {
14
17
  roomToSession[roomId] = sessionId;
15
18
  }
16
19
  }