@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.js
CHANGED
|
@@ -35,7 +35,7 @@ import "./chunk-XWHWJP4I.js";
|
|
|
35
35
|
import {
|
|
36
36
|
ACPClientHistoryConverter,
|
|
37
37
|
ACPServerHistoryConverter
|
|
38
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-V5TSWS7P.js";
|
|
39
39
|
import {
|
|
40
40
|
A2AAdapter,
|
|
41
41
|
A2AGatewayAdapter,
|
|
@@ -158,7 +158,13 @@ function normalizeMcpServers(mcpServers) {
|
|
|
158
158
|
// src/adapters/acp/client.ts
|
|
159
159
|
var BandACPClient = class {
|
|
160
160
|
sessionChunks = /* @__PURE__ */ new Map();
|
|
161
|
-
|
|
161
|
+
permissionHandler;
|
|
162
|
+
// The handler is connection-scoped and required at construction, so it is
|
|
163
|
+
// already in place before the agent process is spawned: there is no window
|
|
164
|
+
// in which a `session/request_permission` has nowhere to go.
|
|
165
|
+
constructor(permissionHandler) {
|
|
166
|
+
this.permissionHandler = permissionHandler;
|
|
167
|
+
}
|
|
162
168
|
async sessionUpdate(params) {
|
|
163
169
|
const chunk = toCollectedChunk(params.update);
|
|
164
170
|
if (!chunk) {
|
|
@@ -169,35 +175,21 @@ var BandACPClient = class {
|
|
|
169
175
|
this.sessionChunks.set(params.sessionId, existing);
|
|
170
176
|
}
|
|
171
177
|
async requestPermission(params) {
|
|
172
|
-
|
|
173
|
-
if (handler) {
|
|
174
|
-
return handler(params);
|
|
175
|
-
}
|
|
176
|
-
return {
|
|
177
|
-
outcome: {
|
|
178
|
-
outcome: "cancelled"
|
|
179
|
-
}
|
|
180
|
-
};
|
|
178
|
+
return this.permissionHandler(params);
|
|
181
179
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
this.permissionHandlers.set(sessionId, handler);
|
|
188
|
-
}
|
|
189
|
-
resetSession(sessionId) {
|
|
180
|
+
// Named for the one thing it clears: collected chunks are per-turn, and a
|
|
181
|
+
// per-turn caller must not be able to reach anything with a longer life.
|
|
182
|
+
resetChunks(sessionId) {
|
|
190
183
|
this.sessionChunks.delete(sessionId);
|
|
191
|
-
this.permissionHandlers.delete(sessionId);
|
|
192
184
|
}
|
|
193
185
|
getCollectedText(sessionId) {
|
|
194
186
|
return this.getCollectedChunks(sessionId).filter((chunk) => chunk.chunkType === "text").map((chunk) => chunk.content).join("");
|
|
195
187
|
}
|
|
196
188
|
getCollectedChunks(sessionId) {
|
|
197
189
|
if (sessionId) {
|
|
198
|
-
return
|
|
190
|
+
return coalesceChunks(this.sessionChunks.get(sessionId) ?? []);
|
|
199
191
|
}
|
|
200
|
-
return [...this.sessionChunks.values()].flatMap((chunks) => chunks);
|
|
192
|
+
return [...this.sessionChunks.values()].flatMap((chunks) => coalesceChunks(chunks));
|
|
201
193
|
}
|
|
202
194
|
async extMethod(method, params) {
|
|
203
195
|
if (method === "cursor/ask_question") {
|
|
@@ -240,7 +232,8 @@ var BandACPClient = class {
|
|
|
240
232
|
this.appendChunk(sessionId, {
|
|
241
233
|
chunkType: "plan",
|
|
242
234
|
content: lines.join("\n"),
|
|
243
|
-
metadata: {}
|
|
235
|
+
metadata: {},
|
|
236
|
+
streamed: false
|
|
244
237
|
});
|
|
245
238
|
}
|
|
246
239
|
return;
|
|
@@ -251,7 +244,8 @@ var BandACPClient = class {
|
|
|
251
244
|
this.appendChunk(sessionId, {
|
|
252
245
|
chunkType: "text",
|
|
253
246
|
content: `[Task completed] ${result}`,
|
|
254
|
-
metadata: {}
|
|
247
|
+
metadata: {},
|
|
248
|
+
streamed: false
|
|
255
249
|
});
|
|
256
250
|
}
|
|
257
251
|
}
|
|
@@ -262,19 +256,33 @@ var BandACPClient = class {
|
|
|
262
256
|
this.sessionChunks.set(sessionId, existing);
|
|
263
257
|
}
|
|
264
258
|
};
|
|
259
|
+
function coalesceChunks(chunks) {
|
|
260
|
+
const result = [];
|
|
261
|
+
for (const chunk of chunks) {
|
|
262
|
+
const last = result[result.length - 1];
|
|
263
|
+
if (chunk.streamed && last?.streamed && last.chunkType === chunk.chunkType) {
|
|
264
|
+
last.content += chunk.content;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
result.push({ ...chunk });
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
265
271
|
function toCollectedChunk(update) {
|
|
266
272
|
switch (update.sessionUpdate) {
|
|
267
273
|
case "agent_message_chunk":
|
|
268
274
|
return {
|
|
269
275
|
chunkType: "text",
|
|
270
276
|
content: extractTextFromContent(update.content),
|
|
271
|
-
metadata: {}
|
|
277
|
+
metadata: {},
|
|
278
|
+
streamed: true
|
|
272
279
|
};
|
|
273
280
|
case "agent_thought_chunk":
|
|
274
281
|
return {
|
|
275
282
|
chunkType: "thought",
|
|
276
283
|
content: extractTextFromContent(update.content),
|
|
277
|
-
metadata: {}
|
|
284
|
+
metadata: {},
|
|
285
|
+
streamed: true
|
|
278
286
|
};
|
|
279
287
|
case "tool_call":
|
|
280
288
|
return {
|
|
@@ -284,7 +292,8 @@ function toCollectedChunk(update) {
|
|
|
284
292
|
tool_call_id: update.toolCallId,
|
|
285
293
|
raw_input: update.rawInput,
|
|
286
294
|
status: update.status ?? "pending"
|
|
287
|
-
}
|
|
295
|
+
},
|
|
296
|
+
streamed: false
|
|
288
297
|
};
|
|
289
298
|
case "tool_call_update":
|
|
290
299
|
return {
|
|
@@ -293,13 +302,15 @@ function toCollectedChunk(update) {
|
|
|
293
302
|
metadata: {
|
|
294
303
|
tool_call_id: update.toolCallId,
|
|
295
304
|
status: update.status ?? "completed"
|
|
296
|
-
}
|
|
305
|
+
},
|
|
306
|
+
streamed: false
|
|
297
307
|
};
|
|
298
308
|
case "plan":
|
|
299
309
|
return {
|
|
300
310
|
chunkType: "plan",
|
|
301
311
|
content: update.entries.map((entry) => entry.content).join("\n"),
|
|
302
|
-
metadata: {}
|
|
312
|
+
metadata: {},
|
|
313
|
+
streamed: false
|
|
303
314
|
};
|
|
304
315
|
default:
|
|
305
316
|
return null;
|
|
@@ -382,10 +393,18 @@ var ACPClientAdapter = class extends SimpleAdapter {
|
|
|
382
393
|
clientCapabilities;
|
|
383
394
|
connectionFactory;
|
|
384
395
|
roomToSession = /* @__PURE__ */ new Map();
|
|
396
|
+
sessionToRoom = /* @__PURE__ */ new Map();
|
|
385
397
|
roomTools = /* @__PURE__ */ new Map();
|
|
386
398
|
activeSessions = /* @__PURE__ */ new Set();
|
|
387
399
|
bootstrappedSessions = /* @__PURE__ */ new Set();
|
|
388
400
|
pendingPermissions = /* @__PURE__ */ new Map();
|
|
401
|
+
sessionsInFlight = /* @__PURE__ */ new Map();
|
|
402
|
+
roomTurnLocks = /* @__PURE__ */ new Map();
|
|
403
|
+
// Bumped each time a room starts a *new* establishment (never on a
|
|
404
|
+
// coalesced reuse) and whenever a room is torn down. An establishment
|
|
405
|
+
// captures its own value at the start; if the room has moved on by the
|
|
406
|
+
// time it would link/activate a session, it was superseded and must not.
|
|
407
|
+
roomGeneration = /* @__PURE__ */ new Map();
|
|
389
408
|
resolvePermission;
|
|
390
409
|
resolveSessionMode;
|
|
391
410
|
permissionTimeoutMs;
|
|
@@ -439,17 +458,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
|
|
|
439
458
|
this.rehydrate(history);
|
|
440
459
|
}
|
|
441
460
|
this.roomTools.set(context.roomId, tools);
|
|
461
|
+
await this.withRoomTurnLock(context.roomId, () => this.runTurn(message, tools, participantsMessage, contactsMessage, context));
|
|
462
|
+
}
|
|
463
|
+
async runTurn(message, tools, participantsMessage, contactsMessage, context) {
|
|
442
464
|
const connection = await this.ensureConnection();
|
|
443
465
|
const client = this.client;
|
|
444
466
|
if (!client) {
|
|
445
467
|
throw new Error("ACP client was not initialized");
|
|
446
468
|
}
|
|
447
469
|
const sessionId = await this.getOrCreateSession(context.roomId, connection);
|
|
448
|
-
client.
|
|
449
|
-
client.setPermissionHandler(
|
|
450
|
-
sessionId,
|
|
451
|
-
(params) => this.handlePermissionRequest(tools, context.roomId, params)
|
|
452
|
-
);
|
|
470
|
+
client.resetChunks(sessionId);
|
|
453
471
|
const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
|
|
454
472
|
const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
|
|
455
473
|
const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
|
|
@@ -482,15 +500,28 @@ ${messageWithContext}`;
|
|
|
482
500
|
acp_client_room_id: context.roomId
|
|
483
501
|
});
|
|
484
502
|
}
|
|
503
|
+
// A per-room async mutex: `fn` for a given `roomId` never overlaps another
|
|
504
|
+
// call for that same room, while different rooms stay fully concurrent.
|
|
505
|
+
// The tracked tail (`this.roomTurnLocks`) always settles — via the
|
|
506
|
+
// trailing `.catch` — so one turn's failure can't wedge every later turn
|
|
507
|
+
// for the room; the real result/rejection is still `run`, returned to this
|
|
508
|
+
// call's own caller.
|
|
509
|
+
async withRoomTurnLock(roomId, fn) {
|
|
510
|
+
const previous = this.roomTurnLocks.get(roomId) ?? Promise.resolve();
|
|
511
|
+
const run = previous.then(fn, fn);
|
|
512
|
+
this.roomTurnLocks.set(roomId, run.catch(() => void 0));
|
|
513
|
+
return run;
|
|
514
|
+
}
|
|
485
515
|
async onCleanup(roomId) {
|
|
486
|
-
const sessionId = this.
|
|
487
|
-
this.roomToSession.delete(roomId);
|
|
516
|
+
const sessionId = this.unlinkRoom(roomId);
|
|
488
517
|
this.roomTools.delete(roomId);
|
|
518
|
+
this.sessionsInFlight.delete(roomId);
|
|
519
|
+
this.roomTurnLocks.delete(roomId);
|
|
520
|
+
this.nextRoomGeneration(roomId);
|
|
489
521
|
if (sessionId) {
|
|
490
522
|
this.activeSessions.delete(sessionId);
|
|
491
523
|
this.bootstrappedSessions.delete(sessionId);
|
|
492
|
-
this.
|
|
493
|
-
this.cancelPendingPermissions(sessionId);
|
|
524
|
+
this.cancelPendingPermissions(sessionId, "room-closed");
|
|
494
525
|
}
|
|
495
526
|
}
|
|
496
527
|
async onRuntimeStop() {
|
|
@@ -502,8 +533,14 @@ ${messageWithContext}`;
|
|
|
502
533
|
this.activeSessions.clear();
|
|
503
534
|
this.bootstrappedSessions.clear();
|
|
504
535
|
this.roomToSession.clear();
|
|
536
|
+
this.sessionToRoom.clear();
|
|
505
537
|
this.roomTools.clear();
|
|
506
|
-
this.
|
|
538
|
+
this.sessionsInFlight.clear();
|
|
539
|
+
this.roomTurnLocks.clear();
|
|
540
|
+
for (const roomId of this.roomGeneration.keys()) {
|
|
541
|
+
this.nextRoomGeneration(roomId);
|
|
542
|
+
}
|
|
543
|
+
this.cancelAllPendingPermissions("adapter-stopped");
|
|
507
544
|
this.client = null;
|
|
508
545
|
this.connection = null;
|
|
509
546
|
if (this.backend) {
|
|
@@ -520,10 +557,66 @@ ${messageWithContext}`;
|
|
|
520
557
|
rehydrate(history) {
|
|
521
558
|
for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
|
|
522
559
|
if (!this.roomToSession.has(roomId)) {
|
|
523
|
-
this.
|
|
560
|
+
this.linkSession(roomId, sessionId);
|
|
524
561
|
}
|
|
525
562
|
}
|
|
526
563
|
}
|
|
564
|
+
// The only writer of both session maps, so they cannot drift: replacing a
|
|
565
|
+
// room's session drops the old session's route, and a session id already
|
|
566
|
+
// routed to another room is refused rather than silently re-pointed — two
|
|
567
|
+
// rooms sharing one session id would make its permission requests
|
|
568
|
+
// unattributable. Returns whether the link was made — a caller that goes
|
|
569
|
+
// on to activate/configure/prompt a session regardless of a `false` here
|
|
570
|
+
// would use a session this room was refused, not just fail to route its
|
|
571
|
+
// permissions.
|
|
572
|
+
linkSession(roomId, sessionId) {
|
|
573
|
+
const routedRoomId = this.sessionToRoom.get(sessionId);
|
|
574
|
+
if (routedRoomId !== void 0 && routedRoomId !== roomId) {
|
|
575
|
+
this.safeWarn("refusing to route one ACP session to a second room", {
|
|
576
|
+
sessionId,
|
|
577
|
+
roomId,
|
|
578
|
+
routedRoomId
|
|
579
|
+
});
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
const replacedSessionId = this.roomToSession.get(roomId);
|
|
583
|
+
if (replacedSessionId !== void 0 && replacedSessionId !== sessionId) {
|
|
584
|
+
this.sessionToRoom.delete(replacedSessionId);
|
|
585
|
+
}
|
|
586
|
+
this.roomToSession.set(roomId, sessionId);
|
|
587
|
+
this.sessionToRoom.set(sessionId, roomId);
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
nextRoomGeneration(roomId) {
|
|
591
|
+
const next = (this.roomGeneration.get(roomId) ?? 0) + 1;
|
|
592
|
+
this.roomGeneration.set(roomId, next);
|
|
593
|
+
return next;
|
|
594
|
+
}
|
|
595
|
+
isCurrentGeneration(roomId, generation) {
|
|
596
|
+
return this.roomGeneration.get(roomId) === generation;
|
|
597
|
+
}
|
|
598
|
+
// The installed ACP SDK's `sendRequest` never rejects a pending call when
|
|
599
|
+
// its connection closes (no server response ever arrives to reject it
|
|
600
|
+
// with) — so a session-establishment RPC in flight when the subprocess
|
|
601
|
+
// dies would otherwise hang forever, wedging the room's `sessionsInFlight`
|
|
602
|
+
// entry along with it. Racing every such RPC against the connection's own
|
|
603
|
+
// `closed` promise gives it a real, prompt failure instead.
|
|
604
|
+
raceAgainstConnectionClose(connection, operation) {
|
|
605
|
+
let reject = () => void 0;
|
|
606
|
+
const closedRejection = new Promise((_resolve, rejectFn) => {
|
|
607
|
+
reject = rejectFn;
|
|
608
|
+
});
|
|
609
|
+
void connection.closed.then(() => reject(new Error("ACP connection closed while a session operation was still in flight")));
|
|
610
|
+
return Promise.race([operation, closedRejection]);
|
|
611
|
+
}
|
|
612
|
+
unlinkRoom(roomId) {
|
|
613
|
+
const sessionId = this.roomToSession.get(roomId);
|
|
614
|
+
this.roomToSession.delete(roomId);
|
|
615
|
+
if (sessionId !== void 0) {
|
|
616
|
+
this.sessionToRoom.delete(sessionId);
|
|
617
|
+
}
|
|
618
|
+
return sessionId;
|
|
619
|
+
}
|
|
527
620
|
async ensureConnection() {
|
|
528
621
|
if (this.connection && !this.connection.signal.aborted) {
|
|
529
622
|
return this.connection;
|
|
@@ -543,7 +636,7 @@ ${messageWithContext}`;
|
|
|
543
636
|
}
|
|
544
637
|
async spawnConnection() {
|
|
545
638
|
const acp = await acpModule.get();
|
|
546
|
-
const client = new BandACPClient();
|
|
639
|
+
const client = new BandACPClient((params) => this.routePermissionRequest(params));
|
|
547
640
|
const handle = await this.connectionFactory(client, {
|
|
548
641
|
command: this.command,
|
|
549
642
|
cwd: this.cwd,
|
|
@@ -569,6 +662,7 @@ ${messageWithContext}`;
|
|
|
569
662
|
this.connectionHandle = null;
|
|
570
663
|
this.connectionState = null;
|
|
571
664
|
this.activeSessions.clear();
|
|
665
|
+
this.cancelAllPendingPermissions("connection-lost");
|
|
572
666
|
}
|
|
573
667
|
});
|
|
574
668
|
return connection;
|
|
@@ -578,25 +672,56 @@ ${messageWithContext}`;
|
|
|
578
672
|
if (existingSessionId && this.activeSessions.has(existingSessionId)) {
|
|
579
673
|
return existingSessionId;
|
|
580
674
|
}
|
|
675
|
+
const inFlight = this.sessionsInFlight.get(roomId);
|
|
676
|
+
if (inFlight) {
|
|
677
|
+
return inFlight;
|
|
678
|
+
}
|
|
679
|
+
const generation = this.nextRoomGeneration(roomId);
|
|
680
|
+
const establishing = this.establishSession(roomId, existingSessionId, connection, generation);
|
|
681
|
+
establishing.finally(() => {
|
|
682
|
+
if (this.sessionsInFlight.get(roomId) === establishing) {
|
|
683
|
+
this.sessionsInFlight.delete(roomId);
|
|
684
|
+
}
|
|
685
|
+
}).catch(() => void 0);
|
|
686
|
+
this.sessionsInFlight.set(roomId, establishing);
|
|
687
|
+
return establishing;
|
|
688
|
+
}
|
|
689
|
+
async establishSession(roomId, existingSessionId, connection, generation) {
|
|
581
690
|
const mcpServers = await this.buildSessionMcpServers();
|
|
582
691
|
if (existingSessionId) {
|
|
583
692
|
const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
|
|
584
693
|
if (restored.ok) {
|
|
694
|
+
this.linkOrAbandon(roomId, existingSessionId, generation);
|
|
585
695
|
this.activeSessions.add(existingSessionId);
|
|
586
696
|
this.bootstrappedSessions.add(existingSessionId);
|
|
587
697
|
await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
|
|
588
698
|
return existingSessionId;
|
|
589
699
|
}
|
|
590
700
|
}
|
|
591
|
-
const created = await connection.newSession({
|
|
701
|
+
const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
|
|
592
702
|
cwd: this.cwd,
|
|
593
703
|
mcpServers
|
|
594
|
-
});
|
|
595
|
-
this.
|
|
704
|
+
}));
|
|
705
|
+
this.linkOrAbandon(roomId, created.sessionId, generation);
|
|
596
706
|
this.activeSessions.add(created.sessionId);
|
|
597
707
|
await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
|
|
598
708
|
return created.sessionId;
|
|
599
709
|
}
|
|
710
|
+
// The single gate an establishment must pass before it's allowed to claim
|
|
711
|
+
// the room: it must still be the room's current generation (not
|
|
712
|
+
// superseded by a teardown or a fresher establishment while this one was
|
|
713
|
+
// awaiting an RPC), and its session id must not already belong to another
|
|
714
|
+
// room. Either failure throws — this establishment cannot silently
|
|
715
|
+
// continue to activate, configure, and prompt a session it has no right
|
|
716
|
+
// to use for this room.
|
|
717
|
+
linkOrAbandon(roomId, sessionId, generation) {
|
|
718
|
+
if (!this.isCurrentGeneration(roomId, generation)) {
|
|
719
|
+
throw new Error(`ACP session establishment for room "${roomId}" was superseded before it could be linked`);
|
|
720
|
+
}
|
|
721
|
+
if (!this.linkSession(roomId, sessionId)) {
|
|
722
|
+
throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
600
725
|
// Best-effort: never throws, so a mode switch going wrong can't take a
|
|
601
726
|
// session establishment down with it.
|
|
602
727
|
async configureSessionMode(roomId, sessionId, modes, connection) {
|
|
@@ -675,7 +800,7 @@ ${messageWithContext}`;
|
|
|
675
800
|
return { ok: false };
|
|
676
801
|
}
|
|
677
802
|
try {
|
|
678
|
-
const restored = await restore();
|
|
803
|
+
const restored = await this.raceAgainstConnectionClose(connection, restore());
|
|
679
804
|
return { ok: true, modes: restored?.modes };
|
|
680
805
|
} catch {
|
|
681
806
|
return { ok: false };
|
|
@@ -788,6 +913,36 @@ ${messageWithContext}`;
|
|
|
788
913
|
"All Band MCP tool calls must include room_id."
|
|
789
914
|
].join("\n");
|
|
790
915
|
}
|
|
916
|
+
// The connection's single permission entry point, and total by
|
|
917
|
+
// construction: every path resolves, nothing throws, and a request that
|
|
918
|
+
// can't be attributed to a live room is cancelled *and* warned rather than
|
|
919
|
+
// silently declined. `activeSessions` is the gate that keeps a dead
|
|
920
|
+
// session from raising a live prompt — `roomToSession` deliberately
|
|
921
|
+
// outlives a dropped connection so the session can be restored later.
|
|
922
|
+
async routePermissionRequest(params) {
|
|
923
|
+
const isActive = this.activeSessions.has(params.sessionId);
|
|
924
|
+
const roomId = isActive ? this.sessionToRoom.get(params.sessionId) : void 0;
|
|
925
|
+
const tools = roomId === void 0 ? void 0 : this.roomTools.get(roomId);
|
|
926
|
+
if (roomId === void 0 || !tools) {
|
|
927
|
+
this.safeWarn("cancelling a permission request that maps to no live room", {
|
|
928
|
+
sessionId: params.sessionId,
|
|
929
|
+
toolName: params.toolCall?.title,
|
|
930
|
+
sessionActive: isActive,
|
|
931
|
+
roomId
|
|
932
|
+
});
|
|
933
|
+
return { outcome: { outcome: "cancelled" } };
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
return await this.handlePermissionRequest(tools, roomId, params);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
this.safeWarn("permission handling failed; cancelling the request", {
|
|
939
|
+
sessionId: params.sessionId,
|
|
940
|
+
roomId,
|
|
941
|
+
error: String(error)
|
|
942
|
+
});
|
|
943
|
+
return { outcome: { outcome: "cancelled" } };
|
|
944
|
+
}
|
|
945
|
+
}
|
|
791
946
|
async handlePermissionRequest(tools, roomId, params) {
|
|
792
947
|
const toolName = params.toolCall.title ?? "unknown";
|
|
793
948
|
const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
|
|
@@ -795,28 +950,52 @@ ${messageWithContext}`;
|
|
|
795
950
|
if (controller) {
|
|
796
951
|
this.trackPending(params.sessionId, controller);
|
|
797
952
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
// can take up to `permissionTimeoutMs`.
|
|
953
|
+
let requestEventFailed = false;
|
|
954
|
+
const [, resolvedChosenId] = await Promise.all([
|
|
955
|
+
// Started immediately rather than serialized in front of a manual
|
|
956
|
+
// wait that can take up to `permissionTimeoutMs`.
|
|
803
957
|
tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
|
|
804
958
|
permission_request: true,
|
|
805
959
|
tool_name: toolName,
|
|
806
960
|
tool_call_id: params.toolCall.toolCallId,
|
|
807
961
|
acp_session_id: params.sessionId,
|
|
808
962
|
auto_allowed: autoSelection !== void 0 && autoSelection !== null
|
|
963
|
+
}).catch((error) => {
|
|
964
|
+
requestEventFailed = true;
|
|
965
|
+
this.safeWarn("failed to post the permission-requested event; cancelling the request", {
|
|
966
|
+
roomId,
|
|
967
|
+
sessionId: params.sessionId,
|
|
968
|
+
error: String(error)
|
|
969
|
+
});
|
|
970
|
+
if (controller) {
|
|
971
|
+
this.abandon(controller, "no-answer");
|
|
972
|
+
}
|
|
809
973
|
}),
|
|
810
|
-
controller ? this.resolveManually(
|
|
974
|
+
controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
|
|
811
975
|
]);
|
|
812
|
-
|
|
976
|
+
const chosenId = requestEventFailed ? void 0 : resolvedChosenId;
|
|
977
|
+
const response = this.toResponse(chosenId, params.options, { roomId, sessionId: params.sessionId });
|
|
978
|
+
if (controller && !controller.signal.aborted) {
|
|
979
|
+
this.abandon(controller, response.outcome.outcome === "selected" ? "settled" : "no-answer");
|
|
980
|
+
}
|
|
981
|
+
return response;
|
|
813
982
|
}
|
|
814
983
|
// `undefined`, or an id absent from this request's own `options` (a buggy
|
|
815
984
|
// or stale caller), both map to `cancelled` — never silently treated as a
|
|
816
985
|
// deny. A real match, reject-kind options included, maps to `selected`.
|
|
817
|
-
toResponse(chosenId, options) {
|
|
818
|
-
|
|
819
|
-
|
|
986
|
+
toResponse(chosenId, options, context) {
|
|
987
|
+
if (chosenId === void 0) {
|
|
988
|
+
return { outcome: { outcome: "cancelled" } };
|
|
989
|
+
}
|
|
990
|
+
if (!options.some((option) => option.optionId === chosenId)) {
|
|
991
|
+
this.safeWarn("resolvePermission chose an option this request does not offer", {
|
|
992
|
+
...context,
|
|
993
|
+
chosenId,
|
|
994
|
+
optionIds: options.map((option) => option.optionId)
|
|
995
|
+
});
|
|
996
|
+
return { outcome: { outcome: "cancelled" } };
|
|
997
|
+
}
|
|
998
|
+
return { outcome: { outcome: "selected", optionId: chosenId } };
|
|
820
999
|
}
|
|
821
1000
|
// A caller-supplied `Logger` isn't guaranteed to be synchronous or
|
|
822
1001
|
// non-throwing. Every best-effort warning in this file routes through here
|
|
@@ -830,41 +1009,56 @@ ${messageWithContext}`;
|
|
|
830
1009
|
} catch {
|
|
831
1010
|
}
|
|
832
1011
|
}
|
|
833
|
-
// Races the caller-supplied resolver against
|
|
834
|
-
//
|
|
835
|
-
// `cancelPendingPermissions
|
|
836
|
-
// `
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
async resolveManually(
|
|
841
|
-
|
|
842
|
-
const cancelled = new Promise((resolve) => {
|
|
1012
|
+
// Races the caller-supplied resolver against `controller`'s abort signal,
|
|
1013
|
+
// which is the request's single termination channel: the timeout below
|
|
1014
|
+
// fires it with `"timeout"`, and `cancelPendingPermissions` /
|
|
1015
|
+
// `cancelAllPendingPermissions` fire it with the reason their caller
|
|
1016
|
+
// supplies. `controller` is the same object tracked in `pendingPermissions`,
|
|
1017
|
+
// so there is exactly one cancellation channel here, not a second
|
|
1018
|
+
// hand-rolled one alongside it.
|
|
1019
|
+
async resolveManually(roomId, params, controller) {
|
|
1020
|
+
const abandoned = new Promise((resolve) => {
|
|
843
1021
|
controller.signal.addEventListener("abort", () => resolve(void 0));
|
|
844
1022
|
});
|
|
1023
|
+
const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
|
|
845
1024
|
try {
|
|
846
|
-
const timeout = new Promise((resolve) => {
|
|
847
|
-
timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
|
|
848
|
-
});
|
|
849
1025
|
return await Promise.race([
|
|
850
1026
|
// `resolvePermission` is caller-supplied; nothing guarantees it's
|
|
851
1027
|
// `async` or otherwise well-behaved. `Promise.resolve().then(...)`
|
|
852
1028
|
// normalizes a synchronous throw the same way it normalizes a
|
|
853
1029
|
// rejected promise, so both land in the `.catch` below rather than
|
|
854
1030
|
// escaping this race uncaught.
|
|
855
|
-
Promise.resolve().then(() => this.resolvePermission(params, controller.signal)).catch((error) => {
|
|
1031
|
+
Promise.resolve().then(() => this.resolvePermission({ ...params, roomId }, controller.signal)).then((chosenId) => this.discardLateAnswer(chosenId, controller, roomId, params.sessionId)).catch((error) => {
|
|
856
1032
|
this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
|
|
857
1033
|
return void 0;
|
|
858
1034
|
}),
|
|
859
|
-
|
|
860
|
-
cancelled
|
|
1035
|
+
abandoned
|
|
861
1036
|
]);
|
|
862
1037
|
} finally {
|
|
863
1038
|
clearTimeout(timer);
|
|
864
|
-
this.untrackPending(sessionId, controller);
|
|
865
|
-
controller.abort();
|
|
1039
|
+
this.untrackPending(params.sessionId, controller);
|
|
866
1040
|
}
|
|
867
1041
|
}
|
|
1042
|
+
// An answer that lands after the request was given up on can no longer be
|
|
1043
|
+
// honoured — the response has already gone back to the agent. It is dropped
|
|
1044
|
+
// either way; warning is what makes "my click did nothing" explicable.
|
|
1045
|
+
discardLateAnswer(chosenId, controller, roomId, sessionId) {
|
|
1046
|
+
if (!controller.signal.aborted || chosenId === void 0) {
|
|
1047
|
+
return chosenId;
|
|
1048
|
+
}
|
|
1049
|
+
this.safeWarn("resolvePermission answered after the request was abandoned; discarding", {
|
|
1050
|
+
roomId,
|
|
1051
|
+
sessionId,
|
|
1052
|
+
chosenId,
|
|
1053
|
+
reason: String(controller.signal.reason)
|
|
1054
|
+
});
|
|
1055
|
+
return void 0;
|
|
1056
|
+
}
|
|
1057
|
+
// The only place a permission's controller is ever aborted, so every
|
|
1058
|
+
// `signal.reason` a consumer can observe comes from the documented union.
|
|
1059
|
+
abandon(controller, reason) {
|
|
1060
|
+
controller.abort(reason);
|
|
1061
|
+
}
|
|
868
1062
|
trackPending(sessionId, controller) {
|
|
869
1063
|
const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
870
1064
|
pending.add(controller);
|
|
@@ -877,14 +1071,14 @@ ${messageWithContext}`;
|
|
|
877
1071
|
this.pendingPermissions.delete(sessionId);
|
|
878
1072
|
}
|
|
879
1073
|
}
|
|
880
|
-
cancelPendingPermissions(sessionId) {
|
|
1074
|
+
cancelPendingPermissions(sessionId, reason) {
|
|
881
1075
|
for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
|
|
882
|
-
|
|
1076
|
+
this.abandon(controller, reason);
|
|
883
1077
|
}
|
|
884
1078
|
}
|
|
885
|
-
cancelAllPendingPermissions() {
|
|
1079
|
+
cancelAllPendingPermissions(reason) {
|
|
886
1080
|
for (const sessionId of this.pendingPermissions.keys()) {
|
|
887
|
-
this.cancelPendingPermissions(sessionId);
|
|
1081
|
+
this.cancelPendingPermissions(sessionId, reason);
|
|
888
1082
|
}
|
|
889
1083
|
}
|
|
890
1084
|
async flushChunks(input) {
|
|
@@ -207,6 +207,7 @@ async function readResponseBody(response) {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
// src/platform/streaming/PhoenixChannelsTransport.ts
|
|
210
|
+
import { agentControlTopic } from "@band-ai/band-sdk-core";
|
|
210
211
|
var PhoenixChannelsTransport = class {
|
|
211
212
|
socket;
|
|
212
213
|
agentId;
|
|
@@ -473,7 +474,7 @@ var PhoenixChannelsTransport = class {
|
|
|
473
474
|
if (!this.agentId) {
|
|
474
475
|
return;
|
|
475
476
|
}
|
|
476
|
-
await this.join(
|
|
477
|
+
await this.join(agentControlTopic(this.agentId), {
|
|
477
478
|
supersede: (payload) => {
|
|
478
479
|
const reason = parseSupersedeDisconnectReason(payload);
|
|
479
480
|
if (!reason) {
|
|
@@ -574,6 +575,12 @@ function getSocketChannelCount(socket) {
|
|
|
574
575
|
|
|
575
576
|
// src/platform/BandLink.ts
|
|
576
577
|
import { BandClient } from "@band-ai/rest-client";
|
|
578
|
+
import {
|
|
579
|
+
agentContactsTopic,
|
|
580
|
+
agentRoomsTopic,
|
|
581
|
+
chatRoomTopic,
|
|
582
|
+
roomParticipantsTopic
|
|
583
|
+
} from "@band-ai/band-sdk-core";
|
|
577
584
|
var DEFAULT_WS_URL = "wss://app.band.ai/api/v1/socket";
|
|
578
585
|
function deriveDefaultRestUrl(wsUrl) {
|
|
579
586
|
const parsed = new URL(wsUrl);
|
|
@@ -582,8 +589,8 @@ function deriveDefaultRestUrl(wsUrl) {
|
|
|
582
589
|
}
|
|
583
590
|
function roomTopics(roomId) {
|
|
584
591
|
return {
|
|
585
|
-
chat:
|
|
586
|
-
participants:
|
|
592
|
+
chat: chatRoomTopic(roomId),
|
|
593
|
+
participants: roomParticipantsTopic(roomId)
|
|
587
594
|
};
|
|
588
595
|
}
|
|
589
596
|
function toPlatformMessage(roomId, message) {
|
|
@@ -703,7 +710,7 @@ var BandLink = class {
|
|
|
703
710
|
this.eventQueue.push(event);
|
|
704
711
|
}
|
|
705
712
|
async subscribeAgentRooms() {
|
|
706
|
-
await this.transport.join(
|
|
713
|
+
await this.transport.join(agentRoomsTopic(this.agentId), {
|
|
707
714
|
room_added: (payload) => {
|
|
708
715
|
const roomId = typeof payload.id === "string" ? payload.id : "";
|
|
709
716
|
this.emit("room_added", payload, roomId);
|
|
@@ -756,7 +763,7 @@ var BandLink = class {
|
|
|
756
763
|
}
|
|
757
764
|
async subscribeAgentContacts() {
|
|
758
765
|
assertCapability(this.capabilities, "contacts", "Contacts streaming");
|
|
759
|
-
await this.transport.join(
|
|
766
|
+
await this.transport.join(agentContactsTopic(this.agentId), {
|
|
760
767
|
contact_request_received: (payload) => {
|
|
761
768
|
this.emit("contact_request_received", payload, null);
|
|
762
769
|
},
|
|
@@ -772,7 +779,7 @@ var BandLink = class {
|
|
|
772
779
|
});
|
|
773
780
|
}
|
|
774
781
|
async unsubscribeAgentContacts() {
|
|
775
|
-
await this.transport.leave(
|
|
782
|
+
await this.transport.leave(agentContactsTopic(this.agentId));
|
|
776
783
|
}
|
|
777
784
|
async nextEvent(signal) {
|
|
778
785
|
if (this.terminalDisconnectError) {
|
|
@@ -3,6 +3,9 @@ var ACPClientHistoryConverter = class {
|
|
|
3
3
|
convert(raw) {
|
|
4
4
|
const roomToSession = {};
|
|
5
5
|
for (const entry of raw) {
|
|
6
|
+
if (entry.message_type !== "task") {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
6
9
|
const metadataRaw = entry.metadata;
|
|
7
10
|
if (!metadataRaw || typeof metadataRaw !== "object" || Array.isArray(metadataRaw)) {
|
|
8
11
|
continue;
|
|
@@ -10,7 +13,7 @@ var ACPClientHistoryConverter = class {
|
|
|
10
13
|
const metadata = metadataRaw;
|
|
11
14
|
const sessionId = metadata.acp_client_session_id;
|
|
12
15
|
const roomId = metadata.acp_client_room_id;
|
|
13
|
-
if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId) {
|
|
16
|
+
if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId && roomId === entry.room_id) {
|
|
14
17
|
roomToSession[roomId] = sessionId;
|
|
15
18
|
}
|
|
16
19
|
}
|