@band-ai/sdk 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapters.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,35 +1908,21 @@ 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("");
1925
1920
  }
1926
1921
  getCollectedChunks(sessionId) {
1927
1922
  if (sessionId) {
1928
- return [...this.sessionChunks.get(sessionId) ?? []];
1923
+ return coalesceChunks(this.sessionChunks.get(sessionId) ?? []);
1929
1924
  }
1930
- return [...this.sessionChunks.values()].flatMap((chunks) => chunks);
1925
+ return [...this.sessionChunks.values()].flatMap((chunks) => coalesceChunks(chunks));
1931
1926
  }
1932
1927
  async extMethod(method, params) {
1933
1928
  if (method === "cursor/ask_question") {
@@ -1970,7 +1965,8 @@ var BandACPClient = class {
1970
1965
  this.appendChunk(sessionId, {
1971
1966
  chunkType: "plan",
1972
1967
  content: lines.join("\n"),
1973
- metadata: {}
1968
+ metadata: {},
1969
+ streamed: false
1974
1970
  });
1975
1971
  }
1976
1972
  return;
@@ -1981,7 +1977,8 @@ var BandACPClient = class {
1981
1977
  this.appendChunk(sessionId, {
1982
1978
  chunkType: "text",
1983
1979
  content: `[Task completed] ${result}`,
1984
- metadata: {}
1980
+ metadata: {},
1981
+ streamed: false
1985
1982
  });
1986
1983
  }
1987
1984
  }
@@ -1992,19 +1989,33 @@ var BandACPClient = class {
1992
1989
  this.sessionChunks.set(sessionId, existing);
1993
1990
  }
1994
1991
  };
1992
+ function coalesceChunks(chunks) {
1993
+ const result = [];
1994
+ for (const chunk of chunks) {
1995
+ const last = result[result.length - 1];
1996
+ if (chunk.streamed && last?.streamed && last.chunkType === chunk.chunkType) {
1997
+ last.content += chunk.content;
1998
+ continue;
1999
+ }
2000
+ result.push({ ...chunk });
2001
+ }
2002
+ return result;
2003
+ }
1995
2004
  function toCollectedChunk(update) {
1996
2005
  switch (update.sessionUpdate) {
1997
2006
  case "agent_message_chunk":
1998
2007
  return {
1999
2008
  chunkType: "text",
2000
2009
  content: extractTextFromContent(update.content),
2001
- metadata: {}
2010
+ metadata: {},
2011
+ streamed: true
2002
2012
  };
2003
2013
  case "agent_thought_chunk":
2004
2014
  return {
2005
2015
  chunkType: "thought",
2006
2016
  content: extractTextFromContent(update.content),
2007
- metadata: {}
2017
+ metadata: {},
2018
+ streamed: true
2008
2019
  };
2009
2020
  case "tool_call":
2010
2021
  return {
@@ -2014,7 +2025,8 @@ function toCollectedChunk(update) {
2014
2025
  tool_call_id: update.toolCallId,
2015
2026
  raw_input: update.rawInput,
2016
2027
  status: update.status ?? "pending"
2017
- }
2028
+ },
2029
+ streamed: false
2018
2030
  };
2019
2031
  case "tool_call_update":
2020
2032
  return {
@@ -2023,13 +2035,15 @@ function toCollectedChunk(update) {
2023
2035
  metadata: {
2024
2036
  tool_call_id: update.toolCallId,
2025
2037
  status: update.status ?? "completed"
2026
- }
2038
+ },
2039
+ streamed: false
2027
2040
  };
2028
2041
  case "plan":
2029
2042
  return {
2030
2043
  chunkType: "plan",
2031
2044
  content: update.entries.map((entry) => entry.content).join("\n"),
2032
- metadata: {}
2045
+ metadata: {},
2046
+ streamed: false
2033
2047
  };
2034
2048
  default:
2035
2049
  return null;
@@ -2162,10 +2176,18 @@ var ACPClientAdapter = class extends SimpleAdapter {
2162
2176
  clientCapabilities;
2163
2177
  connectionFactory;
2164
2178
  roomToSession = /* @__PURE__ */ new Map();
2179
+ sessionToRoom = /* @__PURE__ */ new Map();
2165
2180
  roomTools = /* @__PURE__ */ new Map();
2166
2181
  activeSessions = /* @__PURE__ */ new Set();
2167
2182
  bootstrappedSessions = /* @__PURE__ */ new Set();
2168
2183
  pendingPermissions = /* @__PURE__ */ new Map();
2184
+ sessionsInFlight = /* @__PURE__ */ new Map();
2185
+ roomTurnLocks = /* @__PURE__ */ new Map();
2186
+ // Bumped each time a room starts a *new* establishment (never on a
2187
+ // coalesced reuse) and whenever a room is torn down. An establishment
2188
+ // captures its own value at the start; if the room has moved on by the
2189
+ // time it would link/activate a session, it was superseded and must not.
2190
+ roomGeneration = /* @__PURE__ */ new Map();
2169
2191
  resolvePermission;
2170
2192
  resolveSessionMode;
2171
2193
  permissionTimeoutMs;
@@ -2219,17 +2241,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
2219
2241
  this.rehydrate(history);
2220
2242
  }
2221
2243
  this.roomTools.set(context.roomId, tools);
2244
+ await this.withRoomTurnLock(context.roomId, () => this.runTurn(message, tools, participantsMessage, contactsMessage, context));
2245
+ }
2246
+ async runTurn(message, tools, participantsMessage, contactsMessage, context) {
2222
2247
  const connection = await this.ensureConnection();
2223
2248
  const client = this.client;
2224
2249
  if (!client) {
2225
2250
  throw new Error("ACP client was not initialized");
2226
2251
  }
2227
2252
  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
- );
2253
+ client.resetChunks(sessionId);
2233
2254
  const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
