@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 +311 -96
- package/dist/adapters.d.cts +34 -6
- package/dist/adapters.d.ts +34 -6
- package/dist/adapters.js +307 -95
- package/dist/{chunk-FYVLUGW7.js → chunk-V5TSWS7P.js} +4 -1
- package/dist/converters.cjs +4 -1
- package/dist/converters.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
2468
|
+
}));
|
|
2469
|
+
this.linkOrAbandon(roomId, created.sessionId, generation);
|
|
2379
2470
|
this.activeSessions.add(created.sessionId);
|
|
2380
|
-
await this.
|
|
2471
|
+
await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
|
|
2381
2472
|
return created.sessionId;
|
|
2382
2473
|
}
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
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
|
-
|
|
2391
|
-
|
|
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
|
|
2400
|
-
|
|
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 (
|
|
2406
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
2526
|
+
this.safeWarn("failed to switch session into the selected mode", {
|
|
2421
2527
|
sessionId,
|
|
2422
|
-
|
|
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
|
-
|
|
2542
|
-
|
|
2543
|
-
//
|
|
2544
|
-
//
|
|
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(
|
|
2738
|
+
controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
|
|
2554
2739
|
]);
|
|
2555
|
-
|
|
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
|
-
|
|
2562
|
-
|
|
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
|
|
2577
|
-
//
|
|
2578
|
-
// `cancelPendingPermissions
|
|
2579
|
-
// `
|
|
2580
|
-
//
|
|
2581
|
-
//
|
|
2582
|
-
//
|
|
2583
|
-
async resolveManually(
|
|
2584
|
-
|
|
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
|
-
|
|
2603
|
-
cancelled
|
|
2799
|
+
abandoned
|
|
2604
2800
|
]);
|
|
2605
2801
|
} finally {
|
|
2606
2802
|
clearTimeout(timer);
|
|
2607
|
-
this.untrackPending(sessionId, controller);
|
|
2608
|
-
|
|
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
|
-
|
|
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) {
|