@band-ai/sdk 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapters.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
+ return this.permissionHandler(params);
1911
1912
  }
1912
- setPermissionHandler(sessionId, handler) {
1913
- if (!handler) {
1914
- this.permissionHandlers.delete(sessionId);
1915
- return;
1916
- }
1917
- this.permissionHandlers.set(sessionId, handler);
1918
- }
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,13 +2157,21 @@ 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;
2173
+ resolveSessionMode;
2170
2174
  permissionTimeoutMs;
2171
- requestedPermissionMode;
2172
2175
  logger;
2173
2176
  backend = null;
2174
2177
  backendPromise = null;
@@ -2197,13 +2200,10 @@ var ACPClientAdapter = class extends SimpleAdapter {
2197
2200
  this.clientCapabilities = options.clientCapabilities;
2198
2201
  this.connectionFactory = options.connectionFactory ?? createSubprocessConnection;
2199
2202
  this.resolvePermission = options.resolvePermission;
2203
+ this.resolveSessionMode = options.resolveSessionMode;
2200
2204
  this.logger = options.logger ?? new NoopLogger();
2201
2205
  this.permissionTimeoutMs = options.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS;
2202
- this.requestedPermissionMode = options.requestedPermissionMode;
2203
- if (this.requestedPermissionMode !== void 0 && this.requestedPermissionMode.length === 0) {
2204
- throw new ValidationError("requestedPermissionMode must be a non-empty mode id, got an empty string");
2205
- }
2206
- if (this.resolvePermission && (!Number.isFinite(this.permissionTimeoutMs) || this.permissionTimeoutMs <= 0)) {
2206
+ if ((this.resolvePermission || this.resolveSessionMode) && (!Number.isFinite(this.permissionTimeoutMs) || this.permissionTimeoutMs <= 0)) {
2207
2207
  throw new ValidationError(`permissionTimeoutMs must be a positive finite number, got ${options.permissionTimeoutMs}`);
2208
2208
  }
2209
2209
  }
@@ -2222,17 +2222,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
2222
2222
  this.rehydrate(history);
2223
2223
  }