2234
2255
  const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
2235
2256
  const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
@@ -2262,15 +2283,28 @@ ${messageWithContext}`;
2262
2283
  acp_client_room_id: context.roomId
2263
2284
  });
2264
2285
  }
2286
+ // A per-room async mutex: `fn` for a given `roomId` never overlaps another
2287
+ // call for that same room, while different rooms stay fully concurrent.
2288
+ // The tracked tail (`this.roomTurnLocks`) always settles — via the
2289
+ // trailing `.catch` — so one turn's failure can't wedge every later turn
2290
+ // for the room; the real result/rejection is still `run`, returned to this
2291
+ // call's own caller.
2292
+ async withRoomTurnLock(roomId, fn) {
2293
+ const previous = this.roomTurnLocks.get(roomId) ?? Promise.resolve();
2294
+ const run = previous.then(fn, fn);
2295
+ this.roomTurnLocks.set(roomId, run.catch(() => void 0));
2296
+ return run;
2297
+ }
2265
2298
  async onCleanup(roomId) {
2266
- const sessionId = this.roomToSession.get(roomId);
2267
- this.roomToSession.delete(roomId);
2299
+ const sessionId = this.unlinkRoom(roomId);
2268
2300
  this.roomTools.delete(roomId);
2301
+ this.sessionsInFlight.delete(roomId);
2302
+ this.roomTurnLocks.delete(roomId);
2303
+ this.nextRoomGeneration(roomId);
2269
2304
  if (sessionId) {
2270
2305
  this.activeSessions.delete(sessionId);
2271
2306
  this.bootstrappedSessions.delete(sessionId);
2272
- this.client?.setPermissionHandler(sessionId, void 0);
2273
- this.cancelPendingPermissions(sessionId);
2307
+ this.cancelPendingPermissions(sessionId, "room-closed");
2274
2308
  }
2275
2309
  }
2276
2310
  async onRuntimeStop() {
@@ -2282,8 +2316,14 @@ ${messageWithContext}`;
2282
2316
  this.activeSessions.clear();
2283
2317
  this.bootstrappedSessions.clear();
2284
2318
  this.roomToSession.clear();
2319
+ this.sessionToRoom.clear();
2285
2320
  this.roomTools.clear();
2286
- this.cancelAllPendingPermissions();
2321
+ this.sessionsInFlight.clear();
2322
+ this.roomTurnLocks.clear();
2323
+ for (const roomId of this.roomGeneration.keys()) {
2324
+ this.nextRoomGeneration(roomId);
2325
+ }
2326
+ this.cancelAllPendingPermissions("adapter-stopped");
2287
2327
  this.client = null;
