@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 +275 -78
- package/dist/adapters.d.cts +24 -3
- package/dist/adapters.d.ts +24 -3
- package/dist/adapters.js +271 -77
- package/dist/{chunk-JJF44XWA.js → chunk-U6AFCLYZ.js} +13 -6
- package/dist/{chunk-FYVLUGW7.js → chunk-V5TSWS7P.js} +4 -1
- package/dist/converters.cjs +4 -1
- package/dist/converters.js +1 -1
- package/dist/index.cjs +15 -13
- package/dist/index.js +1 -1
- package/dist/runtime.cjs +12 -10
- package/dist/runtime.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,35 +1908,21 @@ 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
|
-
}
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2579
|
-
|
|
2580
|
-
//
|
|
2581
|
-
//
|
|
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(
|
|
2757
|
+
controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
|
|
2591
2758
|
]);
|
|
2592
|
-
|
|
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
|
-
|
|
2599
|
-
|
|
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
|
|
2614
|
-
//
|
|
2615
|
-
// `cancelPendingPermissions
|
|
2616
|
-
// `
|
|
2617
|
-
//
|
|
2618
|
-
//
|
|
2619
|
-
//
|
|
2620
|
-
async resolveManually(
|
|
2621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|