@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.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,26 +175,12 @@ 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
|
-
};
|
|
181
|
-
}
|
|
182
|
-
setPermissionHandler(sessionId, handler) {
|
|
183
|
-
if (!handler) {
|
|
184
|
-
this.permissionHandlers.delete(sessionId);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
this.permissionHandlers.set(sessionId, handler);
|
|
178
|
+
return this.permissionHandler(params);
|
|
188
179
|
}
|
|
189
|
-
|
|
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("");
|
|
@@ -382,13 +374,21 @@ var ACPClientAdapter = class extends SimpleAdapter {
|
|
|
382
374
|
clientCapabilities;
|
|
383
375
|
connectionFactory;
|
|
384
376
|
roomToSession = /* @__PURE__ */ new Map();
|
|
377
|
+
sessionToRoom = /* @__PURE__ */ new Map();
|
|
385
378
|
roomTools = /* @__PURE__ */ new Map();
|
|
386
379
|
activeSessions = /* @__PURE__ */ new Set();
|
|
387
380
|
bootstrappedSessions = /* @__PURE__ */ new Set();
|
|
388
381
|
pendingPermissions = /* @__PURE__ */ new Map();
|
|
382
|
+
sessionsInFlight = /* @__PURE__ */ new Map();
|
|
383
|
+
roomTurnLocks = /* @__PURE__ */ new Map();
|
|
384
|
+
// Bumped each time a room starts a *new* establishment (never on a
|
|
385
|
+
// coalesced reuse) and whenever a room is torn down. An establishment
|
|
386
|
+
// captures its own value at the start; if the room has moved on by the
|
|
387
|
+
// time it would link/activate a session, it was superseded and must not.
|
|
388
|
+
roomGeneration = /* @__PURE__ */ new Map();
|
|
389
389
|
resolvePermission;
|
|
390
|
+
resolveSessionMode;
|
|
390
391
|
permissionTimeoutMs;
|
|
391
|
-
requestedPermissionMode;
|
|
392
392
|
logger;
|
|
393
393
|
backend = null;
|
|
394
394
|
backendPromise = null;
|
|
@@ -417,13 +417,10 @@ var ACPClientAdapter = class extends SimpleAdapter {
|
|
|
417
417
|
this.clientCapabilities = options.clientCapabilities;
|
|
418
418
|
this.connectionFactory = options.connectionFactory ?? createSubprocessConnection;
|
|
419
419
|
this.resolvePermission = options.resolvePermission;
|
|
420
|
+
this.resolveSessionMode = options.resolveSessionMode;
|
|
420
421
|
this.logger = options.logger ?? new NoopLogger();
|
|
421
422
|
this.permissionTimeoutMs = options.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS;
|
|
422
|
-
this.
|
|
423
|
-
if (this.requestedPermissionMode !== void 0 && this.requestedPermissionMode.length === 0) {
|
|
424
|
-
throw new ValidationError("requestedPermissionMode must be a non-empty mode id, got an empty string");
|
|
425
|
-
}
|
|
426
|
-
if (this.resolvePermission && (!Number.isFinite(this.permissionTimeoutMs) || this.permissionTimeoutMs <= 0)) {
|
|
423
|
+
if ((this.resolvePermission || this.resolveSessionMode) && (!Number.isFinite(this.permissionTimeoutMs) || this.permissionTimeoutMs <= 0)) {
|
|
427
424
|
throw new ValidationError(`permissionTimeoutMs must be a positive finite number, got ${options.permissionTimeoutMs}`);
|
|
428
425
|
}
|
|
429
426
|
}
|
|
@@ -442,17 +439,16 @@ var ACPClientAdapter = class extends SimpleAdapter {
|
|
|
442
439
|
this.rehydrate(history);
|
|
443
440
|
}
|
|
444
441
|
this.roomTools.set(context.roomId, tools);
|
|
442
|
+
await this.withRoomTurnLock(context.roomId, () => this.runTurn(message, tools, participantsMessage, contactsMessage, context));
|
|
443
|
+
}
|
|
444
|
+
async runTurn(message, tools, participantsMessage, contactsMessage, context) {
|
|
445
445
|
const connection = await this.ensureConnection();
|
|
446
446
|
const client = this.client;
|
|
447
447
|
if (!client) {
|
|
448
448
|
throw new Error("ACP client was not initialized");
|
|
449
449
|
}
|
|
450
450
|
const sessionId = await this.getOrCreateSession(context.roomId, connection);
|
|
451
|
-
client.
|
|
452
|
-
client.setPermissionHandler(
|
|
453
|
-
sessionId,
|
|
454
|
-
(params) => this.handlePermissionRequest(tools, context.roomId, params)
|
|
455
|
-
);
|
|
451
|
+
client.resetChunks(sessionId);
|
|
456
452
|
const content = replaceUuidMentions(message.content, mentionSubjectsFromMetadata(message.metadata));
|
|
457
453
|
const messageWithContext = [...systemUpdateParts(participantsMessage, contactsMessage), content].join("\n\n");
|
|
458
454
|
const promptText = this.bootstrappedSessions.has(sessionId) ? messageWithContext : `${this.buildSystemContext(context.roomId, message)}
|
|
@@ -485,15 +481,28 @@ ${messageWithContext}`;
|
|
|
485
481
|
acp_client_room_id: context.roomId
|
|
486
482
|
});
|
|
487
483
|
}
|
|
484
|
+
// A per-room async mutex: `fn` for a given `roomId` never overlaps another
|
|
485
|
+
// call for that same room, while different rooms stay fully concurrent.
|
|
486
|
+
// The tracked tail (`this.roomTurnLocks`) always settles — via the
|
|
487
|
+
// trailing `.catch` — so one turn's failure can't wedge every later turn
|
|
488
|
+
// for the room; the real result/rejection is still `run`, returned to this
|
|
489
|
+
// call's own caller.
|
|
490
|
+
async withRoomTurnLock(roomId, fn) {
|
|
491
|
+
const previous = this.roomTurnLocks.get(roomId) ?? Promise.resolve();
|
|
492
|
+
const run = previous.then(fn, fn);
|
|
493
|
+
this.roomTurnLocks.set(roomId, run.catch(() => void 0));
|
|
494
|
+
return run;
|
|
495
|
+
}
|
|
488
496
|
async onCleanup(roomId) {
|
|
489
|
-
const sessionId = this.
|
|
490
|
-
this.roomToSession.delete(roomId);
|
|
497
|
+
const sessionId = this.unlinkRoom(roomId);
|
|
491
498
|
this.roomTools.delete(roomId);
|
|
499
|
+
this.sessionsInFlight.delete(roomId);
|
|
500
|
+
this.roomTurnLocks.delete(roomId);
|
|
501
|
+
this.nextRoomGeneration(roomId);
|
|
492
502
|
if (sessionId) {
|
|
493
503
|
this.activeSessions.delete(sessionId);
|
|
494
504
|
this.bootstrappedSessions.delete(sessionId);
|
|
495
|
-
this.
|
|
496
|
-
this.cancelPendingPermissions(sessionId);
|
|
505
|
+
this.cancelPendingPermissions(sessionId, "room-closed");
|
|
497
506
|
}
|
|
498
507
|
}
|
|
499
508
|
async onRuntimeStop() {
|
|
@@ -505,8 +514,14 @@ ${messageWithContext}`;
|
|
|
505
514
|
this.activeSessions.clear();
|
|
506
515
|
this.bootstrappedSessions.clear();
|
|
507
516
|
this.roomToSession.clear();
|
|
517
|
+
this.sessionToRoom.clear();
|
|
508
518
|
this.roomTools.clear();
|
|
509
|
-
this.
|
|
519
|
+
this.sessionsInFlight.clear();
|
|
520
|
+
this.roomTurnLocks.clear();
|
|
521
|
+
for (const roomId of this.roomGeneration.keys()) {
|
|
522
|
+
this.nextRoomGeneration(roomId);
|
|
523
|
+
}
|
|
524
|
+
this.cancelAllPendingPermissions("adapter-stopped");
|
|
510
525
|
this.client = null;
|
|
511
526
|
this.connection = null;
|
|
512
527
|
if (this.backend) {
|
|
@@ -523,10 +538,66 @@ ${messageWithContext}`;
|
|
|
523
538
|
rehydrate(history) {
|
|
524
539
|
for (const [roomId, sessionId] of Object.entries(history.roomToSession)) {
|
|
525
540
|
if (!this.roomToSession.has(roomId)) {
|
|
526
|
-
this.
|
|
541
|
+
this.linkSession(roomId, sessionId);
|
|
527
542
|
}
|
|
528
543
|
}
|
|
529
544
|
}
|
|
545
|
+
// The only writer of both session maps, so they cannot drift: replacing a
|
|
546
|
+
// room's session drops the old session's route, and a session id already
|
|
547
|
+
// routed to another room is refused rather than silently re-pointed — two
|
|
548
|
+
// rooms sharing one session id would make its permission requests
|
|
549
|
+
// unattributable. Returns whether the link was made — a caller that goes
|
|
550
|
+
// on to activate/configure/prompt a session regardless of a `false` here
|
|
551
|
+
// would use a session this room was refused, not just fail to route its
|
|
552
|
+
// permissions.
|
|
553
|
+
linkSession(roomId, sessionId) {
|
|
554
|
+
const routedRoomId = this.sessionToRoom.get(sessionId);
|
|
555
|
+
if (routedRoomId !== void 0 && routedRoomId !== roomId) {
|
|
556
|
+
this.safeWarn("refusing to route one ACP session to a second room", {
|
|
557
|
+
sessionId,
|
|
558
|
+
roomId,
|
|
559
|
+
routedRoomId
|
|
560
|
+
});
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
const replacedSessionId = this.roomToSession.get(roomId);
|
|
564
|
+
if (replacedSessionId !== void 0 && replacedSessionId !== sessionId) {
|
|
565
|
+
this.sessionToRoom.delete(replacedSessionId);
|
|
566
|
+
}
|
|
567
|
+
this.roomToSession.set(roomId, sessionId);
|
|
568
|
+
this.sessionToRoom.set(sessionId, roomId);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
nextRoomGeneration(roomId) {
|
|
572
|
+
const next = (this.roomGeneration.get(roomId) ?? 0) + 1;
|
|
573
|
+
this.roomGeneration.set(roomId, next);
|
|
574
|
+
return next;
|
|
575
|
+
}
|
|
576
|
+
isCurrentGeneration(roomId, generation) {
|
|
577
|
+
return this.roomGeneration.get(roomId) === generation;
|
|
578
|
+
}
|
|
579
|
+
// The installed ACP SDK's `sendRequest` never rejects a pending call when
|
|
580
|
+
// its connection closes (no server response ever arrives to reject it
|
|
581
|
+
// with) — so a session-establishment RPC in flight when the subprocess
|
|
582
|
+
// dies would otherwise hang forever, wedging the room's `sessionsInFlight`
|
|
583
|
+
// entry along with it. Racing every such RPC against the connection's own
|
|
584
|
+
// `closed` promise gives it a real, prompt failure instead.
|
|
585
|
+
raceAgainstConnectionClose(connection, operation) {
|
|
586
|
+
let reject = () => void 0;
|
|
587
|
+
const closedRejection = new Promise((_resolve, rejectFn) => {
|
|
588
|
+
reject = rejectFn;
|
|
589
|
+
});
|
|
590
|
+
void connection.closed.then(() => reject(new Error("ACP connection closed while a session operation was still in flight")));
|
|
591
|
+
return Promise.race([operation, closedRejection]);
|
|
592
|
+
}
|
|
593
|
+
unlinkRoom(roomId) {
|
|
594
|
+
const sessionId = this.roomToSession.get(roomId);
|
|
595
|
+
this.roomToSession.delete(roomId);
|
|
596
|
+
if (sessionId !== void 0) {
|
|
597
|
+
this.sessionToRoom.delete(sessionId);
|
|
598
|
+
}
|
|
599
|
+
return sessionId;
|
|
600
|
+
}
|
|
530
601
|
async ensureConnection() {
|
|
531
602
|
if (this.connection && !this.connection.signal.aborted) {
|
|
532
603
|
return this.connection;
|
|
@@ -546,7 +617,7 @@ ${messageWithContext}`;
|
|
|
546
617
|
}
|
|
547
618
|
async spawnConnection() {
|
|
548
619
|
const acp = await acpModule.get();
|
|
549
|
-
const client = new BandACPClient();
|
|
620
|
+
const client = new BandACPClient((params) => this.routePermissionRequest(params));
|
|
550
621
|
const handle = await this.connectionFactory(client, {
|
|
551
622
|
command: this.command,
|
|
552
623
|
cwd: this.cwd,
|
|
@@ -572,6 +643,7 @@ ${messageWithContext}`;
|
|
|
572
643
|
this.connectionHandle = null;
|
|
573
644
|
this.connectionState = null;
|
|
574
645
|
this.activeSessions.clear();
|
|
646
|
+
this.cancelAllPendingPermissions("connection-lost");
|
|
575
647
|
}
|
|
576
648
|
});
|
|
577
649
|
return connection;
|
|
@@ -581,69 +653,140 @@ ${messageWithContext}`;
|
|
|
581
653
|
if (existingSessionId && this.activeSessions.has(existingSessionId)) {
|
|
582
654
|
return existingSessionId;
|
|
583
655
|
}
|
|
656
|
+
const inFlight = this.sessionsInFlight.get(roomId);
|
|
657
|
+
if (inFlight) {
|
|
658
|
+
return inFlight;
|
|
659
|
+
}
|
|
660
|
+
const generation = this.nextRoomGeneration(roomId);
|
|
661
|
+
const establishing = this.establishSession(roomId, existingSessionId, connection, generation);
|
|
662
|
+
establishing.finally(() => {
|
|
663
|
+
if (this.sessionsInFlight.get(roomId) === establishing) {
|
|
664
|
+
this.sessionsInFlight.delete(roomId);
|
|
665
|
+
}
|
|
666
|
+
}).catch(() => void 0);
|
|
667
|
+
this.sessionsInFlight.set(roomId, establishing);
|
|
668
|
+
return establishing;
|
|
669
|
+
}
|
|
670
|
+
async establishSession(roomId, existingSessionId, connection, generation) {
|
|
584
671
|
const mcpServers = await this.buildSessionMcpServers();
|
|
585
672
|
if (existingSessionId) {
|
|
586
673
|
const restored = await this.tryRestoreSession(connection, existingSessionId, mcpServers);
|
|
587
674
|
if (restored.ok) {
|
|
675
|
+
this.linkOrAbandon(roomId, existingSessionId, generation);
|
|
588
676
|
this.activeSessions.add(existingSessionId);
|
|
589
677
|
this.bootstrappedSessions.add(existingSessionId);
|
|
590
|
-
await this.
|
|
678
|
+
await this.configureSessionMode(roomId, existingSessionId, restored.modes, connection);
|
|
591
679
|
return existingSessionId;
|
|
592
680
|
}
|
|
593
681
|
}
|
|
594
|
-
const created = await connection.newSession({
|
|
682
|
+
const created = await this.raceAgainstConnectionClose(connection, connection.newSession({
|
|
595
683
|
cwd: this.cwd,
|
|
596
684
|
mcpServers
|
|
597
|
-
});
|
|
598
|
-
this.
|
|
685
|
+
}));
|
|
686
|
+
this.linkOrAbandon(roomId, created.sessionId, generation);
|
|
599
687
|
this.activeSessions.add(created.sessionId);
|
|
600
|
-
await this.
|
|
688
|
+
await this.configureSessionMode(roomId, created.sessionId, created.modes, connection);
|
|
601
689
|
return created.sessionId;
|
|
602
690
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
691
|
+
// The single gate an establishment must pass before it's allowed to claim
|
|
692
|
+
// the room: it must still be the room's current generation (not
|
|
693
|
+
// superseded by a teardown or a fresher establishment while this one was
|
|
694
|
+
// awaiting an RPC), and its session id must not already belong to another
|
|
695
|
+
// room. Either failure throws — this establishment cannot silently
|
|
696
|
+
// continue to activate, configure, and prompt a session it has no right
|
|
697
|
+
// to use for this room.
|
|
698
|
+
linkOrAbandon(roomId, sessionId, generation) {
|
|
699
|
+
if (!this.isCurrentGeneration(roomId, generation)) {
|
|
700
|
+
throw new Error(`ACP session establishment for room "${roomId}" was superseded before it could be linked`);
|
|
609
701
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
return { ok: true, modes: restored?.modes };
|
|
613
|
-
} catch {
|
|
614
|
-
return { ok: false };
|
|
702
|
+
if (!this.linkSession(roomId, sessionId)) {
|
|
703
|
+
throw new Error(`ACP session "${sessionId}" could not be linked to room "${roomId}": already routed elsewhere`);
|
|
615
704
|
}
|
|
616
705
|
}
|
|
617
706
|
// Best-effort: never throws, so a mode switch going wrong can't take a
|
|
618
707
|
// session establishment down with it.
|
|
619
|
-
async
|
|
620
|
-
|
|
621
|
-
if (!requestedModeId || !modes || modes.currentModeId === requestedModeId) {
|
|
708
|
+
async configureSessionMode(roomId, sessionId, modes, connection) {
|
|
709
|
+
if (!this.resolveSessionMode || !modes) {
|
|
622
710
|
return;
|
|
623
711
|
}
|
|
624
712
|
const availableModes = Array.isArray(modes.availableModes) ? modes.availableModes : [];
|
|
625
|
-
if (
|
|
626
|
-
|
|
713
|
+
if (availableModes.length === 0) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const selectedModeId = await this.resolveSessionModeManually(
|
|
717
|
+
(signal) => this.resolveSessionMode({
|
|
718
|
+
roomId,
|
|
627
719
|
sessionId,
|
|
628
|
-
|
|
720
|
+
currentModeId: modes.currentModeId,
|
|
721
|
+
modes: availableModes
|
|
722
|
+
}, signal),
|
|
723
|
+
connection.signal
|
|
724
|
+
);
|
|
725
|
+
if (!selectedModeId || selectedModeId === modes.currentModeId) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (!availableModes.some((mode) => mode?.id === selectedModeId)) {
|
|
729
|
+
this.safeWarn("resolveSessionMode selected a mode id this session does not advertise", {
|
|
730
|
+
sessionId,
|
|
731
|
+
selectedModeId,
|
|
629
732
|
availableModeIds: availableModes.map((mode) => mode?.id)
|
|
630
733
|
});
|
|
631
734
|
return;
|
|
632
735
|
}
|
|
633
736
|
try {
|
|
634
737
|
await withTimeout(
|
|
635
|
-
connection.setSessionMode({ sessionId, modeId:
|
|
738
|
+
connection.setSessionMode({ sessionId, modeId: selectedModeId }),
|
|
636
739
|
SET_SESSION_MODE_TIMEOUT_MS,
|
|
637
740
|
`setSessionMode did not respond within ${SET_SESSION_MODE_TIMEOUT_MS}ms`
|
|
638
741
|
);
|
|
639
742
|
} catch (error) {
|
|
640
|
-
this.safeWarn("failed to switch session into the
|
|
743
|
+
this.safeWarn("failed to switch session into the selected mode", {
|
|
641
744
|
sessionId,
|
|
642
|
-
|
|
745
|
+
selectedModeId,
|
|
643
746
|
error: String(error)
|
|
644
747
|
});
|
|
645
748
|
}
|
|
646
749
|
}
|
|
750
|
+
async resolveSessionModeManually(resolver, signal) {
|
|
751
|
+
const controller = new AbortController();
|
|
752
|
+
const abort = () => controller.abort();
|
|
753
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
754
|
+
let timer;
|
|
755
|
+
try {
|
|
756
|
+
const cancelled = new Promise((resolve) => {
|
|
757
|
+
controller.signal.addEventListener("abort", () => resolve(void 0), { once: true });
|
|
758
|
+
});
|
|
759
|
+
const timeout = new Promise((resolve) => {
|
|
760
|
+
timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
|
|
761
|
+
});
|
|
762
|
+
return await Promise.race([
|
|
763
|
+
Promise.resolve().then(() => resolver(controller.signal)).catch((error) => {
|
|
764
|
+
this.safeWarn("resolveSessionMode threw; preserving the harness default", { error: String(error) });
|
|
765
|
+
return void 0;
|
|
766
|
+
}),
|
|
767
|
+
timeout,
|
|
768
|
+
cancelled
|
|
769
|
+
]);
|
|
770
|
+
} finally {
|
|
771
|
+
clearTimeout(timer);
|
|
772
|
+
signal.removeEventListener("abort", abort);
|
|
773
|
+
controller.abort();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async tryRestoreSession(connection, sessionId, mcpServers) {
|
|
777
|
+
const capabilities = this.connectionState?.agentCapabilities;
|
|
778
|
+
const params = { cwd: this.cwd, mcpServers, sessionId };
|
|
779
|
+
const restore = capabilities?.loadSession ? () => connection.loadSession(params) : capabilities?.sessionCapabilities?.resume ? () => connection.unstable_resumeSession(params) : null;
|
|
780
|
+
if (!restore) {
|
|
781
|
+
return { ok: false };
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const restored = await this.raceAgainstConnectionClose(connection, restore());
|
|
785
|
+
return { ok: true, modes: restored?.modes };
|
|
786
|
+
} catch {
|
|
787
|
+
return { ok: false };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
647
790
|
async buildSessionMcpServers() {
|
|
648
791
|
const mcpServers = [...this.mcpServers];
|
|
649
792
|
if (!this.enableMcpTools) {
|
|
@@ -751,6 +894,36 @@ ${messageWithContext}`;
|
|
|
751
894
|
"All Band MCP tool calls must include room_id."
|
|
752
895
|
].join("\n");
|
|
753
896
|
}
|
|
897
|
+
// The connection's single permission entry point, and total by
|
|
898
|
+
// construction: every path resolves, nothing throws, and a request that
|
|
899
|
+
// can't be attributed to a live room is cancelled *and* warned rather than
|
|
900
|
+
// silently declined. `activeSessions` is the gate that keeps a dead
|
|
901
|
+
// session from raising a live prompt — `roomToSession` deliberately
|
|
902
|
+
// outlives a dropped connection so the session can be restored later.
|
|
903
|
+
async routePermissionRequest(params) {
|
|
904
|
+
const isActive = this.activeSessions.has(params.sessionId);
|
|
905
|
+
const roomId = isActive ? this.sessionToRoom.get(params.sessionId) : void 0;
|
|
906
|
+
const tools = roomId === void 0 ? void 0 : this.roomTools.get(roomId);
|
|
907
|
+
if (roomId === void 0 || !tools) {
|
|
908
|
+
this.safeWarn("cancelling a permission request that maps to no live room", {
|
|
909
|
+
sessionId: params.sessionId,
|
|
910
|
+
toolName: params.toolCall?.title,
|
|
911
|
+
sessionActive: isActive,
|
|
912
|
+
roomId
|
|
913
|
+
});
|
|
914
|
+
return { outcome: { outcome: "cancelled" } };
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
return await this.handlePermissionRequest(tools, roomId, params);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
this.safeWarn("permission handling failed; cancelling the request", {
|
|
920
|
+
sessionId: params.sessionId,
|
|
921
|
+
roomId,
|
|
922
|
+
error: String(error)
|
|
923
|
+
});
|
|
924
|
+
return { outcome: { outcome: "cancelled" } };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
754
927
|
async handlePermissionRequest(tools, roomId, params) {
|
|
755
928
|
const toolName = params.toolCall.title ?? "unknown";
|
|
756
929
|
const autoSelection = this.resolvePermission ? void 0 : choosePermissionOption(params.options);
|
|
@@ -758,28 +931,52 @@ ${messageWithContext}`;
|
|
|
758
931
|
if (controller) {
|
|
759
932
|
this.trackPending(params.sessionId, controller);
|
|
760
933
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
// can take up to `permissionTimeoutMs`.
|
|
934
|
+
let requestEventFailed = false;
|
|
935
|
+
const [, resolvedChosenId] = await Promise.all([
|
|
936
|
+
// Started immediately rather than serialized in front of a manual
|
|
937
|
+
// wait that can take up to `permissionTimeoutMs`.
|
|
766
938
|
tools.sendEvent(`Permission requested: ${toolName}`, "tool_call", {
|
|
767
939
|
permission_request: true,
|
|
768
940
|
tool_name: toolName,
|
|
769
941
|
tool_call_id: params.toolCall.toolCallId,
|
|
770
942
|
acp_session_id: params.sessionId,
|
|
771
943
|
auto_allowed: autoSelection !== void 0 && autoSelection !== null
|
|
944
|
+
}).catch((error) => {
|
|
945
|
+
requestEventFailed = true;
|
|
946
|
+
this.safeWarn("failed to post the permission-requested event; cancelling the request", {
|
|
947
|
+
roomId,
|
|
948
|
+
sessionId: params.sessionId,
|
|
949
|
+
error: String(error)
|
|
950
|
+
});
|
|
951
|
+
if (controller) {
|
|
952
|
+
this.abandon(controller, "no-answer");
|
|
953
|
+
}
|
|
772
954
|
}),
|
|
773
|
-
controller ? this.resolveManually(
|
|
955
|
+
controller ? this.resolveManually(roomId, params, controller) : Promise.resolve(autoSelection?.optionId)
|
|
774
956
|
]);
|
|
775
|
-
|
|
957
|
+
const chosenId = requestEventFailed ? void 0 : resolvedChosenId;
|
|
958
|
+
const response = this.toResponse(chosenId, params.options, { roomId, sessionId: params.sessionId });
|
|
959
|
+
if (controller && !controller.signal.aborted) {
|
|
960
|
+
this.abandon(controller, response.outcome.outcome === "selected" ? "settled" : "no-answer");
|
|
961
|
+
}
|
|
962
|
+
return response;
|
|
776
963
|
}
|
|
777
964
|
// `undefined`, or an id absent from this request's own `options` (a buggy
|
|
778
965
|
// or stale caller), both map to `cancelled` — never silently treated as a
|
|
779
966
|
// deny. A real match, reject-kind options included, maps to `selected`.
|
|
780
|
-
toResponse(chosenId, options) {
|
|
781
|
-
|
|
782
|
-
|
|
967
|
+
toResponse(chosenId, options, context) {
|
|
968
|
+
if (chosenId === void 0) {
|
|
969
|
+
return { outcome: { outcome: "cancelled" } };
|
|
970
|
+
}
|
|
971
|
+
if (!options.some((option) => option.optionId === chosenId)) {
|
|
972
|
+
this.safeWarn("resolvePermission chose an option this request does not offer", {
|
|
973
|
+
...context,
|
|
974
|
+
chosenId,
|
|
975
|
+
optionIds: options.map((option) => option.optionId)
|
|
976
|
+
});
|
|
977
|
+
return { outcome: { outcome: "cancelled" } };
|
|
978
|
+
}
|
|
979
|
+
return { outcome: { outcome: "selected", optionId: chosenId } };
|
|
783
980
|
}
|
|
784
981
|
// A caller-supplied `Logger` isn't guaranteed to be synchronous or
|
|
785
982
|
// non-throwing. Every best-effort warning in this file routes through here
|
|
@@ -793,41 +990,56 @@ ${messageWithContext}`;
|
|
|
793
990
|
} catch {
|
|
794
991
|
}
|
|
795
992
|
}
|
|
796
|
-
// Races the caller-supplied resolver against
|
|
797
|
-
//
|
|
798
|
-
// `cancelPendingPermissions
|
|
799
|
-
// `
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
async resolveManually(
|
|
804
|
-
|
|
805
|
-
const cancelled = new Promise((resolve) => {
|
|
993
|
+
// Races the caller-supplied resolver against `controller`'s abort signal,
|
|
994
|
+
// which is the request's single termination channel: the timeout below
|
|
995
|
+
// fires it with `"timeout"`, and `cancelPendingPermissions` /
|
|
996
|
+
// `cancelAllPendingPermissions` fire it with the reason their caller
|
|
997
|
+
// supplies. `controller` is the same object tracked in `pendingPermissions`,
|
|
998
|
+
// so there is exactly one cancellation channel here, not a second
|
|
999
|
+
// hand-rolled one alongside it.
|
|
1000
|
+
async resolveManually(roomId, params, controller) {
|
|
1001
|
+
const abandoned = new Promise((resolve) => {
|
|
806
1002
|
controller.signal.addEventListener("abort", () => resolve(void 0));
|
|
807
1003
|
});
|
|
1004
|
+
const timer = setTimeout(() => this.abandon(controller, "timeout"), this.permissionTimeoutMs);
|
|
808
1005
|
try {
|
|
809
|
-
const timeout = new Promise((resolve) => {
|
|
810
|
-
timer = setTimeout(() => resolve(void 0), this.permissionTimeoutMs);
|
|
811
|
-
});
|
|
812
1006
|
return await Promise.race([
|
|
813
1007
|
// `resolvePermission` is caller-supplied; nothing guarantees it's
|
|
814
1008
|
// `async` or otherwise well-behaved. `Promise.resolve().then(...)`
|
|
815
1009
|
// normalizes a synchronous throw the same way it normalizes a
|
|
816
1010
|
// rejected promise, so both land in the `.catch` below rather than
|
|
817
1011
|
// escaping this race uncaught.
|
|
818
|
-
Promise.resolve().then(() => this.resolvePermission(params, controller.signal)).catch((error) => {
|
|
1012
|
+
Promise.resolve().then(() => this.resolvePermission({ ...params, roomId }, controller.signal)).then((chosenId) => this.discardLateAnswer(chosenId, controller, roomId, params.sessionId)).catch((error) => {
|
|
819
1013
|
this.safeWarn("resolvePermission threw; treating as no answer", { error: String(error) });
|
|
820
1014
|
return void 0;
|
|
821
1015
|
}),
|
|
822
|
-
|
|
823
|
-
cancelled
|
|
1016
|
+
abandoned
|
|
824
1017
|
]);
|
|
825
1018
|
} finally {
|
|
826
1019
|
clearTimeout(timer);
|
|
827
|
-
this.untrackPending(sessionId, controller);
|
|
828
|
-
controller.abort();
|
|
1020
|
+
this.untrackPending(params.sessionId, controller);
|
|
829
1021
|
}
|
|
830
1022
|
}
|
|
1023
|
+
// An answer that lands after the request was given up on can no longer be
|
|
1024
|
+
// honoured — the response has already gone back to the agent. It is dropped
|
|
1025
|
+
// either way; warning is what makes "my click did nothing" explicable.
|
|
1026
|
+
discardLateAnswer(chosenId, controller, roomId, sessionId) {
|
|
1027
|
+
if (!controller.signal.aborted || chosenId === void 0) {
|
|
1028
|
+
return chosenId;
|
|
1029
|
+
}
|
|
1030
|
+
this.safeWarn("resolvePermission answered after the request was abandoned; discarding", {
|
|
1031
|
+
roomId,
|
|
1032
|
+
sessionId,
|
|
1033
|
+
chosenId,
|
|
1034
|
+
reason: String(controller.signal.reason)
|
|
1035
|
+
});
|
|
1036
|
+
return void 0;
|
|
1037
|
+
}
|
|
1038
|
+
// The only place a permission's controller is ever aborted, so every
|
|
1039
|
+
// `signal.reason` a consumer can observe comes from the documented union.
|
|
1040
|
+
abandon(controller, reason) {
|
|
1041
|
+
controller.abort(reason);
|
|
1042
|
+
}
|
|
831
1043
|
trackPending(sessionId, controller) {
|
|
832
1044
|
const pending = this.pendingPermissions.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
833
1045
|
pending.add(controller);
|
|
@@ -840,14 +1052,14 @@ ${messageWithContext}`;
|
|
|
840
1052
|
this.pendingPermissions.delete(sessionId);
|
|
841
1053
|
}
|
|
842
1054
|
}
|
|
843
|
-
cancelPendingPermissions(sessionId) {
|
|
1055
|
+
cancelPendingPermissions(sessionId, reason) {
|
|
844
1056
|
for (const controller of this.pendingPermissions.get(sessionId) ?? []) {
|
|
845
|
-
|
|
1057
|
+
this.abandon(controller, reason);
|
|
846
1058
|
}
|
|
847
1059
|
}
|
|
848
|
-
cancelAllPendingPermissions() {
|
|
1060
|
+
cancelAllPendingPermissions(reason) {
|
|
849
1061
|
for (const sessionId of this.pendingPermissions.keys()) {
|
|
850
|
-
this.cancelPendingPermissions(sessionId);
|
|
1062
|
+
this.cancelPendingPermissions(sessionId, reason);
|
|
851
1063
|
}
|
|
852
1064
|
}
|
|
853
1065
|
async flushChunks(input) {
|
|
@@ -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
|
}
|
package/dist/converters.cjs
CHANGED
|
@@ -46,6 +46,9 @@ var ACPClientHistoryConverter = class {
|
|
|
46
46
|
convert(raw) {
|
|
47
47
|
const roomToSession = {};
|
|
48
48
|
for (const entry of raw) {
|
|
49
|
+
if (entry.message_type !== "task") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
49
52
|
const metadataRaw = entry.metadata;
|
|
50
53
|
if (!metadataRaw || typeof metadataRaw !== "object" || Array.isArray(metadataRaw)) {
|
|
51
54
|
continue;
|
|
@@ -53,7 +56,7 @@ var ACPClientHistoryConverter = class {
|
|
|
53
56
|
const metadata = metadataRaw;
|
|
54
57
|
const sessionId = metadata.acp_client_session_id;
|
|
55
58
|
const roomId = metadata.acp_client_room_id;
|
|
56
|
-
if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId) {
|
|
59
|
+
if (typeof sessionId === "string" && typeof roomId === "string" && sessionId && roomId && roomId === entry.room_id) {
|
|
57
60
|
roomToSession[roomId] = sessionId;
|
|
58
61
|
}
|
|
59
62
|
}
|
package/dist/converters.js
CHANGED