2288
2328
  this.connection = null;
2289
2329
  if (this.backend) {
@@ -2300,9 +2340,65 @@ ${messageWithContext}`;
2300
2340
  rehydrate(history) {
2301
2341
  for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
2302
2342
  if (!this.roomToSession.has(roomId)) {
2303
- this.roomToSession.set(roomId, sessionId);
2304
- }
2343
+ this.linkSession(roomId, sessionId);
2344
+ }
2345
+ }
2346
+ }
2347
+ // The only writer of both session maps, so they cannot drift: replacing a
2348
+ // room's session drops the old session's route, and a session id already
2349
+ // routed to another room is refused rather than silently re-pointed — two
2350
+ // rooms sharing one session id would make its permission requests
2351
+ // unattributable. Returns whether the link was made — a caller that goes
2352
+ // on to activate/configure/prompt a session regardless of a `false` here
2353
+ // would use a session this room was refused, not just fail to route its
2354
+ // permissions.
2355
+ linkSession(roomId, sessionId) {
2356
+ const routedRoomId = this.sessionToRoom.get(sessionId);
2357
+ if (routedRoomId !== void 0 && routedRoomId !== roomId) {
2358
+ this.safeWarn("refusing to route one ACP session to a second room", {
2359
+ sessionId,
2360
+ roomId,
2361
+ routedRoomId
2362
+ });
2363
+ return false;
2364
+ }
2365
+ const replacedSessionId = this.roomToSession.get(roomId);
2366
+ if (replacedSessionId !== void 0 && replacedSessionId !== sessionId) {
2367
+ this.sessionToRoom.delete(replacedSessionId);
2368
+ }
2369
+ this.roomToSession.set(roomId, sessionId);
2370
+ this.sessionToRoom.set(sessionId, roomId);
2371
+ return true;
2372
+ }
2373
+ nextRoomGeneration(roomId) {
2374
+ const next = (this.roomGeneration.get(roomId) ?? 0) + 1;
2375
+ this.roomGeneration.set(roomId, next);
2376
+ return next;
2377
+ }
2378
+ isCurrentGeneration(roomId, generation) {
2379
+ return this.roomGeneration.get(roomId) === generation;
2380
+ }
2381
+ // The installed ACP SDK's `sendRequest` never rejects a pending call when
2382
+ // its connection closes (no server response ever arrives to reject it
2383
+ // with) — so a session-establishment RPC in flight when the subprocess
2384
+ // dies would otherwise hang forever, wedging the room's `sessionsInFlight`
2385
+ // entry along with it. Racing every such RPC against the connection's own
2386
+ // `closed` promise gives it a real, prompt failure instead.
2387
+ raceAgainstConnectionClose(connection, operation) {
2388
+ let reject = () => void 0;
2389
+ const closedRejection = new Promise((_resolve, rejectFn) => {
2390
+ reject = rejectFn;
2391
+ });
2392
+ void connection.closed.then(() => reject(new Error("ACP connection closed while a session operation was still in flight")));
2393
+ return Promise.race([operation, closedRejection]);
2394
+ }
2395
+ unlinkRoom(roomId) {
2396
+ const sessionId = this.roomToSession.get(roomId);
2397
+ this.roomToSession.delete(roomId);
2398
+ if (sessionId !== void 0) {
2399
+ this.sessionToRoom.delete(sessionId);
2305
2400
  }
2401
+ return sessionId;
2306
2402
  }
2307
2403
  async ensureConnection() {
2308
2404
  if (this.connection && !this.connection.signal.aborted) {
@@ -2323,7 +2419,7 @@ ${messageWithContext}`;
2323
2419
  }