2224
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) {
2225
2228
  const connection = await this.ensureConnection();
2226
2229
  const client = this.client;
2227
2230
  if (!client) {
2228
2231
  throw new Error("ACP client was not initialized");
2229
2232
  }
2230
2233
  const sessionId = await this.getOrCreateSession(context.roomId, connection);
2231
- client.resetSession(sessionId);
2232
- client.setPermissionHandler(
2233
- sessionId,
2234
- (params) => this.handlePermissionRequest(tools, context.roomId, params)
2235
- );
2234
+ client.resetChunks(sessionId);
2236
2235
  const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
2237
2236
  const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
2238
2237
  const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
@@ -2265,15 +2264,28 @@ ${messageWithContext}`;
2265
2264
  acp_client_room_id: context.roomId
2266
2265
  });
2267
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
+ }
2268
2279
  async onCleanup(roomId) {
2269
- const sessionId = this.roomToSession.get(roomId);
2270
- this.roomToSession.delete(roomId);
2280
+ const sessionId = this.unlinkRoom(roomId);
2271
2281
  this.roomTools.delete(roomId);
2282
+ this.sessionsInFlight.delete(roomId);
2283
+ this.roomTurnLocks.delete(roomId);
2284
+ this.nextRoomGeneration(roomId);
2272
2285
  if (sessionId) {
2273
2286
  this.activeSessions.delete(sessionId);
2274
2287
  this.bootstrappedSessions.delete(sessionId);
2275
- this.client?.setPermissionHandler(sessionId, void 0);
2276
- this.cancelPendingPermissions(sessionId);
2288
+ this.cancelPendingPermissions(sessionId, "room-closed");
2277
2289
  }
2278
2290
  }
2279
2291
  async onRuntimeStop() {
@@ -2285,8 +2297,14 @@ ${messageWithContext}`;
2285
2297
  this.activeSessions.clear();
2286
2298
  this.bootstrappedSessions.clear();
2287
2299
  this.roomToSession.clear();
2300
+ this.sessionToRoom.clear();
2288
2301
  this.roomTools.clear();
2289
- 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");
2290
2308
  this.client = null;
2291
2309
  this.connection = null;
2292
2310
  if (this.backend) {
@@ -2303,9 +2321,65 @@ ${messageWithContext}`;
2303
2321
  rehydrate(history) {
2304
2322
  for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
2305
2323
  if (!this.roomToSession.has(roomId)) {
2306
- this.roomToSession.set(roomId, sessionId);
2307
- }
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);
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);
2308
2381
  }
2382
+ return sessionId;
2309
2383
  }
2310
2384
  async ensureConnection() {
2311
2385
  if (this.connection && !this.connection.signal.aborted) {
@@ -2326,7 +2400,7 @@ ${messageWithContext}`;
2326
2400
  }
2327
2401
  async spawnConnection() {
2328
2402
  const acp = await acpModule.get();
2329
- const client = new BandACPClient();
2403
+ const client = new BandACPClient((params) => this.routePermissionRequest(params));
2330
2404
  const handle = await this.connectionFactory(client, {
2331
2405
  command: this.command,
2332
2406
  cwd: this.cwd,
@@ -2352,6 +2426,7 @@ ${messageWithContext}`;
2352
2426
  this.connectionHandle = null;
2353
2427
  this.connectionState = null;
2354
2428
  this.activeSessions.clear();
2429
+ this.cancelAllPendingPermissions("connection-lost");
2355
2430
  }
2356
2431
  });
2357
2432
  return connection;
@@ -2361,69 +2436,140 @@ ${messageWithContext}`;
2361
2436
  if (existingSessionId && this.activeSessions.has(existingSessionId)) {
2362
2437
  return existingSessionId;
2363
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) {
2364
2454
  const mcpServers = await this.buildSessionMcpServers();
2365
2455
  if (existingSessionId) {
2366
2456
  const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
2367
2457
  if (restored.ok) {
2458
+ this.linkOrAbandon(roomId, existingSessionId, generation);
2368
2459
  this.activeSessions.add(existingSessionId);
2369
2460
  this.bootstrappedSessions.add(existingSessionId);
2370
- await this.applyRequestedPermissionMode(connection, existingSessionId, restored.modes);
2461
+ await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
2371
2462
  return existingSessionId;
2372
2463
  }
2373
2464
  }
2374
- const created = await connection.newSession({
2465
+ const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
2375
2466
  cwd: this.cwd,
2376
2467
  mcpServers
2377
- });
2378
- this.roomToSession.set(roomId, created.sessionId);
2468
+ }));
2469
+ this.linkOrAbandon(roomId, created.sessionId, generation);
2379
2470
  this.activeSessions.add(created.sessionId);
2380
- await this.applyRequestedPermissionMode(connection, created.sessionId, created.modes);
2471
+ await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
2381
2472
  return created.sessionId;
2382
2473
  }
2383
- async tryRestoreSession(connection, sessionId, mcpServers) {
2384
- const capabilities = this.connectionState?.agentCapabilities;
2385
- const params = { cwd: this.cwd, mcpServers, sessionId };
2386
- const restore = capabilities?.loadSession ? () => connection.loadSession(params) : capabilities?.sessionCapabilities?.resume ? () => connection.unstable_resumeSession(params) : null;
2387
- if (!restore) {
2388
- return { ok: false };
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`);
2389
2484
  }
2390
- try {
2391
- const restored = await restore();
2392
- return { ok: true, modes: restored?.modes };
2393
- } catch {
2394
- return { ok: false };
2485
+ if (!this.linkSession(roomId, sessionId)) {
2486
+ throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
2395
2487
  }
2396
2488
  }
2397
2489
  // Best-effort: never throws, so a mode switch going wrong can't take a
2398
2490
  // session establishment down with it.
2399
- async applyRequestedPermissionMode(connection, sessionId, modes) {
2400
- const requestedModeId = this.requestedPermissionMode;
2401
- if (!requestedModeId || !modes || modes.currentModeId === requestedModeId) {
2491
+ async configureSessionMode(roomId, sessionId, modes, connection) {
2492
+ if (!this.resolveSessionMode || !modes) {
2402
2493
  return;
2403
2494
  }
2404
2495
  const availableModes = Array.isArray(modes.availableModes) ? modes.availableModes : [];
2405
- if (!availableModes.some((mode) => mode?.id === requestedModeId)) {
2406
- this.safeWarn("requested permission mode is not advertised by this session", {
2496
+ if (availableModes.length === 0) {
2497
+ return;
2498
+ }
2499
+ const selectedModeId = await this.resolveSessionModeManually(
2500
+ (signal) => this.resolveSessionMode({
2501
+ roomId,
2502
+ sessionId,
2503
+ currentModeId: modes.currentModeId,
2504
+ modes: availableModes
2505
+ }, signal),
2506
+ connection.signal
2507
+ );
2508
+ if (!selectedModeId || selectedModeId === modes.currentModeId) {
2509
+ return;
2510
+ }
2511
+ if (!availableModes.some((mode) => mode?.id === selectedModeId)) {
2512
+ this.safeWarn("resolveSessionMode selected a mode id this session does not advertise", {
2407
2513
  sessionId,
2408
- requestedModeId,
2514
+ selectedModeId,
2409
2515
  availableModeIds: availableModes.map((mode) => mode?.id)
2410
2516
  });
2411
2517
  return;
2412
2518
  }
2413
2519
  try {
2414
2520
  await withTimeout(
2415
- connection.setSessionMode({ sessionId, modeId: requestedModeId }),
2521
+ connection.setSessionMode({ sessionId, modeId: selectedModeId }),
2416
2522
  SET_SESSION_MODE_TIMEOUT_MS,
2417
2523
  `setSessionMode did not respond within ${SET_SESSION_MODE_TIMEOUT_MS}ms`
2418
2524
  );
2419
2525
  } catch (error) {
2420
- this.safeWarn("failed to switch session into the requested permission mode", {
2526
+ this.safeWarn("failed to switch session into the selected mode", {
2421
2527
  sessionId,
2422
- requestedModeId,
2528
+ selectedModeId,
2423
2529
  error: String(error)
2424
2530
  });
2425
2531
  }
2426
2532
  }
2533
+ async resolveSessionModeManually(resolver, signal) {
2534
+ const controller = new AbortController();
2535
+ const abort = () => controller.abort();
2536
+ signal.addEventListener("abort", abort, { once: true });
2537
+ let timer;
2538
+ try {
2539
+ const cancelled = new Promise((resolve) => {
2540
+ controller.signal.addEventListener("abort", () => resolve(void 0), { once: true });
2541
+ });
2542
+ const timeout = new Promise((resolve) => {
2543
+ timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
2544
+ });
2545
+ return await Promise.race([
2546
+ Promise.resolve().then(() => resolver(controller.signal)).catch((error) => {
2547
+ this.safeWarn("resolveSessionMode threw; preserving the harness default", { error: String(error) });
2548
+ return void 0;
2549
+ }),
2550
+ timeout,
2551
+ cancelled
2552
+ ]);
2553
+ } finally {
2554
+ clearTimeout(timer);
2555
+ signal.removeEventListener("abort", abort);
2556
+ controller.abort();
2557
+ }
2558
+ }
2559
+ async tryRestoreSession(connection, sessionId, mcpServers) {
2560
+ const capabilities = this.connectionState?.agentCapabilities;
2561
+ const params = { cwd: this.cwd, mcpServers, sessionId };
2562
+ const restore = capabilities?.loadSession ? () => connection.loadSession(params) : capabilities?.sessionCapabilities?.resume ? () => connection.unstable_resumeSession(params) : null;
2563
+ if (!restore) {
2564
+ return { ok: false };
2565
+ }
2566
+ try {
2567
+ const restored = await this.raceAgainstConnectionClose(connection, restore());
2568
+ return { ok: true, modes: restored?.modes };
2569
+ } catch {
2570
+ return { ok: false };
2571
+ }
2572
+ }
2427
2573
  async buildSessionMcpServers() {
2428
2574
  const mcpServers = [...this.mcpServers];
2429
2575
  if (!this.enableMcpTools) {
@@ -2531,6 +2677,36 @@ ${messageWithContext}`;
2531
2677
  "All Band MCP tool calls must include room_id."
2532
2678
  ].join("\n");
2533
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
+ }
2534
2710
  async handlePermissionRequest(tools, roomId, params) {
2535
2711
  const toolName = params.toolCall.title ?? "unknown";
2536
2712
  const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
@@ -2538,28 +2714,52 @@ ${messageWithContext}`;
2538
2714
  if (controller) {
2539
2715
  this.trackPending(params.sessionId, controller);
2540
2716
  }
2541
- const [, chosenId] = await Promise.all([
2542
- // This is the room's only "a permission request is pending" signal,
2543
- // and the only one other room participants ever see — started
2544
- // immediately rather than serialized in front of a manual wait that
2545
- // 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`.
2546
2721
  tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
2547
2722
  permission_request: true,
2548
2723
  tool_name: toolName,
2549
2724
  tool_call_id: params.toolCall.toolCallId,
2550
2725
  acp_session_id: params.sessionId,
2551
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
+ }
2552
2737
  }),
