@band-ai/sdk 0.3.0 → 0.3.2

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,26 +175,12 @@ 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
- };
181
- }
182
- setPermissionHandler(sessionId, handler) {
183
- if (!handler) {
184
- this.permissionHandlers.delete(sessionId);
185
- return;
186
- }
187
- this.permissionHandlers.set(sessionId, handler);
178
+ return this.permissionHandler(params);
188
179
  }
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("");
@@ -382,13 +374,21 @@ var ACPClientAdapter = class extends SimpleAdapter {
382
374
  clientCapabilities;
383
375
  connectionFactory;
384
376
  roomToSession = /* @__PURE__ */ new Map();
377
+ sessionToRoom = /* @__PURE__ */ new Map();
385
378
  roomTools = /* @__PURE__ */ new Map();
386
379
  activeSessions = /* @__PURE__ */ new Set();
387
380
  bootstrappedSessions = /* @__PURE__ */ new Set();
388
381
  pendingPermissions = /* @__PURE__ */ new Map();
382
+ sessionsInFlight = /* @__PURE__ */ new Map();
383
+ roomTurnLocks = /* @__PURE__ */ new Map();
384
+ // Bumped each time a room starts a *new* establishment (never on a
385
+ // coalesced reuse) and whenever a room is torn down. An establishment
386
+ // captures its own value at the start; if the room has moved on by the
387
+ // time it would link/activate a session, it was superseded and must not.
388
+ roomGeneration = /* @__PURE__ */ new Map();
389
389
  resolvePermission;
390
+ resolveSessionMode;
390
391
  permissionTimeoutMs;
391
- requestedPermissionMode;
392
392
  logger;
393
393
  backend = null;
394
394
  backendPromise = null;
@@ -417,13 +417,10 @@ var ACPClientAdapter = class extends SimpleAdapter {
417
417
  this.clientCapabilities = options.clientCapabilities;
418
418
  this.connectionFactory = options.connectionFactory ?? createSubprocessConnection;
419
419
  this.resolvePermission = options.resolvePermission;
420
+ this.resolveSessionMode = options.resolveSessionMode;
420
421
  this.logger = options.logger ?? new NoopLogger();
421
422
  this.permissionTimeoutMs = options.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS;
422
- this.requestedPermissionMode = options.requestedPermissionMode;
423
- if (this.requestedPermissionMode !== void 0 && this.requestedPermissionMode.length === 0) {
424
- throw new ValidationError("requestedPermissionMode must be a non-empty mode id, got an empty string");
425
- }
426
- if (this.resolvePermission && (!Number.isFinite(this.permissionTimeoutMs) || this.permissionTimeoutMs <= 0)) {
423
+ if ((this.resolvePermission || this.resolveSessionMode) && (!Number.isFinite(this.permissionTimeoutMs) || this.permissionTimeoutMs <= 0)) {
427
424
  throw new ValidationError(`permissionTimeoutMs must be a positive finite number, got ${options.permissionTimeoutMs}`);
428
425
  }
429
426
  }
@@ -442,17 +439,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
442
439
  this.rehydrate(history);
443
440
  }
444
441
  this.roomTools.set(context.roomId, tools);
442
+ await this.withRoomTurnLock(context.roomId, () => this.runTurn(message, tools, participantsMessage, contactsMessage, context));
443
+ }
444
+ async runTurn(message, tools, participantsMessage, contactsMessage, context) {
445
445
  const connection = await this.ensureConnection();
446
446
  const client = this.client;
447
447
  if (!client) {
448
448
  throw new Error("ACP client was not initialized");
449
449
  }
450
450
  const sessionId = await this.getOrCreateSession(context.roomId, connection);
451
- client.resetSession(sessionId);
452
- client.setPermissionHandler(
453
- sessionId,
454
- (params) => this.handlePermissionRequest(tools, context.roomId, params)
455
- );
451
+ client.resetChunks(sessionId);
456
452
  const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
457
453
  const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
458
454
  const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
@@ -485,15 +481,28 @@ ${messageWithContext}`;
485
481
  acp_client_room_id: context.roomId
486
482
  });
487
483
  }
484
+ // A per-room async mutex: `fn` for a given `roomId` never overlaps another
485
+ // call for that same room, while different rooms stay fully concurrent.
486
+ // The tracked tail (`this.roomTurnLocks`) always settles — via the
487
+ // trailing `.catch` — so one turn's failure can't wedge every later turn
488
+ // for the room; the real result/rejection is still `run`, returned to this
489
+ // call's own caller.
490
+ async withRoomTurnLock(roomId, fn) {
491
+ const previous = this.roomTurnLocks.get(roomId) ?? Promise.resolve();
492
+ const run = previous.then(fn, fn);
493
+ this.roomTurnLocks.set(roomId, run.catch(() => void 0));
494
+ return run;
495
+ }
488
496
  async onCleanup(roomId) {
489
- const sessionId = this.roomToSession.get(roomId);
490
- this.roomToSession.delete(roomId);
497
+ const sessionId = this.unlinkRoom(roomId);
491
498
  this.roomTools.delete(roomId);
499
+ this.sessionsInFlight.delete(roomId);
500
+ this.roomTurnLocks.delete(roomId);
501
+ this.nextRoomGeneration(roomId);
492
502
  if (sessionId) {
493
503
  this.activeSessions.delete(sessionId);
494
504
  this.bootstrappedSessions.delete(sessionId);
495
- this.client?.setPermissionHandler(sessionId, void 0);
496
- this.cancelPendingPermissions(sessionId);
505
+ this.cancelPendingPermissions(sessionId, "room-closed");
497
506
  }
498
507
  }
499
508
  async onRuntimeStop() {
@@ -505,8 +514,14 @@ ${messageWithContext}`;
505
514
  this.activeSessions.clear();
506
515
  this.bootstrappedSessions.clear();
507
516
  this.roomToSession.clear();
517
+ this.sessionToRoom.clear();
508
518
  this.roomTools.clear();
509
- this.cancelAllPendingPermissions();
519
+ this.sessionsInFlight.clear();
520
+ this.roomTurnLocks.clear();
521
+ for (const roomId of this.roomGeneration.keys()) {
522
+ this.nextRoomGeneration(roomId);
523
+ }
524
+ this.cancelAllPendingPermissions("adapter-stopped");
510
525
  this.client = null;
511
526
  this.connection = null;
512
527
  if (this.backend) {
@@ -523,10 +538,66 @@ ${messageWithContext}`;
523
538
  rehydrate(history) {
524
539
  for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
525
540
  if (!this.roomToSession.has(roomId)) {
526
- this.roomToSession.set(roomId, sessionId);
541
+ this.linkSession(roomId, sessionId);
527
542
  }
528
543
  }
529
544
  }
545
+ // The only writer of both session maps, so they cannot drift: replacing a
546
+ // room's session drops the old session's route, and a session id already
547
+ // routed to another room is refused rather than silently re-pointed — two
548
+ // rooms sharing one session id would make its permission requests
549
+ // unattributable. Returns whether the link was made — a caller that goes
550
+ // on to activate/configure/prompt a session regardless of a `false` here
551
+ // would use a session this room was refused, not just fail to route its
552
+ // permissions.
553
+ linkSession(roomId, sessionId) {
554
+ const routedRoomId = this.sessionToRoom.get(sessionId);
555
+ if (routedRoomId !== void 0 && routedRoomId !== roomId) {
556
+ this.safeWarn("refusing to route one ACP session to a second room", {
557
+ sessionId,
558
+ roomId,
559
+ routedRoomId
560
+ });
561
+ return false;
562
+ }
563
+ const replacedSessionId = this.roomToSession.get(roomId);
564
+ if (replacedSessionId !== void 0 && replacedSessionId !== sessionId) {
565
+ this.sessionToRoom.delete(replacedSessionId);
566
+ }
567
+ this.roomToSession.set(roomId, sessionId);
568
+ this.sessionToRoom.set(sessionId, roomId);
569
+ return true;
570
+ }
571
+ nextRoomGeneration(roomId) {
572
+ const next = (this.roomGeneration.get(roomId) ?? 0) + 1;
573
+ this.roomGeneration.set(roomId, next);
574
+ return next;
575
+ }
576
+ isCurrentGeneration(roomId, generation) {
577
+ return this.roomGeneration.get(roomId) === generation;
578
+ }
579
+ // The installed ACP SDK's `sendRequest` never rejects a pending call when
580
+ // its connection closes (no server response ever arrives to reject it
581
+ // with) — so a session-establishment RPC in flight when the subprocess
582
+ // dies would otherwise hang forever, wedging the room's `sessionsInFlight`
583
+ // entry along with it. Racing every such RPC against the connection's own
584
+ // `closed` promise gives it a real, prompt failure instead.
585
+ raceAgainstConnectionClose(connection, operation) {
586
+ let reject = () => void 0;
587
+ const closedRejection = new Promise((_resolve, rejectFn) => {
588
+ reject = rejectFn;
589
+ });
590
+ void connection.closed.then(() => reject(new Error("ACP connection closed while a session operation was still in flight")));
591
+ return Promise.race([operation, closedRejection]);
592
+ }
593
+ unlinkRoom(roomId) {
594
+ const sessionId = this.roomToSession.get(roomId);
595
+ this.roomToSession.delete(roomId);
596
+ if (sessionId !== void 0) {
597
+ this.sessionToRoom.delete(sessionId);
598
+ }
599
+ return sessionId;
600
+ }
530
601
  async ensureConnection() {
531
602
  if (this.connection && !this.connection.signal.aborted) {
532
603
  return this.connection;
@@ -546,7 +617,7 @@ ${messageWithContext}`;
546
617
  }
547
618
  async spawnConnection() {
548
619
  const acp = await acpModule.get();
549
- const client = new BandACPClient();
620
+ const client = new BandACPClient((params) => this.routePermissionRequest(params));
550
621
  const handle = await this.connectionFactory(client, {
551
622
  command: this.command,
552
623
  cwd: this.cwd,
@@ -572,6 +643,7 @@ ${messageWithContext}`;
572
643
  this.connectionHandle = null;
573
644
  this.connectionState = null;
574
645
  this.activeSessions.clear();
646
+ this.cancelAllPendingPermissions("connection-lost");
575
647
  }
576
648
  });
577
649
  return connection;
@@ -581,69 +653,140 @@ ${messageWithContext}`;
581
653
  if (existingSessionId && this.activeSessions.has(existingSessionId)) {
582
654
  return existingSessionId;
583
655
  }
656
+ const inFlight = this.sessionsInFlight.get(roomId);
657
+ if (inFlight) {
658
+ return inFlight;
659
+ }
660
+ const generation = this.nextRoomGeneration(roomId);
661
+ const establishing = this.establishSession(roomId, existingSessionId, connection, generation);
662
+ establishing.finally(() => {
663
+ if (this.sessionsInFlight.get(roomId) === establishing) {
664
+ this.sessionsInFlight.delete(roomId);
665
+ }
666
+ }).catch(() => void 0);
667
+ this.sessionsInFlight.set(roomId, establishing);
668
+ return establishing;
669
+ }
670
+ async establishSession(roomId, existingSessionId, connection, generation) {
584
671
  const mcpServers = await this.buildSessionMcpServers();
585
672
  if (existingSessionId) {
586
673
  const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
587
674
  if (restored.ok) {
675
+ this.linkOrAbandon(roomId, existingSessionId, generation);
588
676
  this.activeSessions.add(existingSessionId);
589
677
  this.bootstrappedSessions.add(existingSessionId);
590
- await this.applyRequestedPermissionMode(connection, existingSessionId, restored.modes);
678
+ await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
591
679
  return existingSessionId;
592
680
  }
593
681
  }
594
- const created = await connection.newSession({
682
+ const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
595
683
  cwd: this.cwd,
596
684
  mcpServers
597
- });
598
- this.roomToSession.set(roomId, created.sessionId);
685
+ }));
686
+ this.linkOrAbandon(roomId, created.sessionId, generation);
599
687
  this.activeSessions.add(created.sessionId);
600
- await this.applyRequestedPermissionMode(connection, created.sessionId, created.modes);
688
+ await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
601
689
  return created.sessionId;
602
690
  }