2324
2420
  async spawnConnection() {
2325
2421
  const acp = await acpModule.get();
2326
- const client = new BandACPClient();
2422
+ const client = new BandACPClient((params) => this.routePermissionRequest(params));
2327
2423
  const handle = await this.connectionFactory(client, {
2328
2424
  command: this.command,
2329
2425
  cwd: this.cwd,
@@ -2349,6 +2445,7 @@ ${messageWithContext}`;
2349
2445
  this.connectionHandle = null;
2350
2446
  this.connectionState = null;
2351
2447
  this.activeSessions.clear();
2448
+ this.cancelAllPendingPermissions("connection-lost");
2352
2449
  }
2353
2450
  });
2354
2451
  return connection;
@@ -2358,25 +2455,56 @@ ${messageWithContext}`;
2358
2455
  if (existingSessionId && this.activeSessions.has(existingSessionId)) {
2359
2456
  return existingSessionId;
2360
2457
  }
2458
+ const inFlight = this.sessionsInFlight.get(roomId);
2459
+ if (inFlight) {
2460
+ return inFlight;
2461
+ }
2462
+ const generation = this.nextRoomGeneration(roomId);
2463
+ const establishing = this.establishSession(roomId, existingSessionId, connection, generation);
2464
+ establishing.finally(() => {
2465
+ if (this.sessionsInFlight.get(roomId) === establishing) {
2466
+ this.sessionsInFlight.delete(roomId);
2467
+ }
2468
+ }).catch(() => void 0);
2469
+ this.sessionsInFlight.set(roomId, establishing);
2470
+ return establishing;
2471
+ }
2472
+ async establishSession(roomId, existingSessionId, connection, generation) {
2361
2473
  const mcpServers = await this.buildSessionMcpServers();
2362
2474
  if (existingSessionId) {
2363
2475
  const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
2364
2476
  if (restored.ok) {
2477
+ this.linkOrAbandon(roomId, existingSessionId, generation);
2365
2478
  this.activeSessions.add(existingSessionId);
2366
2479
  this.bootstrappedSessions.add(existingSessionId);
2367
2480
  await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
2368
2481
  return existingSessionId;
2369
2482
  }
2370
2483
  }
2371
- const created = await connection.newSession({
2484
+ const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
2372
2485
  cwd: this.cwd,
2373
2486
  mcpServers
2374
- });
2375
- this.roomToSession.set(roomId, created.sessionId);
2487
+ }));
2488
+ this.linkOrAbandon(roomId, created.sessionId, generation);
2376
2489
  this.activeSessions.add(created.sessionId);
2377
2490
  await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
2378
2491
  return created.sessionId;
2379
2492
  }
2493
+ // The single gate an establishment must pass before it's allowed to claim
2494
+ // the room: it must still be the room's current generation (not
2495
+ // superseded by a teardown or a fresher establishment while this one was
2496
+ // awaiting an RPC), and its session id must not already belong to another
2497
+ // room. Either failure throws — this establishment cannot silently
2498
+ // continue to activate, configure, and prompt a session it has no right
2499
+ // to use for this room.
2500
+ linkOrAbandon(roomId, sessionId, generation) {
2501
+ if (!this.isCurrentGeneration(roomId, generation)) {
2502
+ throw new Error(`ACP session establishment for room "${roomId}" was superseded before it could be linked`);
2503
+ }
2504
+ if (!this.linkSession(roomId, sessionId)) {
2505
+ throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
2506
+ }
2507
+ }
2380
2508
  // Best-effort: never throws, so a mode switch going wrong can't take a
2381
2509
  // session establishment down with it.
2382
2510
  async configureSessionMode(roomId, sessionId, modes, connection) {
@@ -2455,7 +2583,7 @@ ${messageWithContext}`;
2455
2583
  return { ok: false };
2456
2584
  }
2457
2585
  try {
2458
- const restored = await restore();
2586
+ const restored = await this.raceAgainstConnectionClose(connection, restore());
2459
2587
  return { ok: true, modes: restored?.modes };
2460
2588
  } catch {
2461
2589
  return { ok: false };
@@ -2568,6 +2696,36 @@ ${messageWithContext}`;
2568
2696
  "All Band MCP tool calls must include room_id."
2569
2697
  ].join("\n");
2570
2698
  }
