@band-ai/sdk 0.3.1 → 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.cjs CHANGED
@@ -1062,6 +1062,9 @@ var ACPClientHistoryConverter = class {
1062
1062
  convert(raw) {
1063
1063
  const roomToSession = {};
1064
1064
  for (const entry of raw) {
1065
+ if (entry.message_type !== "task") {
1066
+ continue;
1067
+ }
1065
1068
  const metadataRaw = entry.metadata;
1066
1069
  if (!metadataRaw || typeof metadataRaw !== "object" || Array.isArray(metadataRaw)) {
1067
1070
  continue;
@@ -1069,7 +1072,7 @@ var ACPClientHistoryConverter = class {
1069
1072
  const metadata = metadataRaw;
1070
1073
  const sessionId = metadata.acp_client_session_id;
1071
1074
  const roomId = metadata.acp_client_room_id;
1072
- if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId) {
1075
+ if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId && roomId === entry.room_id) {
1073
1076
  roomToSession[roomId] = sessionId;
1074
1077
  }
1075
1078
  }
@@ -1888,7 +1891,13 @@ function normalizeMcpServers(mcpServers) {
1888
1891
  // src/adapters/acp/client.ts
1889
1892
  var BandACPClient = class {
1890
1893
  sessionChunks = /* @__PURE__ */ new Map();
1891
- permissionHandlers = /* @__PURE__ */ new Map();
1894
+ permissionHandler;
1895
+ // The handler is connection-scoped and required at construction, so it is
1896
+ // already in place before the agent process is spawned: there is no window
1897
+ // in which a `session/request_permission` has nowhere to go.
1898
+ constructor(permissionHandler) {
1899
+ this.permissionHandler = permissionHandler;
1900
+ }
1892
1901
  async sessionUpdate(params) {
1893
1902
  const chunk = toCollectedChunk(params.update);
1894
1903
  if (!chunk) {
@@ -1899,26 +1908,12 @@ var BandACPClient = class {
1899
1908
  this.sessionChunks.set(params.sessionId, existing);
1900
1909
  }
1901
1910
  async requestPermission(params) {
1902
- const handler = this.permissionHandlers.get(params.sessionId);
1903
- if (handler) {
1904
- return handler(params);
1905
- }
1906
- return {
1907
- outcome: {
1908
- outcome: "cancelled"
1909
- }
1910
- };
1911
- }
1912
- setPermissionHandler(sessionId, handler) {
1913
- if (!handler) {
1914
- this.permissionHandlers.delete(sessionId);
1915
- return;
1916
- }
1917
- this.permissionHandlers.set(sessionId, handler);
1911
+ return this.permissionHandler(params);
1918
1912
  }
1919
- resetSession(sessionId) {
1913
+ // Named for the one thing it clears: collected chunks are per-turn, and a
1914
+ // per-turn caller must not be able to reach anything with a longer life.
1915
+ resetChunks(sessionId) {
1920
1916
  this.sessionChunks.delete(sessionId);
1921
- this.permissionHandlers.delete(sessionId);
1922
1917
  }
1923
1918
  getCollectedText(sessionId) {
1924
1919
  return this.getCollectedChunks(sessionId).filter((chunk) => chunk.chunkType === "text").map((chunk) => chunk.content).join("");
@@ -2162,10 +2157,18 @@ var ACPClientAdapter = class extends SimpleAdapter {
2162
2157
  clientCapabilities;
2163
2158
  connectionFactory;
2164
2159
  roomToSession = /* @__PURE__ */ new Map();
2160
+ sessionToRoom = /* @__PURE__ */ new Map();
2165
2161
  roomTools = /* @__PURE__ */ new Map();
2166
2162
  activeSessions = /* @__PURE__ */ new Set();
2167
2163
  bootstrappedSessions = /* @__PURE__ */ new Set();
2168
2164
  pendingPermissions = /* @__PURE__ */ new Map();
2165
+ sessionsInFlight = /* @__PURE__ */ new Map();
2166
+ roomTurnLocks = /* @__PURE__ */ new Map();
2167
+ // Bumped each time a room starts a *new* establishment (never on a
2168
+ // coalesced reuse) and whenever a room is torn down. An establishment
2169
+ // captures its own value at the start; if the room has moved on by the
2170
+ // time it would link/activate a session, it was superseded and must not.
2171
+ roomGeneration = /* @__PURE__ */ new Map();
2169
2172
  resolvePermission;
2170
2173
  resolveSessionMode;
2171
2174
  permissionTimeoutMs;
@@ -2219,17 +2222,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
2219
2222
  this.rehydrate(history);
2220
2223
  }
2221
2224
  this.roomTools.set(context.roomId, tools);
2225
+ await this.withRoomTurnLock(context.roomId, () => this.runTurn(message, tools, participantsMessage, contactsMessage, context));
2226
+ }
2227
+ async runTurn(message, tools, participantsMessage, contactsMessage, context) {
2222
2228
  const connection = await this.ensureConnection();
2223
2229
  const client = this.client;
2224
2230
  if (!client) {
2225
2231
  throw new Error("ACP client was not initialized");
2226
2232
  }
2227
2233
  const sessionId = await this.getOrCreateSession(context.roomId, connection);
2228
- client.resetSession(sessionId);
2229
- client.setPermissionHandler(
2230
- sessionId,
2231
- (params) => this.handlePermissionRequest(tools, context.roomId, params)
2232
- );
2234
+ client.resetChunks(sessionId);
2233
2235
  const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
2234
2236
  const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
2235
2237
  const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
@@ -2262,15 +2264,28 @@ ${messageWithContext}`;
2262
2264
  acp_client_room_id: context.roomId
2263
2265
  });
2264
2266
  }
2267
+ // A per-room async mutex: `fn` for a given `roomId` never overlaps another
2268
+ // call for that same room, while different rooms stay fully concurrent.
2269
+ // The tracked tail (`this.roomTurnLocks`) always settles — via the
2270
+ // trailing `.catch` — so one turn's failure can't wedge every later turn
2271
+ // for the room; the real result/rejection is still `run`, returned to this
2272
+ // call's own caller.
2273
+ async withRoomTurnLock(roomId, fn) {
2274
+ const previous = this.roomTurnLocks.get(roomId) ?? Promise.resolve();
2275
+ const run = previous.then(fn, fn);
2276
+ this.roomTurnLocks.set(roomId, run.catch(() => void 0));
2277
+ return run;
2278
+ }
2265
2279
  async onCleanup(roomId) {
2266
- const sessionId = this.roomToSession.get(roomId);
2267
- this.roomToSession.delete(roomId);
2280
+ const sessionId = this.unlinkRoom(roomId);
2268
2281
  this.roomTools.delete(roomId);
2282
+ this.sessionsInFlight.delete(roomId);
2283
+ this.roomTurnLocks.delete(roomId);
2284
+ this.nextRoomGeneration(roomId);
2269
2285
  if (sessionId) {
2270
2286
  this.activeSessions.delete(sessionId);
2271
2287
  this.bootstrappedSessions.delete(sessionId);
2272
- this.client?.setPermissionHandler(sessionId, void 0);
2273
- this.cancelPendingPermissions(sessionId);
2288
+ this.cancelPendingPermissions(sessionId, "room-closed");
2274
2289
  }
2275
2290
  }
2276
2291
  async onRuntimeStop() {
@@ -2282,8 +2297,14 @@ ${messageWithContext}`;
2282
2297
  this.activeSessions.clear();
2283
2298
  this.bootstrappedSessions.clear();
2284
2299
  this.roomToSession.clear();
2300
+ this.sessionToRoom.clear();
2285
2301
  this.roomTools.clear();
2286
- this.cancelAllPendingPermissions();
2302
+ this.sessionsInFlight.clear();
2303
+ this.roomTurnLocks.clear();
2304
+ for (const roomId of this.roomGeneration.keys()) {
2305
+ this.nextRoomGeneration(roomId);
2306
+ }
2307
+ this.cancelAllPendingPermissions("adapter-stopped");
2287
2308
  this.client = null;
2288
2309
  this.connection = null;
2289
2310
  if (this.backend) {
@@ -2300,9 +2321,65 @@ ${messageWithContext}`;
2300
2321
  rehydrate(history) {
2301
2322
  for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
2302
2323
  if (!this.roomToSession.has(roomId)) {
2303
- this.roomToSession.set(roomId, sessionId);
2304
- }
2324
+ this.linkSession(roomId, sessionId);
2325
+ }
2326
+ }
2327
+ }
2328
+ // The only writer of both session maps, so they cannot drift: replacing a
2329
+ // room's session drops the old session's route, and a session id already
2330
+ // routed to another room is refused rather than silently re-pointed — two
2331
+ // rooms sharing one session id would make its permission requests
2332
+ // unattributable. Returns whether the link was made — a caller that goes
2333
+ // on to activate/configure/prompt a session regardless of a `false` here
2334
+ // would use a session this room was refused, not just fail to route its
2335
+ // permissions.
2336
+ linkSession(roomId, sessionId) {
2337
+ const routedRoomId = this.sessionToRoom.get(sessionId);
2338
+ if (routedRoomId !== void 0 && routedRoomId !== roomId) {
2339
+ this.safeWarn("refusing to route one ACP session to a second room", {
2340
+ sessionId,
2341
+ roomId,
2342
+ routedRoomId
2343
+ });
2344
+ return false;
2345
+ }
2346
+ const replacedSessionId = this.roomToSession.get(roomId);
2347
+ if (replacedSessionId !== void 0 && replacedSessionId !== sessionId) {
2348
+ this.sessionToRoom.delete(replacedSessionId);
2305
2349
  }
2350
+ this.roomToSession.set(roomId, sessionId);
2351
+ this.sessionToRoom.set(sessionId, roomId);
2352
+ return true;
2353
+ }
2354
+ nextRoomGeneration(roomId) {
2355
+ const next = (this.roomGeneration.get(roomId) ?? 0) + 1;
2356
+ this.roomGeneration.set(roomId, next);
2357
+ return next;
2358
+ }
2359
+ isCurrentGeneration(roomId, generation) {
2360
+ return this.roomGeneration.get(roomId) === generation;
2361
+ }
2362
+ // The installed ACP SDK's `sendRequest` never rejects a pending call when
2363
+ // its connection closes (no server response ever arrives to reject it
2364
+ // with) — so a session-establishment RPC in flight when the subprocess
2365
+ // dies would otherwise hang forever, wedging the room's `sessionsInFlight`
2366
+ // entry along with it. Racing every such RPC against the connection's own
2367
+ // `closed` promise gives it a real, prompt failure instead.
2368
+ raceAgainstConnectionClose(connection, operation) {
2369
+ let reject = () => void 0;
2370
+ const closedRejection = new Promise((_resolve, rejectFn) => {
2371
+ reject = rejectFn;
2372
+ });
2373
+ void connection.closed.then(() => reject(new Error("ACP connection closed while a session operation was still in flight")));
2374
+ return Promise.race([operation, closedRejection]);
2375
+ }
2376
+ unlinkRoom(roomId) {
2377
+ const sessionId = this.roomToSession.get(roomId);
2378
+ this.roomToSession.delete(roomId);
2379
+ if (sessionId !== void 0) {
2380
+ this.sessionToRoom.delete(sessionId);
2381
+ }
2382
+ return sessionId;
2306
2383
  }
2307
2384
  async ensureConnection() {
2308
2385
  if (this.connection && !this.connection.signal.aborted) {
@@ -2323,7 +2400,7 @@ ${messageWithContext}`;
2323
2400
  }
2324
2401
  async spawnConnection() {
2325
2402
  const acp = await acpModule.get();
2326
- const client = new BandACPClient();
2403
+ const client = new BandACPClient((params) => this.routePermissionRequest(params));
2327
2404
  const handle = await this.connectionFactory(client, {
2328
2405
  command: this.command,
2329
2406
  cwd: this.cwd,
@@ -2349,6 +2426,7 @@ ${messageWithContext}`;
2349
2426
  this.connectionHandle = null;
2350
2427
  this.connectionState = null;
2351
2428
  this.activeSessions.clear();
2429
+ this.cancelAllPendingPermissions("connection-lost");
2352
2430
  }
2353
2431
  });
2354
2432
  return connection;
@@ -2358,25 +2436,56 @@ ${messageWithContext}`;
2358
2436
  if (existingSessionId && this.activeSessions.has(existingSessionId)) {
2359
2437
  return existingSessionId;
2360
2438
  }
2439
+ const inFlight = this.sessionsInFlight.get(roomId);
2440
+ if (inFlight) {
2441
+ return inFlight;
2442
+ }
2443
+ const generation = this.nextRoomGeneration(roomId);
2444
+ const establishing = this.establishSession(roomId, existingSessionId, connection, generation);
2445
+ establishing.finally(() => {
2446
+ if (this.sessionsInFlight.get(roomId) === establishing) {
2447
+ this.sessionsInFlight.delete(roomId);
2448
+ }
2449
+ }).catch(() => void 0);
2450
+ this.sessionsInFlight.set(roomId, establishing);
2451
+ return establishing;
2452
+ }
2453
+ async establishSession(roomId, existingSessionId, connection, generation) {
2361
2454
  const mcpServers = await this.buildSessionMcpServers();
2362
2455
  if (existingSessionId) {
2363
2456
  const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
2364
2457
  if (restored.ok) {
2458
+ this.linkOrAbandon(roomId, existingSessionId, generation);
2365
2459
  this.activeSessions.add(existingSessionId);
2366
2460
  this.bootstrappedSessions.add(existingSessionId);
2367
2461
  await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
2368
2462
  return existingSessionId;
2369
2463
  }
2370
2464
  }
2371
- const created = await connection.newSession({
2465
+ const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
2372
2466
  cwd: this.cwd,
2373
2467
  mcpServers
2374
- });
2375
- this.roomToSession.set(roomId, created.sessionId);
2468
+ }));
2469
+ this.linkOrAbandon(roomId, created.sessionId, generation);
2376
2470
  this.activeSessions.add(created.sessionId);
2377
2471
  await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
2378
2472
  return created.sessionId;
2379
2473
  }
2474
+ // The single gate an establishment must pass before it's allowed to claim
2475
+ // the room: it must still be the room's current generation (not
2476
+ // superseded by a teardown or a fresher establishment while this one was
2477
+ // awaiting an RPC), and its session id must not already belong to another
2478
+ // room. Either failure throws — this establishment cannot silently
2479
+ // continue to activate, configure, and prompt a session it has no right
2480
+ // to use for this room.
2481
+ linkOrAbandon(roomId, sessionId, generation) {
2482
+ if (!this.isCurrentGeneration(roomId, generation)) {
2483
+ throw new Error(`ACP session establishment for room "${roomId}" was superseded before it could be linked`);
2484
+ }
2485
+ if (!this.linkSession(roomId, sessionId)) {
2486
+ throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
2487
+ }
2488
+ }
2380
2489
  // Best-effort: never throws, so a mode switch going wrong can't take a
2381
2490
  // session establishment down with it.
2382
2491
  async configureSessionMode(roomId, sessionId, modes, connection) {
@@ -2455,7 +2564,7 @@ ${messageWithContext}`;
2455
2564
  return { ok: false };
2456
2565
  }
2457
2566
  try {
2458
- const restored = await restore();
2567
+ const restored = await this.raceAgainstConnectionClose(connection, restore());
2459
2568
  return { ok: true, modes: restored?.modes };
2460
2569
  } catch {
2461
2570
  return { ok: false };
@@ -2568,6 +2677,36 @@ ${messageWithContext}`;
2568
2677
  "All Band MCP tool calls must include room_id."
2569
2678
  ].join("\n");
2570
2679
  }
2680
+ // The connection's single permission entry point, and total by
2681
+ // construction: every path resolves, nothing throws, and a request that
2682
+ // can't be attributed to a live room is cancelled *and* warned rather than
2683
+ // silently declined. `activeSessions` is the gate that keeps a dead
2684
+ // session from raising a live prompt — `roomToSession` deliberately
2685
+ // outlives a dropped connection so the session can be restored later.
2686
+ async routePermissionRequest(params) {
2687
+ const isActive = this.activeSessions.has(params.sessionId);
2688
+ const roomId = isActive ? this.sessionToRoom.get(params.sessionId) : void 0;
2689
+ const tools = roomId === void 0 ? void 0 : this.roomTools.get(roomId);
2690
+ if (roomId === void 0 || !tools) {
2691
+ this.safeWarn("cancelling a permission request that maps to no live room", {
2692
+ sessionId: params.sessionId,
2693
+ toolName: params.toolCall?.title,
2694
+ sessionActive: isActive,
2695
+ roomId
2696
+ });
2697
+ return { outcome: { outcome: "cancelled" } };
2698
+ }
2699
+ try {
2700
+ return await this.handlePermissionRequest(tools, roomId, params);
2701
+ } catch (error) {
2702
+ this.safeWarn("permission handling failed; cancelling the request", {
2703
+ sessionId: params.sessionId,
2704
+ roomId,
2705
+ error: String(error)
2706
+ });
2707
+ return { outcome: { outcome: "cancelled" } };
2708
+ }
2709
+ }
2571
2710
  async handlePermissionRequest(tools, roomId, params) {
2572
2711
  const toolName = params.toolCall.title ?? "unknown";
2573
2712
  const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
@@ -2575,28 +2714,52 @@ ${messageWithContext}`;
2575
2714
  if (controller) {
2576
2715
  this.trackPending(params.sessionId, controller);
2577
2716
  }
2578
- const [, chosenId] = await Promise.all([
2579
- // This is the room's only "a permission request is pending" signal,
2580
- // and the only one other room participants ever see — started
2581
- // immediately rather than serialized in front of a manual wait that
2582
- // can take up to `permissionTimeoutMs`.
2717
+ let requestEventFailed = false;
2718
+ const [, resolvedChosenId] = await Promise.all([
2719
+ // Started immediately rather than serialized in front of a manual
2720
+ // wait that can take up to `permissionTimeoutMs`.
2583
2721
  tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
2584
2722
  permission_request: true,
2585
2723
  tool_name: toolName,
2586
2724
  tool_call_id: params.toolCall.toolCallId,
2587
2725
  acp_session_id: params.sessionId,
2588
2726
  auto_allowed: autoSelection !== void 0 && autoSelection !== null
2727
+ }).catch((error) => {
2728
+ requestEventFailed = true;
2729
+ this.safeWarn("failed to post the permission-requested event; cancelling the request", {
2730
+ roomId,
2731
+ sessionId: params.sessionId,
2732
+ error: String(error)
2733
+ });
2734
+ if (controller) {
2735
+ this.abandon(controller, "no-answer");
2736
+ }
2589
2737
  }),
2590
- controller ? this.resolveManually(params.sessionId, params, controller) : Promise.resolve(autoSelection?.optionId)
2738
+ controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
2591
2739
  ]);
2592
- return this.toResponse(chosenId, params.options);
2740
+ const chosenId = requestEventFailed ? void 0 : resolvedChosenId;
2741
+ const response = this.toResponse(chosenId, params.options, { roomId, sessionId: params.sessionId });
2742
+ if (controller && !controller.signal.aborted) {
2743
+ this.abandon(controller, response.outcome.outcome === "selected" ? "settled" : "no-answer");
2744
+ }
2745
+ return response;
2593
2746
  }
2594
2747
  // `undefined`, or an id absent from this request's own `options` (a buggy
2595
2748
  // or stale caller), both map to `cancelled` — never silently treated as a
2596
2749
  // deny. A real match, reject-kind options included, maps to `selected`.
2597
- toResponse(chosenId, options) {
2598
- const matched = chosenId !== void 0 && options.some((option) => option.optionId === chosenId);
2599
- return matched ? { outcome: { outcome: "selected", optionId: chosenId } } : { outcome: { outcome: "cancelled" } };
2750
+ toResponse(chosenId, options, context) {
2751
+ if (chosenId === void 0) {
2752
+ return { outcome: { outcome: "cancelled" } };
2753
+ }
2754
+ if (!options.some((option) => option.optionId === chosenId)) {
2755
+ this.safeWarn("resolvePermission chose an option this request does not offer", {
2756
+ ...context,
2757
+ chosenId,
2758
+ optionIds: options.map((option) => option.optionId)
2759
+ });
2760
+ return { outcome: { outcome: "cancelled" } };
2761
+ }
2762
+ return { outcome: { outcome: "selected", optionId: chosenId } };
2600
2763
  }
2601
2764
  // A caller-supplied `Logger` isn't guaranteed to be synchronous or
2602
2765
  // non-throwing. Every best-effort warning in this file routes through here
@@ -2610,41 +2773,56 @@ ${messageWithContext}`;
2610
2773
  } catch {
2611
2774
  }
2612
2775
  }
2613
- // Races the caller-supplied resolver against a timeout and against
2614
- // `controller`'s own abort signal aborted externally by
2615
- // `cancelPendingPermissions`/`cancelAllPendingPermissions` (fired from
2616
- // `onCleanup`/`stop()` below) when a room or the whole adapter tears down
2617
- // while this is still pending. `controller` is the same object tracked in
2618
- // `pendingPermissions` by the caller, so there is exactly one cancellation
2619
- // channel here, not a second hand-rolled one alongside it.
2620
- async resolveManually(sessionId, params, controller) {
2621
- let timer;
2622
- const cancelled = new Promise((resolve) => {
2776
+ // Races the caller-supplied resolver against `controller`'s abort signal,
2777
+ // which is the request's single termination channel: the timeout below
2778
+ // fires it with `"timeout"`, and `cancelPendingPermissions` /
2779
+ // `cancelAllPendingPermissions` fire it with the reason their caller
2780
+ // supplies. `controller` is the same object tracked in `pendingPermissions`,
2781
+ // so there is exactly one cancellation channel here, not a second
2782
+ // hand-rolled one alongside it.
2783
+ async resolveManually(roomId, params, controller) {
2784
+ const abandoned = new Promise((resolve) => {
2623
2785
  controller.signal.addEventListener("abort", () => resolve(void 0));
2624
2786
  });
2787
+ const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
2625
2788
  try {
2626
- const timeout = new Promise((resolve) => {
2627
- timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
2628
- });
2629
2789
  return await Promise.race([
2630
2790
  // `resolvePermission` is caller-supplied; nothing guarantees it's
2631
2791
  // `async` or otherwise well-behaved. `Promise.resolve().then(...)`
2632
2792
  // normalizes a synchronous throw the same way it normalizes a
2633
2793
  // rejected promise, so both land in the `.catch` below rather than
2634
2794
  // escaping this race uncaught.
2635
- Promise.resolve().then(() => this.resolvePermission(params, controller.signal)).catch((error) => {
2795
+ Promise.resolve().then(() => this.resolvePermission({ ...params, roomId }, controller.signal)).then((chosenId) => this.discardLateAnswer(chosenId, controller, roomId, params.sessionId)).catch((error) => {
2636
2796
  this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
2637
2797
  return void 0;
2638
2798
  }),
2639
- timeout,
2640
- cancelled
2799
+ abandoned
2641
2800
  ]);
2642
2801
  } finally {
2643
2802
  clearTimeout(timer);
2644
- this.untrackPending(sessionId, controller);
2645
- controller.abort();
2803
+ this.untrackPending(params.sessionId, controller);
2646
2804
  }
2647
2805
  }
2806
+ // An answer that lands after the request was given up on can no longer be
2807
+ // honoured — the response has already gone back to the agent. It is dropped
2808
+ // either way; warning is what makes "my click did nothing" explicable.
2809
+ discardLateAnswer(chosenId, controller, roomId, sessionId) {
2810
+ if (!controller.signal.aborted || chosenId === void 0) {
2811
+ return chosenId;
2812
+ }
2813
+ this.safeWarn("resolvePermission answered after the request was abandoned; discarding", {
2814
+ roomId,
2815
+ sessionId,
2816
+ chosenId,
2817
+ reason: String(controller.signal.reason)
2818
+ });
2819
+ return void 0;
2820
+ }
2821
+ // The only place a permission's controller is ever aborted, so every
2822
+ // `signal.reason` a consumer can observe comes from the documented union.
2823
+ abandon(controller, reason) {
2824
+ controller.abort(reason);
2825
+ }
2648
2826
  trackPending(sessionId, controller) {
2649
2827
  const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
2650
2828
  pending.add(controller);
@@ -2657,14 +2835,14 @@ ${messageWithContext}`;
2657
2835
  this.pendingPermissions.delete(sessionId);
2658
2836
  }
2659
2837
  }
2660
- cancelPendingPermissions(sessionId) {
2838
+ cancelPendingPermissions(sessionId, reason) {
2661
2839
  for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
2662
- controller.abort();
2840
+ this.abandon(controller, reason);
2663
2841
  }
2664
2842
  }
2665
- cancelAllPendingPermissions() {
2843
+ cancelAllPendingPermissions(reason) {
2666
2844
  for (const sessionId of this.pendingPermissions.keys()) {
2667
- this.cancelPendingPermissions(sessionId);
2845
+ this.cancelPendingPermissions(sessionId, reason);
2668
2846
  }
2669
2847
  }
2670
2848
  async flushChunks(input) {
@@ -1,6 +1,6 @@
1
1
  export { A as A2AAdapter, a as A2AAdapterOptions, K as A2AClientFactory, M as A2AClientLike, b as A2AGatewayAdapter, c as AnthropicAdapter, d as AnthropicAdapterOptions, N as AnthropicClientFactory, Q as AnthropicToolCallingModel, R as AnthropicToolCallingModelOptions, C as CODEX_REASONING_EFFORTS, e as CODEX_REASONING_SUMMARIES, f as CODEX_WEB_SEARCH_MODES, g as ClaudePermissionMode, h as ClaudeSDKAdapter, i as ClaudeSDKAdapterOptions, S as ClaudeSDKQuery, U as ClaudeSDKQueryParams, j as CodexAdapter, k as CodexAdapterConfig, W as CodexAppServerStdioClient, l as CodexApprovalPolicy, X as CodexClientLike, Y as CodexJsonRpcError, m as CodexReasoningEffort, n as CodexReasoningSummary, o as CodexSandboxMode, p as CodexWebSearchMode, Z as DynamicToolCallParams, _ as DynamicToolCallResponse, $ as DynamicToolSpec, G as GeminiAdapter, q as GeminiAdapterOptions, a0 as GeminiClientFactory, a1 as GeminiToolCallingModel, a2 as GeminiToolCallingModelOptions, r as GenericAdapter, s as GenericAdapterHandler, t as GoogleADKAdapter, u as GoogleADKAdapterOptions, a3 as HttpOpencodeClient, a4 as HttpOpencodeClientOptions, a5 as HttpStatusError, L as LangGraphAdapter, v as LangGraphAdapterOptions, w as LangGraphGraph, x as LettaAdapter, y as LettaAdapterOptions, a6 as LettaAgentCreateParams, a7 as LettaClientFactory, a8 as LettaClientLike, a9 as LettaHistoryConverter, aa as LettaMessage, ab as LettaMessageCreateParams, ac as LettaMessages, ad as LettaRequestOptions, ae as LettaResponse, af as LettaResponseMessage, O as OpenAIAdapter, z as OpenAIAdapterOptions, ag as OpenAIClientFactory, ah as OpenAIToolCallingModel, ai as OpenAIToolCallingModelOptions, B as OpencodeAdapter, D as OpencodeAdapterConfig, E as OpencodeApprovalMode, F as OpencodeApprovalReply, aj as OpencodeClientLike, H as OpencodeQuestionMode, P as ParlantAdapter, I as ParlantAdapterOptions, ak as ParlantClientFactory, al as ParlantClientLike, am as ToolCall, an as ToolCallingAdapter, ao as ToolCallingAdapterOptions, T as ToolCallingModel, ap as ToolCallingModelRequest, aq as ToolCallingResponse, ar as ToolResult, as as TurnStartParams, V as VercelAISDKAdapter, J as VercelAISDKAdapterOptions, at as VercelAISDKToolCallingModel, au as VercelAISDKToolCallingModelOptions, av as runSingleToolRound } from './ClaudeSDKAdapter-Cx6zhSaG.cjs';
2
2
  import * as _agentclientprotocol_sdk from '@agentclientprotocol/sdk';
3
- import { Client, ClientSideConnection, McpServer, ClientCapabilities, RequestPermissionRequest, SessionMode, AgentSideConnection, SessionModeState, Agent, InitializeResponse, Implementation, Stream, InitializeRequest, LoadSessionRequest, LoadSessionResponse, ListSessionsRequest, ListSessionsResponse, ForkSessionRequest, ForkSessionResponse, ResumeSessionRequest, ResumeSessionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, AuthenticateRequest, AuthenticateResponse, PromptRequest, PromptResponse } from '@agentclientprotocol/sdk';
3
+ import { Client, ClientSideConnection, RequestPermissionRequest, McpServer, ClientCapabilities, SessionMode, AgentSideConnection, SessionModeState, Agent, InitializeResponse, Implementation, Stream, InitializeRequest, LoadSessionRequest, LoadSessionResponse, ListSessionsRequest, ListSessionsResponse, ForkSessionRequest, ForkSessionResponse, ResumeSessionRequest, ResumeSessionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, AuthenticateRequest, AuthenticateResponse, PromptRequest, PromptResponse } from '@agentclientprotocol/sdk';
4
4
  import { A as ACPClientSessionState, a as ACPServerSessionState } from './acp-server-Dlj7D563.cjs';
5
5
  export { G as GatewayHistoryConverter } from './acp-server-Dlj7D563.cjs';
6
6
  import { S as SimpleAdapter } from './simpleAdapter-BpT4XbZC.cjs';
@@ -40,6 +40,11 @@ type ACPClientConnectionFactory = (client: Client, options: {
40
40
  cwd?: string;
41
41
  env?: Record<string, string>;
42
42
  }) => Promise<ACPClientConnectionHandle>;
43
+ type ACPPermissionRequest = RequestPermissionRequest & {
44
+ roomId: string;
45
+ };
46
+ type ACPPermissionAbandonReason = "timeout" | "room-closed" | "adapter-stopped" | "connection-lost";
47
+ type ACPPermissionEndReason = ACPPermissionAbandonReason | "settled" | "no-answer";
43
48
 
44
49
  interface ACPModeRequest {
45
50
  roomId: string;
@@ -58,7 +63,7 @@ interface ACPClientAdapterOptions {
58
63
  additionalMcpTools?: McpToolRegistration[];
59
64
  clientCapabilities?: ClientCapabilities;
60
65
  connectionFactory?: ACPClientConnectionFactory;
61
- resolvePermission?: (request: RequestPermissionRequest, signal: AbortSignal) => Promise<string | undefined>;
66
+ resolvePermission?: (request: ACPPermissionRequest, signal: AbortSignal) => Promise<string | undefined>;
62
67
  permissionTimeoutMs?: number;
63
68
  resolveSessionMode?: (request: ACPModeRequest, signal: AbortSignal) => Promise<string | undefined>;
64
69
  logger?: Logger;
@@ -75,10 +80,14 @@ declare class ACPClientAdapter extends SimpleAdapter<ACPClientSessionState, Adap
75
80
  private readonly clientCapabilities?;
76
81
  private readonly connectionFactory;
77
82
  private readonly roomToSession;
83
+ private readonly sessionToRoom;
78
84
  private readonly roomTools;
79
85
  private readonly activeSessions;
80
86
  private readonly bootstrappedSessions;
81
87
  private readonly pendingPermissions;
88
+ private readonly sessionsInFlight;
89
+ private readonly roomTurnLocks;
90
+ private readonly roomGeneration;
82
91
  private readonly resolvePermission?;
83
92
  private readonly resolveSessionMode?;
84
93
  private readonly permissionTimeoutMs;
@@ -98,13 +107,22 @@ declare class ACPClientAdapter extends SimpleAdapter<ACPClientSessionState, Adap
98
107
  isSessionBootstrap: boolean;
99
108
  roomId: string;
100
109
  }): Promise<void>;
110
+ private runTurn;
111
+ private withRoomTurnLock;
101
112
  onCleanup(roomId: string): Promise<void>;
102
113
  onRuntimeStop(): Promise<void>;
103
114
  stop(): Promise<void>;
104
115
  private rehydrate;
116
+ private linkSession;
117
+ private nextRoomGeneration;
118
+ private isCurrentGeneration;
119
+ private raceAgainstConnectionClose;
120
+ private unlinkRoom;
105
121
  private ensureConnection;
106
122
  private spawnConnection;
107
123
  private getOrCreateSession;
124
+ private establishSession;
125
+ private linkOrAbandon;
108
126
  private configureSessionMode;
109
127
  private resolveSessionModeManually;
110
128
  private tryRestoreSession;
@@ -112,10 +130,13 @@ declare class ACPClientAdapter extends SimpleAdapter<ACPClientSessionState, Adap
112
130
  private getOrCreateBackend;
113
131
  private createBackend;
114
132
  private buildSystemContext;
133
+ private routePermissionRequest;
115
134
  private handlePermissionRequest;
116
135
  private toResponse;
117
136
  private safeWarn;
118
137
  private resolveManually;
138
+ private discardLateAnswer;
139
+ private abandon;
119
140
  private trackPending;
120
141
  private untrackPending;
121
142
  private cancelPendingPermissions;
@@ -240,4 +261,4 @@ declare class ACPServer implements Agent {
240
261
  extNotification(method: string, params: Record<string, unknown>): Promise<void>;
241
262
  }
242
263
 
243
- export { ACPClientAdapter, type ACPClientAdapterOptions, ACPServer, type ACPServerOptions, BandACPServerAdapter, type BandACPServerAdapterOptions, GatewayServer, GatewayServerLike, GatewayServerOptions, createGatewayServer };
264
+ export { ACPClientAdapter, type ACPClientAdapterOptions, type ACPPermissionAbandonReason, type ACPPermissionEndReason, type ACPPermissionRequest, ACPServer, type ACPServerOptions, BandACPServerAdapter, type BandACPServerAdapterOptions, GatewayServer, GatewayServerLike, GatewayServerOptions, createGatewayServer };
@@ -1,6 +1,6 @@
1
1
  export { A as A2AAdapter, a as A2AAdapterOptions, K as A2AClientFactory, M as A2AClientLike, b as A2AGatewayAdapter, c as AnthropicAdapter, d as AnthropicAdapterOptions, N as AnthropicClientFactory, Q as AnthropicToolCallingModel, R as AnthropicToolCallingModelOptions, C as CODEX_REASONING_EFFORTS, e as CODEX_REASONING_SUMMARIES, f as CODEX_WEB_SEARCH_MODES, g as ClaudePermissionMode, h as ClaudeSDKAdapter, i as ClaudeSDKAdapterOptions, S as ClaudeSDKQuery, U as ClaudeSDKQueryParams, j as CodexAdapter, k as CodexAdapterConfig, W as CodexAppServerStdioClient, l as CodexApprovalPolicy, X as CodexClientLike, Y as CodexJsonRpcError, m as CodexReasoningEffort, n as CodexReasoningSummary, o as CodexSandboxMode, p as CodexWebSearchMode, Z as DynamicToolCallParams, _ as DynamicToolCallResponse, $ as DynamicToolSpec, G as GeminiAdapter, q as GeminiAdapterOptions, a0 as GeminiClientFactory, a1 as GeminiToolCallingModel, a2 as GeminiToolCallingModelOptions, r as GenericAdapter, s as GenericAdapterHandler, t as GoogleADKAdapter, u as GoogleADKAdapterOptions, a3 as HttpOpencodeClient, a4 as HttpOpencodeClientOptions, a5 as HttpStatusError, L as LangGraphAdapter, v as LangGraphAdapterOptions, w as LangGraphGraph, x as LettaAdapter, y as LettaAdapterOptions, a6 as LettaAgentCreateParams, a7 as LettaClientFactory, a8 as LettaClientLike, a9 as LettaHistoryConverter, aa as LettaMessage, ab as LettaMessageCreateParams, ac as LettaMessages, ad as LettaRequestOptions, ae as LettaResponse, af as LettaResponseMessage, O as OpenAIAdapter, z as OpenAIAdapterOptions, ag as OpenAIClientFactory, ah as OpenAIToolCallingModel, ai as OpenAIToolCallingModelOptions, B as OpencodeAdapter, D as OpencodeAdapterConfig, E as OpencodeApprovalMode, F as OpencodeApprovalReply, aj as OpencodeClientLike, H as OpencodeQuestionMode, P as ParlantAdapter, I as ParlantAdapterOptions, ak as ParlantClientFactory, al as ParlantClientLike, am as ToolCall, an as ToolCallingAdapter, ao as ToolCallingAdapterOptions, T as ToolCallingModel, ap as ToolCallingModelRequest, aq as ToolCallingResponse, ar as ToolResult, as as TurnStartParams, V as VercelAISDKAdapter, J as VercelAISDKAdapterOptions, at as VercelAISDKToolCallingModel, au as VercelAISDKToolCallingModelOptions, av as runSingleToolRound } from './ClaudeSDKAdapter-CXud2DBE.js';
2
2
  import * as _agentclientprotocol_sdk from '@agentclientprotocol/sdk';
3
- import { Client, ClientSideConnection, McpServer, ClientCapabilities, RequestPermissionRequest, SessionMode, AgentSideConnection, SessionModeState, Agent, InitializeResponse, Implementation, Stream, InitializeRequest, LoadSessionRequest, LoadSessionResponse, ListSessionsRequest, ListSessionsResponse, ForkSessionRequest, ForkSessionResponse, ResumeSessionRequest, ResumeSessionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, AuthenticateRequest, AuthenticateResponse, PromptRequest, PromptResponse } from '@agentclientprotocol/sdk';
3
+ import { Client, ClientSideConnection, RequestPermissionRequest, McpServer, ClientCapabilities, SessionMode, AgentSideConnection, SessionModeState, Agent, InitializeResponse, Implementation, Stream, InitializeRequest, LoadSessionRequest, LoadSessionResponse, ListSessionsRequest, ListSessionsResponse, ForkSessionRequest, ForkSessionResponse, ResumeSessionRequest, ResumeSessionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, AuthenticateRequest, AuthenticateResponse, PromptRequest, PromptResponse } from '@agentclientprotocol/sdk';
4
4
  import { A as ACPClientSessionState, a as ACPServerSessionState } from './acp-server-CiUqN3G3.js';
5
5
  export { G as GatewayHistoryConverter } from './acp-server-CiUqN3G3.js';
6
6
  import { S as SimpleAdapter } from './simpleAdapter--wznuoOw.js';
@@ -40,6 +40,11 @@ type ACPClientConnectionFactory = (client: Client, options: {
40
40
  cwd?: string;
41
41
  env?: Record<string, string>;
42
42
  }) => Promise<ACPClientConnectionHandle>;
43
+ type ACPPermissionRequest = RequestPermissionRequest & {
44
+ roomId: string;
45
+ };
46
+ type ACPPermissionAbandonReason = "timeout" | "room-closed" | "adapter-stopped" | "connection-lost";
47
+ type ACPPermissionEndReason = ACPPermissionAbandonReason | "settled" | "no-answer";
43
48
 
44
49
  interface ACPModeRequest {
45
50
  roomId: string;
@@ -58,7 +63,7 @@ interface ACPClientAdapterOptions {
58
63
  additionalMcpTools?: McpToolRegistration[];
59
64
  clientCapabilities?: ClientCapabilities;
60
65
  connectionFactory?: ACPClientConnectionFactory;
61
- resolvePermission?: (request: RequestPermissionRequest, signal: AbortSignal) => Promise<string | undefined>;
66
+ resolvePermission?: (request: ACPPermissionRequest, signal: AbortSignal) => Promise<string | undefined>;
62
67
  permissionTimeoutMs?: number;
63
68
  resolveSessionMode?: (request: ACPModeRequest, signal: AbortSignal) => Promise<string | undefined>;
64
69
  logger?: Logger;
@@ -75,10 +80,14 @@ declare class ACPClientAdapter extends SimpleAdapter<ACPClientSessionState, Adap
75
80
  private readonly clientCapabilities?;
76
81
  private readonly connectionFactory;
77
82
  private readonly roomToSession;
83
+ private readonly sessionToRoom;
78
84
  private readonly roomTools;
79
85
  private readonly activeSessions;
80
86
  private readonly bootstrappedSessions;
81
87
  private readonly pendingPermissions;
88
+ private readonly sessionsInFlight;
89
+ private readonly roomTurnLocks;
90
+ private readonly roomGeneration;
82
91
  private readonly resolvePermission?;
83
92
  private readonly resolveSessionMode?;
84
93
  private readonly permissionTimeoutMs;
@@ -98,13 +107,22 @@ declare class ACPClientAdapter extends SimpleAdapter<ACPClientSessionState, Adap
98
107
  isSessionBootstrap: boolean;
99
108
  roomId: string;
100
109
  }): Promise<void>;
110
+ private runTurn;
111
+ private withRoomTurnLock;
101
112
  onCleanup(roomId: string): Promise<void>;
102
113
  onRuntimeStop(): Promise<void>;
103
114
  stop(): Promise<void>;
104
115
  private rehydrate;
116
+ private linkSession;
117
+ private nextRoomGeneration;
118
+ private isCurrentGeneration;
119
+ private raceAgainstConnectionClose;
120
+ private unlinkRoom;
105
121
  private ensureConnection;
106
122
  private spawnConnection;
107
123
  private getOrCreateSession;
124
+ private establishSession;
125
+ private linkOrAbandon;
108
126
  private configureSessionMode;
109
127
  private resolveSessionModeManually;
110
128
  private tryRestoreSession;
@@ -112,10 +130,13 @@ declare class ACPClientAdapter extends SimpleAdapter<ACPClientSessionState, Adap
112
130
  private getOrCreateBackend;
113
131
  private createBackend;
114
132
  private buildSystemContext;
133
+ private routePermissionRequest;
115
134
  private handlePermissionRequest;
116
135
  private toResponse;
117
136
  private safeWarn;
118
137
  private resolveManually;
138
+ private discardLateAnswer;
139
+ private abandon;
119
140
  private trackPending;
120
141
  private untrackPending;
121
142
  private cancelPendingPermissions;
@@ -240,4 +261,4 @@ declare class ACPServer implements Agent {
240
261
  extNotification(method: string, params: Record<string, unknown>): Promise<void>;
241
262
  }
242
263
 
243
- export { ACPClientAdapter, type ACPClientAdapterOptions, ACPServer, type ACPServerOptions, BandACPServerAdapter, type BandACPServerAdapterOptions, GatewayServer, GatewayServerLike, GatewayServerOptions, createGatewayServer };
264
+ export { ACPClientAdapter, type ACPClientAdapterOptions, type ACPPermissionAbandonReason, type ACPPermissionEndReason, type ACPPermissionRequest, ACPServer, type ACPServerOptions, BandACPServerAdapter, type BandACPServerAdapterOptions, GatewayServer, GatewayServerLike, GatewayServerOptions, createGatewayServer };
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,10 +374,18 @@ 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
390
  resolveSessionMode;
391
391
  permissionTimeoutMs;
@@ -439,17 +439,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
439
439
  this.rehydrate(history);
440
440
  }
441
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) {
442
445
  const connection = await this.ensureConnection();
443
446
  const client = this.client;
444
447
  if (!client) {
445
448
  throw new Error("ACP client was not initialized");
446
449
  }
447
450
  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
- );
451
+ client.resetChunks(sessionId);
453
452
  const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
454
453
  const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
455
454
  const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
@@ -482,15 +481,28 @@ ${messageWithContext}`;
482
481
  acp_client_room_id: context.roomId
483
482
  });
484
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
+ }
485
496
  async onCleanup(roomId) {
486
- const sessionId = this.roomToSession.get(roomId);
487
- this.roomToSession.delete(roomId);
497
+ const sessionId = this.unlinkRoom(roomId);
488
498
  this.roomTools.delete(roomId);
499
+ this.sessionsInFlight.delete(roomId);
500
+ this.roomTurnLocks.delete(roomId);
501
+ this.nextRoomGeneration(roomId);
489
502
  if (sessionId) {
490
503
  this.activeSessions.delete(sessionId);
491
504
  this.bootstrappedSessions.delete(sessionId);
492
- this.client?.setPermissionHandler(sessionId, void 0);
493
- this.cancelPendingPermissions(sessionId);
505
+ this.cancelPendingPermissions(sessionId, "room-closed");
494
506
  }
495
507
  }
496
508
  async onRuntimeStop() {
@@ -502,8 +514,14 @@ ${messageWithContext}`;
502
514
  this.activeSessions.clear();
503
515
  this.bootstrappedSessions.clear();
504
516
  this.roomToSession.clear();
517
+ this.sessionToRoom.clear();
505
518
  this.roomTools.clear();
506
- 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");
507
525
  this.client = null;
508
526
  this.connection = null;
509
527
  if (this.backend) {
@@ -520,10 +538,66 @@ ${messageWithContext}`;
520
538
  rehydrate(history) {
521
539
  for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
522
540
  if (!this.roomToSession.has(roomId)) {
523
- this.roomToSession.set(roomId, sessionId);
541
+ this.linkSession(roomId, sessionId);
524
542
  }
525
543
  }
526
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
+ }
527
601
  async ensureConnection() {
528
602
  if (this.connection && !this.connection.signal.aborted) {
529
603
  return this.connection;
@@ -543,7 +617,7 @@ ${messageWithContext}`;
543
617
  }
544
618
  async spawnConnection() {
545
619
  const acp = await acpModule.get();
546
- const client = new BandACPClient();
620
+ const client = new BandACPClient((params) => this.routePermissionRequest(params));
547
621
  const handle = await this.connectionFactory(client, {
548
622
  command: this.command,
549
623
  cwd: this.cwd,
@@ -569,6 +643,7 @@ ${messageWithContext}`;
569
643
  this.connectionHandle = null;
570
644
  this.connectionState = null;
571
645
  this.activeSessions.clear();
646
+ this.cancelAllPendingPermissions("connection-lost");
572
647
  }
573
648
  });
574
649
  return connection;
@@ -578,25 +653,56 @@ ${messageWithContext}`;
578
653
  if (existingSessionId && this.activeSessions.has(existingSessionId)) {
579
654
  return existingSessionId;
580
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) {
581
671
  const mcpServers = await this.buildSessionMcpServers();
582
672
  if (existingSessionId) {
583
673
  const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
584
674
  if (restored.ok) {
675
+ this.linkOrAbandon(roomId, existingSessionId, generation);
585
676
  this.activeSessions.add(existingSessionId);
586
677
  this.bootstrappedSessions.add(existingSessionId);
587
678
  await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
588
679
  return existingSessionId;
589
680
  }
590
681
  }
591
- const created = await connection.newSession({
682
+ const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
592
683
  cwd: this.cwd,
593
684
  mcpServers
594
- });
595
- this.roomToSession.set(roomId, created.sessionId);
685
+ }));
686
+ this.linkOrAbandon(roomId, created.sessionId, generation);
596
687
  this.activeSessions.add(created.sessionId);
597
688
  await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
598
689
  return created.sessionId;
599
690
  }
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`);
701
+ }
702
+ if (!this.linkSession(roomId, sessionId)) {
703
+ throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
704
+ }
705
+ }
600
706
  // Best-effort: never throws, so a mode switch going wrong can't take a
601
707
  // session establishment down with it.
602
708
  async configureSessionMode(roomId, sessionId, modes, connection) {
@@ -675,7 +781,7 @@ ${messageWithContext}`;
675
781
  return { ok: false };
676
782
  }
677
783
  try {
678
- const restored = await restore();
784
+ const restored = await this.raceAgainstConnectionClose(connection, restore());
679
785
  return { ok: true, modes: restored?.modes };
680
786
  } catch {
681
787
  return { ok: false };
@@ -788,6 +894,36 @@ ${messageWithContext}`;
788
894
  "All Band MCP tool calls must include room_id."
789
895
  ].join("\n");
790
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
+ }
791
927
  async handlePermissionRequest(tools, roomId, params) {
792
928
  const toolName = params.toolCall.title ?? "unknown";
793
929
  const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
@@ -795,28 +931,52 @@ ${messageWithContext}`;
795
931
  if (controller) {
796
932
  this.trackPending(params.sessionId, controller);
797
933
  }
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`.
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`.
803
938
  tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
804
939
  permission_request: true,
805
940
  tool_name: toolName,
806
941
  tool_call_id: params.toolCall.toolCallId,
807
942
  acp_session_id: params.sessionId,
808
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
+ }
809
954
  }),
810
- controller ? this.resolveManually(params.sessionId, params, controller) : Promise.resolve(autoSelection?.optionId)
955
+ controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
811
956
  ]);
812
- 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;
813
963
  }
814
964
  // `undefined`, or an id absent from this request's own `options` (a buggy
815
965
  // or stale caller), both map to `cancelled` — never silently treated as a
816
966
  // 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" } };
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 } };
820
980
  }
821
981
  // A caller-supplied `Logger` isn't guaranteed to be synchronous or
822
982
  // non-throwing. Every best-effort warning in this file routes through here
@@ -830,40 +990,55 @@ ${messageWithContext}`;
830
990
  } catch {
831
991
  }
832
992
  }
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) => {
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) => {
843
1002
  controller.signal.addEventListener("abort", () => resolve(void 0));
844
1003
  });
1004
+ const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
845
1005
  try {
846
- const timeout = new Promise((resolve) => {
847
- timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
848
- });
849
1006
  return await Promise.race([
850
1007
  // `resolvePermission` is caller-supplied; nothing guarantees it's
851
1008
  // `async` or otherwise well-behaved. `Promise.resolve().then(...)`
852
1009
  // normalizes a synchronous throw the same way it normalizes a
853
1010
  // rejected promise, so both land in the `.catch` below rather than
854
1011
  // escaping this race uncaught.
855
- 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) => {
856
1013
  this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
857
1014
  return void 0;
858
1015
  }),
859
- timeout,
860
- cancelled
1016
+ abandoned
861
1017
  ]);