603
- async tryRestoreSession(connection, sessionId, mcpServers) {
604
- const capabilities = this.connectionState?.agentCapabilities;
605
- const params = { cwd: this.cwd, mcpServers, sessionId };
606
- const restore = capabilities?.loadSession ? () => connection.loadSession(params) : capabilities?.sessionCapabilities?.resume ? () => connection.unstable_resumeSession(params) : null;
607
- if (!restore) {
608
- return { ok: false };
691
+ // The single gate an establishment must pass before it's allowed to claim
692
+ // the room: it must still be the room's current generation (not
693
+ // superseded by a teardown or a fresher establishment while this one was
694
+ // awaiting an RPC), and its session id must not already belong to another
695
+ // room. Either failure throws — this establishment cannot silently
696
+ // continue to activate, configure, and prompt a session it has no right
697
+ // to use for this room.
698
+ linkOrAbandon(roomId, sessionId, generation) {
699
+ if (!this.isCurrentGeneration(roomId, generation)) {
700
+ throw new Error(`ACP session establishment for room "${roomId}" was superseded before it could be linked`);
609
701
  }
610
- try {
611
- const restored = await restore();
612
- return { ok: true, modes: restored?.modes };
613
- } catch {
614
- return { ok: false };
702
+ if (!this.linkSession(roomId, sessionId)) {
703
+ throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
615
704
  }
616
705
  }
617
706
  // Best-effort: never throws, so a mode switch going wrong can't take a
618
707
  // session establishment down with it.
619
- async applyRequestedPermissionMode(connection, sessionId, modes) {
620
- const requestedModeId = this.requestedPermissionMode;
621
- if (!requestedModeId || !modes || modes.currentModeId === requestedModeId) {
708
+ async configureSessionMode(roomId, sessionId, modes, connection) {
709
+ if (!this.resolveSessionMode || !modes) {
622
710
  return;
623
711
  }
624
712
  const availableModes = Array.isArray(modes.availableModes) ? modes.availableModes : [];
625
- if (!availableModes.some((mode) => mode?.id === requestedModeId)) {
626
- this.safeWarn("requested permission mode is not advertised by this session", {
713
+ if (availableModes.length === 0) {
714
+ return;
715
+ }
716
+ const selectedModeId = await this.resolveSessionModeManually(
717
+ (signal) => this.resolveSessionMode({
718
+ roomId,
627
719
  sessionId,
628
- requestedModeId,
720
+ currentModeId: modes.currentModeId,
721
+ modes: availableModes
722
+ }, signal),
723
+ connection.signal
724
+ );
725
+ if (!selectedModeId || selectedModeId === modes.currentModeId) {
726
+ return;
727
+ }
728
+ if (!availableModes.some((mode) => mode?.id === selectedModeId)) {
729
+ this.safeWarn("resolveSessionMode selected a mode id this session does not advertise", {
730
+ sessionId,
731
+ selectedModeId,
629
732
  availableModeIds: availableModes.map((mode) => mode?.id)
630
733
  });
631
734
  return;
632
735
  }
633
736
  try {
634
737
  await withTimeout(
635
- connection.setSessionMode({ sessionId, modeId: requestedModeId }),
738
+ connection.setSessionMode({ sessionId, modeId: selectedModeId }),
636
739
  SET_SESSION_MODE_TIMEOUT_MS,
637
740
  `setSessionMode did not respond within ${SET_SESSION_MODE_TIMEOUT_MS}ms`
638
741
  );
639
742
  } catch (error) {
640
- this.safeWarn("failed to switch session into the requested permission mode", {
743
+ this.safeWarn("failed to switch session into the selected mode", {
641
744
  sessionId,
642
- requestedModeId,
745
+ selectedModeId,
643
746
  error: String(error)
644
747
  });
645
748
  }
646
749
  }
750
+ async resolveSessionModeManually(resolver, signal) {
751
+ const controller = new AbortController();
752
+ const abort = () => controller.abort();
753
+ signal.addEventListener("abort", abort, { once: true });
754
+ let timer;
755
+ try {
756
+ const cancelled = new Promise((resolve) => {
757
+ controller.signal.addEventListener("abort", () => resolve(void 0), { once: true });
758
+ });
759
+ const timeout = new Promise((resolve) => {
760
+ timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
761
+ });
762
+ return await Promise.race([
763
+ Promise.resolve().then(() => resolver(controller.signal)).catch((error) => {
764
+ this.safeWarn("resolveSessionMode threw; preserving the harness default", { error: String(error) });
765
+ return void 0;
766
+ }),
767
+ timeout,
768
+ cancelled
769
+ ]);
770
+ } finally {
771
+ clearTimeout(timer);
772
+ signal.removeEventListener("abort", abort);
773
+ controller.abort();
774
+ }
775
+ }
776
+ async tryRestoreSession(connection, sessionId, mcpServers) {
777
+ const capabilities = this.connectionState?.agentCapabilities;
778
+ const params = { cwd: this.cwd, mcpServers, sessionId };
779
+ const restore = capabilities?.loadSession ? () => connection.loadSession(params) : capabilities?.sessionCapabilities?.resume ? () => connection.unstable_resumeSession(params) : null;
780
+ if (!restore) {
781
+ return { ok: false };
782
+ }
783
+ try {
784
+ const restored = await this.raceAgainstConnectionClose(connection, restore());
785
+ return { ok: true, modes: restored?.modes };
786
+ } catch {
787
+ return { ok: false };
788
+ }
789
+ }
647
790
  async buildSessionMcpServers() {
648
791
  const mcpServers = [...this.mcpServers];
649
792
  if (!this.enableMcpTools) {
@@ -751,6 +894,36 @@ ${messageWithContext}`;
751
894
  "All Band MCP tool calls must include room_id."
752
895
  ].join("\n");
753
896
  }
897
+ // The connection's single permission entry point, and total by
898
+ // construction: every path resolves, nothing throws, and a request that
899
+ // can't be attributed to a live room is cancelled *and* warned rather than
900
+ // silently declined. `activeSessions` is the gate that keeps a dead
901
+ // session from raising a live prompt — `roomToSession` deliberately
902
+ // outlives a dropped connection so the session can be restored later.
903
+ async routePermissionRequest(params) {
904
+ const isActive = this.activeSessions.has(params.sessionId);
905
+ const roomId = isActive ? this.sessionToRoom.get(params.sessionId) : void 0;
906
+ const tools = roomId === void 0 ? void 0 : this.roomTools.get(roomId);
907
+ if (roomId === void 0 || !tools) {
908
+ this.safeWarn("cancelling a permission request that maps to no live room", {
909
+ sessionId: params.sessionId,
910
+ toolName: params.toolCall?.title,
911
+ sessionActive: isActive,
912
+ roomId
913
+ });
914
+ return { outcome: { outcome: "cancelled" } };
915
+ }
916
+ try {
917
+ return await this.handlePermissionRequest(tools, roomId, params);
918
+ } catch (error) {
919
+ this.safeWarn("permission handling failed; cancelling the request", {
920
+ sessionId: params.sessionId,
921
+ roomId,
922
+ error: String(error)
923
+ });
924
+ return { outcome: { outcome: "cancelled" } };
925
+ }
926
+ }
754
927
  async handlePermissionRequest(tools, roomId, params) {
755
928
  const toolName = params.toolCall.title ?? "unknown";
756
929
  const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
@@ -758,28 +931,52 @@ ${messageWithContext}`;
758
931
  if (controller) {
759
932
  this.trackPending(params.sessionId, controller);
760
933
  }
761
- const [, chosenId] = await Promise.all([
762
- // This is the room's only "a permission request is pending" signal,
763
- // and the only one other room participants ever see — started
764
- // immediately rather than serialized in front of a manual wait that
765
- // can take up to `permissionTimeoutMs`.
934
+ let requestEventFailed = false;
935
+ const [, resolvedChosenId] = await Promise.all([
936
+ // Started immediately rather than serialized in front of a manual
937
+ // wait that can take up to `permissionTimeoutMs`.
766
938
  tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
767
939
  permission_request: true,
768
940
  tool_name: toolName,
769
941
  tool_call_id: params.toolCall.toolCallId,
770
942
  acp_session_id: params.sessionId,
771
943
  auto_allowed: autoSelection !== void 0 && autoSelection !== null
944
+ }).catch((error) => {
945
+ requestEventFailed = true;
946
+ this.safeWarn("failed to post the permission-requested event; cancelling the request", {
947
+ roomId,
948
+ sessionId: params.sessionId,
949
+ error: String(error)
950
+ });
951
+ if (controller) {
952
+ this.abandon(controller, "no-answer");
953
+ }
772
954
  }),
773
- controller ? this.resolveManually(params.sessionId, params, controller) : Promise.resolve(autoSelection?.optionId)
955
+ controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
774
956
  ]);
775
- return this.toResponse(chosenId, params.options);
957
+ const chosenId = requestEventFailed ? void 0 : resolvedChosenId;
958
+ const response = this.toResponse(chosenId, params.options, { roomId, sessionId: params.sessionId });
959
+ if (controller && !controller.signal.aborted) {
960
+ this.abandon(controller, response.outcome.outcome === "selected" ? "settled" : "no-answer");
961
+ }
962
+ return response;
776
963
  }
777
964
  // `undefined`, or an id absent from this request's own `options` (a buggy
778
965
  // or stale caller), both map to `cancelled` — never silently treated as a
779
966
  // deny. A real match, reject-kind options included, maps to `selected`.
780
- toResponse(chosenId, options) {
781
- const matched = chosenId !== void 0 && options.some((option) => option.optionId === chosenId);
782
- return matched ? { outcome: { outcome: "selected", optionId: chosenId } } : { outcome: { outcome: "cancelled" } };
967
+ toResponse(chosenId, options, context) {
968
+ if (chosenId === void 0) {
969
+ return { outcome: { outcome: "cancelled" } };
970
+ }
971
+ if (!options.some((option) => option.optionId === chosenId)) {
972
+ this.safeWarn("resolvePermission chose an option this request does not offer", {
973
+ ...context,
974
+ chosenId,
975
+ optionIds: options.map((option) => option.optionId)
976
+ });
977
+ return { outcome: { outcome: "cancelled" } };
978
+ }
979
+ return { outcome: { outcome: "selected", optionId: chosenId } };
783
980
  }
784
981
  // A caller-supplied `Logger` isn't guaranteed to be synchronous or
785
982
  // non-throwing. Every best-effort warning in this file routes through here
@@ -793,41 +990,56 @@ ${messageWithContext}`;
793
990
  } catch {
794
991
  }
795
992
  }
796
- // Races the caller-supplied resolver against a timeout and against
797
- // `controller`'s own abort signal aborted externally by
798
- // `cancelPendingPermissions`/`cancelAllPendingPermissions` (fired from
799
- // `onCleanup`/`stop()` below) when a room or the whole adapter tears down
800
- // while this is still pending. `controller` is the same object tracked in
801
- // `pendingPermissions` by the caller, so there is exactly one cancellation
802
- // channel here, not a second hand-rolled one alongside it.
803
- async resolveManually(sessionId, params, controller) {
804
- let timer;
805
- const cancelled = new Promise((resolve) => {
993
+ // Races the caller-supplied resolver against `controller`'s abort signal,
994
+ // which is the request's single termination channel: the timeout below
995
+ // fires it with `"timeout"`, and `cancelPendingPermissions` /
996
+ // `cancelAllPendingPermissions` fire it with the reason their caller
997
+ // supplies. `controller` is the same object tracked in `pendingPermissions`,
998
+ // so there is exactly one cancellation channel here, not a second
999
+ // hand-rolled one alongside it.
1000
+ async resolveManually(roomId, params, controller) {
1001
+ const abandoned = new Promise((resolve) => {
806
1002
  controller.signal.addEventListener("abort", () => resolve(void 0));
807
1003
  });
1004
+ const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
808
1005
  try {
809
- const timeout = new Promise((resolve) => {
810
- timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
811
- });
812
1006
  return await Promise.race([
813
1007
  // `resolvePermission` is caller-supplied; nothing guarantees it's
814
1008
  // `async` or otherwise well-behaved. `Promise.resolve().then(...)`
815
1009
  // normalizes a synchronous throw the same way it normalizes a
816
1010
  // rejected promise, so both land in the `.catch` below rather than
817
1011
  // escaping this race uncaught.
818
- Promise.resolve().then(() => this.resolvePermission(params, controller.signal)).catch((error) => {
1012
+ Promise.resolve().then(() => this.resolvePermission({ ...params, roomId }, controller.signal)).then((chosenId) => this.discardLateAnswer(chosenId, controller, roomId, params.sessionId)).catch((error) => {
819
1013
  this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
820
1014
  return void 0;
821
1015
  }),
822
- timeout,
823
- cancelled
1016
+ abandoned
824
1017
  ]);
825
1018
  } finally {
826
1019
  clearTimeout(timer);
827
- this.untrackPending(sessionId, controller);
828
- controller.abort();
1020
+ this.untrackPending(params.sessionId, controller);
829
1021
  }
830
1022
  }
1023
+ // An answer that lands after the request was given up on can no longer be
1024
+ // honoured — the response has already gone back to the agent. It is dropped
1025
+ // either way; warning is what makes "my click did nothing" explicable.
1026
+ discardLateAnswer(chosenId, controller, roomId, sessionId) {
1027
+ if (!controller.signal.aborted || chosenId === void 0) {
1028
+ return chosenId;
1029
+ }
1030
+ this.safeWarn("resolvePermission answered after the request was abandoned; discarding", {
1031
+ roomId,
1032
+ sessionId,
1033
+ chosenId,
1034
+ reason: String(controller.signal.reason)
1035
+ });
1036
+ return void 0;
1037
+ }
1038
+ // The only place a permission's controller is ever aborted, so every
1039
+ // `signal.reason` a consumer can observe comes from the documented union.
1040
+ abandon(controller, reason) {
1041
+ controller.abort(reason);
1042
+ }
831
1043
  trackPending(sessionId, controller) {
832
1044
  const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
833
1045
  pending.add(controller);
@@ -840,14 +1052,14 @@ ${messageWithContext}`;
840
1052
  this.pendingPermissions.delete(sessionId);
841
1053
  }
842
1054
  }
843
- cancelPendingPermissions(sessionId) {
1055
+ cancelPendingPermissions(sessionId, reason) {
844
1056
  for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
845
- controller.abort();
1057
+ this.abandon(controller, reason);
846
1058
  }
847
1059
  }
848
- cancelAllPendingPermissions() {
1060
+ cancelAllPendingPermissions(reason) {
849
1061
  for (const sessionId of this.pendingPermissions.keys()) {
850
- this.cancelPendingPermissions(sessionId);
1062
+ this.cancelPendingPermissions(sessionId, reason);
851
1063
  }
852
1064
  }
853
1065
  async flushChunks(input) {
@@ -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
  }
@@ -46,6 +46,9 @@ var ACPClientHistoryConverter = class {
46
46
  convert(raw) {
47
47
  const roomToSession = {};
48
48
  for (const entry of raw) {
49
+ if (entry.message_type !== "task") {
50
+ continue;
51
+ }
49
52
  const metadataRaw = entry.metadata;
50
53
  if (!metadataRaw || typeof metadataRaw !== "object" || Array.isArray(metadataRaw)) {
51
54
  continue;
@@ -53,7 +56,7 @@ var ACPClientHistoryConverter = class {
53
56
  const metadata = metadataRaw;
54
57
  const sessionId = metadata.acp_client_session_id;
55
58
  const roomId = metadata.acp_client_room_id;
56
- if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId) {
59
+ if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId && roomId === entry.room_id) {
57
60
  roomToSession[roomId] = sessionId;
58
61
  }
59
62
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ACPClientHistoryConverter,
3
3
  ACPServerHistoryConverter
4
- } from "./chunk-FYVLUGW7.js";
4
+ } from "./chunk-V5TSWS7P.js";
5
5
  import {
6
6
  A2AHistoryConverter,
7
7
  ClaudeSDKHistoryConverter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@band-ai/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Band TypeScript SDK core runtime",
5
5
  "license": "MIT",
6
6
  "repository": {