2699
+ // The connection's single permission entry point, and total by
2700
+ // construction: every path resolves, nothing throws, and a request that
2701
+ // can't be attributed to a live room is cancelled *and* warned rather than
2702
+ // silently declined. `activeSessions` is the gate that keeps a dead
2703
+ // session from raising a live prompt — `roomToSession` deliberately
2704
+ // outlives a dropped connection so the session can be restored later.
2705
+ async routePermissionRequest(params) {
2706
+ const isActive = this.activeSessions.has(params.sessionId);
2707
+ const roomId = isActive ? this.sessionToRoom.get(params.sessionId) : void 0;
2708
+ const tools = roomId === void 0 ? void 0 : this.roomTools.get(roomId);
2709
+ if (roomId === void 0 || !tools) {
2710
+ this.safeWarn("cancelling a permission request that maps to no live room", {
2711
+ sessionId: params.sessionId,
2712
+ toolName: params.toolCall?.title,
2713
+ sessionActive: isActive,
2714
+ roomId
2715
+ });
2716
+ return { outcome: { outcome: "cancelled" } };
2717
+ }
2718
+ try {
2719
+ return await this.handlePermissionRequest(tools, roomId, params);
2720
+ } catch (error) {
2721
+ this.safeWarn("permission handling failed; cancelling the request", {
2722
+ sessionId: params.sessionId,
2723
+ roomId,
2724
+ error: String(error)
2725
+ });
2726
+ return { outcome: { outcome: "cancelled" } };
2727
+ }
2728
+ }
2571
2729
  async handlePermissionRequest(tools, roomId, params) {
2572
2730
  const toolName = params.toolCall.title ?? "unknown";
2573
2731
  const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
@@ -2575,28 +2733,52 @@ ${messageWithContext}`;
2575
2733
  if (controller) {
2576
2734
  this.trackPending(params.sessionId, controller);
2577
2735
  }
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`.
2736
+ let requestEventFailed = false;
2737
+ const [, resolvedChosenId] = await Promise.all([
2738
+ // Started immediately rather than serialized in front of a manual
2739
+ // wait that can take up to `permissionTimeoutMs`.
2583
2740
  tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
2584
2741
  permission_request: true,
2585
2742
  tool_name: toolName,
2586
2743
  tool_call_id: params.toolCall.toolCallId,
2587
2744
  acp_session_id: params.sessionId,
2588
2745
  auto_allowed: autoSelection !== void 0 && autoSelection !== null
2746
+ }).catch((error) => {
2747
+ requestEventFailed = true;
2748
+ this.safeWarn("failed to post the permission-requested event; cancelling the request", {
2749
+ roomId,
2750
+ sessionId: params.sessionId,
2751
+ error: String(error)
2752
+ });
2753
+ if (controller) {
2754
+ this.abandon(controller, "no-answer");
2755
+ }
2589
2756
  }),
2590
- controller ? this.resolveManually(params.sessionId, params, controller) : Promise.resolve(autoSelection?.optionId)
2757
+ controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
2591
2758
  ]);
2592
- return this.toResponse(chosenId, params.options);
2759
+ const chosenId = requestEventFailed ? void 0 : resolvedChosenId;
2760
+ const response = this.toResponse(chosenId, params.options, { roomId, sessionId: params.sessionId });
2761
+ if (controller && !controller.signal.aborted) {
2762
+ this.abandon(controller, response.outcome.outcome === "selected" ? "settled" : "no-answer");
2763
+ }
2764
+ return response;
2593
2765
  }
2594
2766
  // `undefined`, or an id absent from this request's own `options` (a buggy
2595
2767
  // or stale caller), both map to `cancelled` — never silently treated as a
2596
2768
  // 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" } };
2769
+ toResponse(chosenId, options, context) {
2770
+ if (chosenId === void 0) {
2771
+ return { outcome: { outcome: "cancelled" } };
2772
+ }
2773
+ if (!options.some((option) => option.optionId === chosenId)) {
2774
+ this.safeWarn("resolvePermission chose an option this request does not offer", {
2775
+ ...context,
2776
+ chosenId,
2777
+ optionIds: options.map((option) => option.optionId)
2778
+ });
2779
+ return { outcome: { outcome: "cancelled" } };
2780
+ }
2781
+ return { outcome: { outcome: "selected", optionId: chosenId } };
2600
2782
  }
2601
2783
  // A caller-supplied `Logger` isn't guaranteed to be synchronous or