862
1018
  } finally {
863
1019
  clearTimeout(timer);
864
- this.untrackPending(sessionId, controller);
865
- controller.abort();
1020
+ this.untrackPending(params.sessionId, controller);
1021
+ }
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;
866
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);
867
1042
  }
868
1043
  trackPending(sessionId, controller) {
869
1044
  const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
@@ -877,14 +1052,14 @@ ${messageWithContext}`;
877
1052
  this.pendingPermissions.delete(sessionId);
878
1053
  }
879
1054
  }
880
- cancelPendingPermissions(sessionId) {
1055
+ cancelPendingPermissions(sessionId, reason) {
881
1056
  for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
882
- controller.abort();
1057
+ this.abandon(controller, reason);
883
1058
  }
884
1059
  }
885
- cancelAllPendingPermissions() {
1060
+ cancelAllPendingPermissions(reason) {
886
1061
  for (const sessionId of this.pendingPermissions.keys()) {
887
- this.cancelPendingPermissions(sessionId);
1062
+ this.cancelPendingPermissions(sessionId, reason);
888
1063
  }
889
1064
  }
890
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.1",
3
+ "version": "0.3.2",
4
4
  "description": "Band TypeScript SDK core runtime",
5
5
  "license": "MIT",
6
6
  "repository": {