2553
- controller ? this.resolveManually(params.sessionId, params, controller) : Promise.resolve(autoSelection?.optionId)
2738
+ controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
2554
2739
  ]);
2555
- 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;
2556
2746
  }
2557
2747
  // `undefined`, or an id absent from this request's own `options` (a buggy
2558
2748
  // or stale caller), both map to `cancelled` — never silently treated as a
2559
2749
  // deny. A real match, reject-kind options included, maps to `selected`.
2560
- toResponse(chosenId, options) {
2561
- const matched = chosenId !== void 0 && options.some((option) => option.optionId === chosenId);
2562
- 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 } };
2563
2763
  }
2564
2764
  // A caller-supplied `Logger` isn't guaranteed to be synchronous or
2565
2765
  // non-throwing. Every best-effort warning in this file routes through here
@@ -2573,40 +2773,55 @@ ${messageWithContext}`;
2573
2773
  } catch {
2574
2774
  }
2575
2775
  }
2576
- // Races the caller-supplied resolver against a timeout and against
2577
- // `controller`'s own abort signal aborted externally by
2578
- // `cancelPendingPermissions`/`cancelAllPendingPermissions` (fired from
2579
- // `onCleanup`/`stop()` below) when a room or the whole adapter tears down
2580
- // while this is still pending. `controller` is the same object tracked in
2581
- // `pendingPermissions` by the caller, so there is exactly one cancellation
2582
- // channel here, not a second hand-rolled one alongside it.
2583
- async resolveManually(sessionId, params, controller) {
2584
- let timer;
2585
- 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) => {
2586
2785
  controller.signal.addEventListener("abort", () => resolve(void 0));
2587
2786
  });
2787
+ const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
2588
2788
  try {
2589
- const timeout = new Promise((resolve) => {
2590
- timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
2591
- });
2592
2789
  return await Promise.race([
2593
2790
  // `resolvePermission` is caller-supplied; nothing guarantees it's
2594
2791
  // `async` or otherwise well-behaved. `Promise.resolve().then(...)`
2595
2792
  // normalizes a synchronous throw the same way it normalizes a
2596
2793
  // rejected promise, so both land in the `.catch` below rather than
2597
2794
  // escaping this race uncaught.
2598
- 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) => {
2599
2796
  this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
2600
2797
  return void 0;
2601
2798
  }),
2602
- timeout,
2603
- cancelled
2799
+ abandoned
2604
2800
  ]);
2605
2801
  } finally {
2606
2802
  clearTimeout(timer);
2607
- this.untrackPending(sessionId, controller);
2608
- controller.abort();
2803
+ this.untrackPending(params.sessionId, controller);
2804
+ }
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;
2609
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);
2610
2825
  }
2611
2826
  trackPending(sessionId, controller) {
2612
2827
  const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
@@ -2620,14 +2835,14 @@ ${messageWithContext}`;
2620
2835
  this.pendingPermissions.delete(sessionId);
2621
2836
  }
2622
2837
  }
2623
- cancelPendingPermissions(sessionId) {
2838
+ cancelPendingPermissions(sessionId, reason) {
2624
2839
  for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
2625
- controller.abort();
2840
+ this.abandon(controller, reason);
2626
2841
  }
2627
2842
  }
2628
- cancelAllPendingPermissions() {
2843
+ cancelAllPendingPermissions(reason) {
2629
2844
  for (const sessionId of this.pendingPermissions.keys()) {
2630
- this.cancelPendingPermissions(sessionId);
2845
+ this.cancelPendingPermissions(sessionId, reason);
2631
2846
  }
2632
2847
  }
2633
2848
  async flushChunks(input) {