2602
2784
  // non-throwing. Every best-effort warning in this file routes through here
@@ -2610,41 +2792,56 @@ ${messageWithContext}`;
2610
2792
  } catch {
2611
2793
  }
2612
2794
  }
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) => {
2795
+ // Races the caller-supplied resolver against `controller`'s abort signal,
2796
+ // which is the request's single termination channel: the timeout below
2797
+ // fires it with `"timeout"`, and `cancelPendingPermissions` /
2798
+ // `cancelAllPendingPermissions` fire it with the reason their caller
2799
+ // supplies. `controller` is the same object tracked in `pendingPermissions`,
2800
+ // so there is exactly one cancellation channel here, not a second
2801
+ // hand-rolled one alongside it.
2802
+ async resolveManually(roomId, params, controller) {
2803
+ const abandoned = new Promise((resolve) => {
2623
2804
  controller.signal.addEventListener("abort", () => resolve(void 0));
2624
2805
  });
2806
+ const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
2625
2807
  try {
2626
- const timeout = new Promise((resolve) => {
2627
- timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
2628
- });
2629
2808
  return await Promise.race([
2630
2809
  // `resolvePermission` is caller-supplied; nothing guarantees it's
2631
2810
  // `async` or otherwise well-behaved. `Promise.resolve().then(...)`
2632
2811
  // normalizes a synchronous throw the same way it normalizes a
2633
2812
  // rejected promise, so both land in the `.catch` below rather than
2634
2813
  // escaping this race uncaught.
2635
- Promise.resolve().then(() => this.resolvePermission(params, controller.signal)).catch((error) => {
2814
+ Promise.resolve().then(() => this.resolvePermission({ ...params, roomId }, controller.signal)).then((chosenId) => this.discardLateAnswer(chosenId, controller, roomId, params.sessionId)).catch((error) => {
2636
2815
  this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
2637
2816
  return void 0;
2638
2817
  }),
2639
- timeout,
2640
- cancelled
2818
+ abandoned
2641
2819
  ]);
2642
2820
  } finally {
2643
2821
  clearTimeout(timer);
2644
- this.untrackPending(sessionId, controller);
2645
- controller.abort();
2822
+ this.untrackPending(params.sessionId, controller);
2646
2823
  }
2647
2824
  }
2825
+ // An answer that lands after the request was given up on can no longer be
2826
+ // honoured — the response has already gone back to the agent. It is dropped
2827
+ // either way; warning is what makes "my click did nothing" explicable.
2828
+ discardLateAnswer(chosenId, controller, roomId, sessionId) {
2829
+ if (!controller.signal.aborted || chosenId === void 0) {
2830
+ return chosenId;
2831
+ }
2832
+ this.safeWarn("resolvePermission answered after the request was abandoned; discarding", {
2833
+ roomId,
2834
+ sessionId,
2835
+ chosenId,
2836
+ reason: String(controller.signal.reason)
2837
+ });
2838
+ return void 0;
2839
+ }
2840
+ // The only place a permission's controller is ever aborted, so every
2841
+ // `signal.reason` a consumer can observe comes from the documented union.
2842
+ abandon(controller, reason) {
2843
+ controller.abort(reason);
2844
+ }
2648
2845
  trackPending(sessionId, controller) {
2649
2846
  const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
2650
2847
  pending.add(controller);
@@ -2657,14 +2854,14 @@ ${messageWithContext}`;
2657
2854
  this.pendingPermissions.delete(sessionId);
2658
2855
  }
2659
2856
  }
2660
- cancelPendingPermissions(sessionId) {
2857
+ cancelPendingPermissions(sessionId, reason) {
2661
2858
  for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
2662
- controller.abort();
2859
+ this.abandon(controller, reason);
2663
2860
  }
2664
2861
  }
2665
- cancelAllPendingPermissions() {
2862
+ cancelAllPendingPermissions(reason) {
2666
2863
  for (const sessionId of this.pendingPermissions.keys()) {
2667
- this.cancelPendingPermissions(sessionId);
2864
+ this.cancelPendingPermissions(sessionId, reason);
2668
2865
  }
2669
2866
  }
2670
2867
  async flushChunks(input) {