forked from QwenLM/qwen-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbridgeClient.ts
More file actions
1986 lines (1925 loc) · 78.4 KB
/
Copy pathbridgeClient.ts
File metadata and controls
1986 lines (1925 loc) · 78.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import type {
Client,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
SessionUpdate,
WriteTextFileRequest,
WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import { RequestError } from '@agentclientprotocol/sdk';
import type { BridgeEvent, EventBus } from './eventBus.js';
// Wire constants shared with the child-side caller (`Session.ts`) and, for the
// SSE event type, the SDK validator + browser consumer — single sources of truth
// so a rename can't silently break the protocol.
import { MID_TURN_MESSAGE_INJECTED_EVENT } from './daemonEventTypes.js';
import { MID_TURN_QUEUE_DRAIN_METHOD } from './bridgeTypes.js';
import type { MidTurnQueueEntry } from './bridgeTypes.js';
import { SERVE_CONTROL_EXT_METHODS } from './status.js';
import type {
ClientMcpMessageSender,
CreateSubSessionHandler,
} from './bridgeOptions.js';
import type { BridgeFileSystem } from './bridgeFileSystem.js';
import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js';
// Narrowed from the concrete `MultiClientPermissionMediator` to the
// sub-interface this class actually uses (`request` only). Structural
// typing lets the bridge factory pass the full mediator instance
// without a cast; test stubs only need to fake the `request` method.
import type { PermissionMediator } from './permission.js';
import type {
PermissionRequestRecord,
PermissionResolution,
} from './permission.js';
import { CancelSentinelCollisionError } from './bridgeErrors.js';
import { writeStderrLine } from './internal/stderrLine.js';
import type {
SessionArtifactChange,
SessionArtifactInput,
SessionArtifactStore,
} from './sessionArtifacts.js';
// Keep in sync with core `ToolNames.ARTIFACT`; acp-bridge avoids a runtime
// import from core for this hot demux path.
const PUBLISH_ARTIFACT_TOOL_NAME = 'artifact';
/**
* Duck-type check for `FsError` from `cli/src/serve/fs/errors.ts`.
* FsError lives in `cli`, but this class lives in `acp-bridge` — a
* direct import would invert the dependency. Uses `.name`-based duck
* typing (same pattern as `mapDomainErrorToErrorKind` in status.ts).
*
* Without this: when the `BridgeFileSystem` adapter throws an
* `FsError`, the ACP SDK's default RPC error path serializes only
* `error.message` — the structured `kind` / `status` / `hint` are
* lost. With this: the bridge catches FsError and rethrows as ACP
* `RequestError(-32603, message, {errorKind, hint, status})` so the
* agent's RPC client can branch on `data.errorKind`.
*/
interface FsErrorShape {
name: 'FsError';
message: string;
kind: string;
status?: number;
hint?: string;
}
function isFsErrorShape(err: unknown): err is FsErrorShape {
return (
err instanceof Error &&
err.name === 'FsError' &&
typeof (err as { kind?: unknown }).kind === 'string'
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function artifactPayloadFields(
artifact: Record<string, unknown>,
): SessionArtifactInput {
return {
title: artifact['title'] as string,
kind: artifact['kind'] as SessionArtifactInput['kind'],
storage: artifact['storage'] as SessionArtifactInput['storage'],
description: artifact['description'] as string | undefined,
workspacePath: artifact['workspacePath'] as string | undefined,
managedId: artifact['managedId'] as string | undefined,
url: artifact['url'] as string | undefined,
mimeType: artifact['mimeType'] as string | undefined,
sizeBytes: artifact['sizeBytes'] as number | undefined,
metadata: artifact['metadata'] as SessionArtifactInput['metadata'],
};
}
function extractCappedArtifactInputs(
rawArtifacts: unknown[],
limit: number,
sessionId: string,
source: 'tool' | 'hook',
toInput: (artifact: Record<string, unknown>) => SessionArtifactInput,
): SessionArtifactInput[] {
const artifacts: SessionArtifactInput[] = [];
for (let index = 0; index < rawArtifacts.length; index++) {
const artifact = rawArtifacts[index];
if (!isRecord(artifact)) {
writeStderrLine(
`[artifacts] session=${sessionId} action=dropped reason=malformed source=${source} index=${index}`,
);
continue;
}
if (artifacts.length >= limit) {
writeStderrLine(
`[artifacts] session=${sessionId} action=dropped reason="artifact batch limit exceeded" source=${source} dropped=${rawArtifacts.length - index}`,
);
break;
}
artifacts.push(toInput(artifact));
}
return artifacts;
}
function artifactIngestionErrorReason(error: unknown): unknown {
if (!(error instanceof Error)) {
return String(error);
}
return {
name: error.name,
message: error.message,
stack: error.stack?.split('\n').slice(0, 4).join('\n'),
};
}
function extractSessionUpdateArtifacts(
params: SessionNotification,
updateMeta: Record<string, unknown> | undefined,
limit: number,
sessionId: string,
): SessionArtifactInput[] {
const rawArtifacts = updateMeta?.['artifacts'];
if (!Array.isArray(rawArtifacts)) {
return [];
}
const update = params.update as {
sessionUpdate?: unknown;
status?: unknown;
toolCallId?: unknown;
};
if (
update.sessionUpdate !== 'tool_call_update' ||
(update.status !== 'completed' &&
update.status !== 'failed' &&
update.status !== 'cancelled')
) {
return [];
}
const toolCallId =
typeof update.toolCallId === 'string' ? update.toolCallId : undefined;
const toolName =
typeof updateMeta?.['toolName'] === 'string'
? updateMeta['toolName']
: undefined;
return extractCappedArtifactInputs(
rawArtifacts,
limit,
sessionId,
'tool',
(artifact) => ({
...artifactPayloadFields(artifact),
source: 'tool' as const,
toolCallId,
toolName,
}),
);
}
function sanitizeSessionUpdateArtifacts(
params: SessionNotification,
updateMeta: Record<string, unknown> | undefined,
): SessionNotification {
if (!Array.isArray(updateMeta?.['artifacts'])) {
return params;
}
const sanitizedMeta = { ...updateMeta };
delete sanitizedMeta['artifacts'];
const update = {
...(params.update as Record<string, unknown>),
_meta: sanitizedMeta,
} as SessionNotification['update'];
return {
...params,
update,
};
}
function isTrustedArtifactToolUpdate(
params: SessionNotification,
updateMeta: Record<string, unknown> | undefined,
): boolean {
const update = params.update as {
sessionUpdate?: unknown;
status?: unknown;
};
// ToolCallEmitter stamps _meta.toolName from the actual tool invocation. The
// artifact payload itself is never allowed to self-declare publisher trust.
return (
update.sessionUpdate === 'tool_call_update' &&
update.status === 'completed' &&
updateMeta?.['toolName'] === PUBLISH_ARTIFACT_TOOL_NAME
);
}
/**
* Rethrow an FsError as a structured ACP `RequestError` so the
* agent's RPC client sees `data.errorKind` / `data.hint` /
* `data.status` rather than just the human-readable message.
* Non-FsError errors are rethrown unchanged — the default ACP
* serialization is fine for unstructured errors.
*/
function preserveFsErrorOverAcp(err: unknown): never {
if (isFsErrorShape(err)) {
throw new RequestError(-32603, err.message, {
errorKind: err.kind,
...(err.hint !== undefined ? { hint: err.hint } : {}),
...(err.status !== undefined ? { status: err.status } : {}),
});
}
throw err;
}
/**
* Translate the mediator's internal `PermissionResolution` to the
* ACP-shaped `RequestPermissionResponse` the agent expects.
* Voter-cancel, timeout, and session-closed all project to the same
* `{outcome: 'cancelled'}` shape — the ACP wire frame doesn't
* distinguish them. The audit log carries `decisionReason.type`
* for forensic discrimination.
*/
function resolutionToAcpResponse(
resolution: PermissionResolution,
): RequestPermissionResponse & Record<string, unknown> {
if (resolution.kind === 'option') {
return {
outcome: { outcome: 'selected', optionId: resolution.optionId },
...(resolution.metadata ?? {}),
};
}
return { outcome: { outcome: 'cancelled' } };
}
/**
* Bounded buffering for ACP `extNotification` frames that arrive on
* `BridgeClient` before the matching session has been registered in
* `byId`. The bridge populates `byId` only AFTER `connection.newSession`
* returns, but the child's MCP discovery runs INSIDE `newSession` and
* may fire budget events synchronously before the response makes it
* back. Without buffering, those frames are silently dropped.
*
* The triple bound (max sessions x max events per session x TTL)
* caps worst-case heap retention even if a malicious / buggy child
* spammed `extNotification` for sessionIds that never register:
* 64 x 32 x ~200B = 400 KB total. TTL is generous (60s) so brief
* scheduling pauses don't cause real warnings to be evicted.
*/
const MAX_EARLY_EVENT_SESSIONS = 64;
const MAX_EARLY_EVENTS_PER_SESSION = 32;
const MAX_SUGGESTION_LENGTH = 500;
const EARLY_EVENT_TTL_MS = 60_000;
// Known approval-mode ids accepted on the in-session `current_mode_update`
// demux path. Mirrors the `modeMap` keys in `Session.setMode` (CLI); an id
// outside this set is dropped before it fans out to SSE clients / the SDK
// reducer. Keep the two in lockstep. Exported so the bridge's reconcile and
// snapshot-seed paths apply the same enum backstop to agent-supplied mode ids.
export const KNOWN_APPROVAL_MODES: ReadonlySet<string> = new Set([
'plan',
'default',
'auto-edit',
'auto',
'yolo',
]);
/**
* Human-readable label for a `fs.Stats` object's kind, used in the
* `readTextFile` "not a regular file" rejection message (BX8YO).
* Sockets, pipes, char-devices etc. all report `size: 0` but stream
* unbounded data; the operator wants to know which one they hit so
* the path-mistake is obvious.
*/
function describeStatKind(stats: import('node:fs').Stats): string {
if (stats.isDirectory()) return 'directory';
if (stats.isSymbolicLink()) return 'symlink';
if (stats.isCharacterDevice()) return 'character device';
if (stats.isBlockDevice()) return 'block device';
if (stats.isFIFO()) return 'named pipe (FIFO)';
if (stats.isSocket()) return 'socket';
return 'non-regular file';
}
/**
* Extract the line range `[startLine, endLine)` (0-based) from a string
* without allocating a per-line array. Equivalent to
* `content.split('\n').slice(startLine, endLine).join('\n')` but
* O(file size) string scan rather than O(file size) string + O(line
* count) array. Matters for the partial-read path of `readTextFile`
* where the limit is small and the file is large.
*/
function sliceLineRange(
content: string,
startLine: number,
endLine: number | undefined,
): string {
// Find the byte offset where line `startLine` begins.
let offset = 0;
for (let i = 0; i < startLine; i++) {
const nl = content.indexOf('\n', offset);
if (nl === -1) return '';
offset = nl + 1;
}
if (endLine === undefined) return content.slice(offset);
// Walk `endLine - startLine` newlines forward to find the end byte.
let end = offset;
const want = endLine - startLine;
for (let i = 0; i < want; i++) {
const nl = content.indexOf('\n', end);
if (nl === -1) return content.slice(offset);
end = nl + 1;
}
// Trim the trailing `\n` so the slice mirrors `lines.slice(...).join('\n')`.
return content.slice(offset, end > offset ? end - 1 : end);
}
/**
* Minimal session-entry shape `BridgeClient` reads via its
* `resolveEntry` callback. Defined here (rather than importing the
* factory's richer `SessionEntry`) to keep the bridge package free of
* daemon-host session-bookkeeping types: the factory's `SessionEntry`
* structurally satisfies this interface, so no explicit conversion
* is required.
*
* Only four fields cross the boundary: `sessionId`, `events`,
* `pendingPermissionIds`, `activePromptOriginatorClientId`. New fields
* BridgeClient grows must be added here too (and the factory's
* `SessionEntry` is required to provide them — TS enforces the
* structural match at the callback signature).
*/
export interface BridgeClientSessionEntry {
sessionId: string;
events: EventBus;
artifacts: SessionArtifactStore;
pendingPermissionIds: Set<string>;
/**
* Mid-turn user messages queued by the browser, drained here when the ACP
* child calls the `craft/drainMidTurnQueue` ext-method. Owned by the full
* `SessionEntry` in `bridge.ts`; surfaced on this narrowed view so
* `extMethod` can splice it. See `SessionEntry.midTurnMessageQueue`.
*/
midTurnMessageQueue: MidTurnQueueEntry[];
/** True while a prompt is executing for this session. */
promptActive?: boolean;
activePromptOriginatorClientId?: string;
/**
* True while the bridge drives a model roundtrip; the
* `current_model_update` extNotification demux reads it to suppress
* promotion during a bridge-driven change. Set on the full `SessionEntry`
* in `bridge.ts`; surfaced here for the demux.
*/
modelRoundtripInFlight?: boolean;
/** A2: mirrors `modelRoundtripInFlight` for approval-mode roundtrips. */
approvalModeRoundtripInFlight?: boolean;
}
interface PreparedSessionUpdateFrames {
frames: Array<Omit<BridgeEvent, 'id' | 'v'>>;
artifacts: SessionArtifactInput[];
trustedPublisher: boolean;
}
/**
* Bridge `Client` implementation — the daemon's response surface for things
* the agent asks the client (file reads/writes, permission prompts).
*
* Stage 1 behavior:
* - `requestPermission` publishes a `permission_request` event onto the
* session bus and awaits the first HTTP `POST /permission/:requestId`
* vote (first-responder wins). When the session is cancelled or the
* daemon shuts down, the pending promise resolves with
* `{ outcome: { outcome: 'cancelled' } }` per ACP spec.
* - `sessionUpdate` notifications publish onto the session's EventBus; SSE
* subscribers (`GET /session/:id/events`) drain it.
* - File reads/writes proxy to local fs (daemon and agent share the host).
*
* Stage 1 trust model: the spawned `qwen --acp` child runs as the same user
* as the daemon, so the file-proxy methods do NOT enforce a workspace-cwd
* sandbox. The agent could already read or write the same files via its
* built-in tools (e.g. shell). Restricting the bridge here would be
* theatre. Stage 4+ remote-sandbox deployments swap this `Client` for a
* sandbox-aware variant.
*/
export class BridgeClient implements Client {
constructor(
/**
* Look up the `SessionEntry` for an ACP call. Stage 1.5 multi-
* session on one channel means `BridgeClient` is shared across
* many sessions, so we can't bind the entry in a closure — we
* dispatch by the `sessionId` ACP includes in every per-session
* notification / request. `undefined` sessionId is the fallback
* for ACP calls that don't carry one (none expected on the
* client surface as of this writing) and resolves to whatever
* the channel's most-recent entry is — kept defensive to avoid
* silent drops if ACP grows a no-sessionId call.
*/
private readonly resolveEntry: (
sessionId?: string,
) => BridgeClientSessionEntry | undefined,
private readonly resolvePendingRestoreEvents: (
sessionId?: string,
) => EventBus | undefined,
/** The multi-client permission coordinator. Owns ALL pending +
* resolved permission state; this client just plumbs
* `requestPermission` into `mediator.request` and forwards
* the resolution to the agent. Strategy dispatch and audit/emit
* fan-out live inside the mediator.
*/
private readonly mediator: Pick<PermissionMediator, 'request'>,
/**
* Bd1yh: wall-clock ms before `requestPermission` resolves as
* cancelled if no client vote arrives. 0 = disabled. Prevents
* the per-session FIFO `promptQueue` from poisoning forever
* when no SSE subscriber is connected. Forwarded directly to
* `mediator.request`; the mediator owns the timer.
*/
private readonly permissionTimeoutMs: number,
/**
* Bd1z5: per-session cap on in-flight permissions. New requests
* past this cap resolve as cancelled with a stderr warning.
* Infinity = disabled. The bridge keeps `entry.pendingPermissionIds`
* as a fast cap-check index; the mediator is still the source of
* truth for the pending registry.
*/
private readonly maxPendingPerSession: number,
/**
* Optional fs injection seam. When provided, `writeTextFile` /
* `readTextFile` delegate to this implementation instead of running
* the inline `fs.realpath` / `fs.writeFile` / `fs.readFile` proxy
* below. Production `qwen serve` wires a serve-side adapter
* wrapping `WorkspaceFileSystem` here so writes get the TOCTOU +
* symlink + trust-gate + audit machinery the inline proxy lacks.
* Omitted by tests + Mode A in-process consumers + channels / IDE
* companion — preserves the inline proxy behavior.
*/
private readonly fileSystem?: BridgeFileSystem,
/**
* §2.3 callback: centralised `model_switched` publish through the
* bridge factory's cache-updating helper. The BridgeClient calls
* this instead of inlining `entry.events.publish(...)` so the
* cache update + generation bump stays atomic in one place.
*/
private readonly onModelPromoted?: (
entry: BridgeClientSessionEntry,
modelId: string,
originatorClientId: string | undefined,
) => void,
/**
* §2.3 / A2 callback: centralised `approval_mode_changed` publish.
* Called by the A2 `current_mode_update` demux when the agent
* switches approval mode in-session (exit_plan_mode, ProceedAlways,
* /mode). `previous` is read from the bridge state cache.
*/
private readonly onModePromoted?: (
entry: BridgeClientSessionEntry,
modeId: string,
originatorClientId: string | undefined,
) => void,
/**
* Reverse tool channel (issue #5626, Phase 2). Resolves the
* `sendSdkMcpMessage`-shaped sender for a client-hosted MCP server name so
* the `qwen/control/client_mcp/message` ext-method (child → parent) can
* deliver a JSON-RPC frame to the extension and return the response.
* Omitted by tests / Mode A consumers — the method then rejects with
* `methodNotFound` (no client-hosted server can exist without it).
*/
private readonly clientMcpSender?: ClientMcpMessageSender,
private readonly ownsSession: (sessionId: string) => boolean = () => true,
/**
* Optional daemon token-usage hook. Called once per model round with the
* per-round input/output token increments read from
* `agent_message_chunk._meta.usage` at {@link sessionUpdate} (the single
* session/update fan-in). Wired only by the daemon host for the Daemon
* Status token-burn chart; omitted by tests / Mode A in-process consumers.
*/
private readonly onTokenUsage?: (
inputTokens: number,
outputTokens: number,
durationMs?: number,
) => void,
/**
* Daemon-host seam for the `create_sub_session` tool. Invoked from the
* `extMethod` dispatch (a child→daemon REQUEST, so it returns a Promise the
* child awaits) with the prompt, completion mode, and optional model/name;
* the host spawns a sub-session and, for `'first-turn'`, returns its result.
* Omitted by tests / Mode A / non-daemon — the method then reports
* `methodNotFound` and the tool surfaces itself as daemon-only.
*/
private readonly onCreateSubSession?: CreateSubSessionHandler,
) {}
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const entry = this.resolveEntry(params.sessionId);
if (!entry) return { outcome: { outcome: 'cancelled' } };
// Bd1z5: per-session cap. Reject before issuing so we never
// grow `pendingPermissionIds` past the limit.
if (entry.pendingPermissionIds.size >= this.maxPendingPerSession) {
writeStderrLine(
`qwen serve: session ${entry.sessionId} exceeded ` +
`maxPendingPermissionsPerSession (${this.maxPendingPerSession}) — ` +
`resolving new permission as cancelled.`,
);
return { outcome: { outcome: 'cancelled' } };
}
// BkwQI: snapshot the option-id set the agent is offering for
// this prompt. The mediator validates the voter's `optionId`
// against this set so a malicious client can't forge an option
// (e.g. `ProceedAlways*`) the agent intentionally hid.
const allowedOptionIds = new Set(
params.options.map((o: { optionId?: unknown }) =>
String(o.optionId ?? ''),
),
);
allowedOptionIds.delete('');
// Pre-flight the cancel-vote sentinel collision BEFORE publishing
// the `permission_request` SSE event. The mediator also checks
// defensively at issue time, but if we publish first and the
// mediator throws, SSE subscribers see an orphan event with no
// resolution.
const requestId = randomUUID();
if (allowedOptionIds.has(CANCEL_VOTE_SENTINEL)) {
throw new CancelSentinelCollisionError(requestId, CANCEL_VOTE_SENTINEL);
}
// Publish AFTER the collision check so a violating agent never
// leaves an orphan `permission_request` on the SSE bus. If the
// bus is closed (shutdown race), bail before touching the
// mediator. The mediator's N1 invariant (synchronous register
// inside the Promise executor) protects against the
// forgetSession-races-with-issue case ONLY when register runs;
// refusing to enter the mediator on a publish-failure is the
// symmetric defense for the publish-failure case.
const published = entry.events.publish({
type: 'permission_request',
data: {
requestId,
sessionId: entry.sessionId,
toolCall: params.toolCall,
options: params.options,
},
...(entry.activePromptOriginatorClientId
? { originatorClientId: entry.activePromptOriginatorClientId }
: {}),
});
if (!published) return { outcome: { outcome: 'cancelled' } };
// Cap-index add happens AFTER publish-success so a publish-fail
// path doesn't need to roll back. The mediator's
// `forgetSession` is the only thing that drains this index (via
// the bridge's `cancelPendingForSession`).
entry.pendingPermissionIds.add(requestId);
try {
const record: PermissionRequestRecord = {
requestId,
sessionId: entry.sessionId,
originatorClientId: entry.activePromptOriginatorClientId,
allowedOptionIds,
issuedAtMs: Date.now(),
};
const resolution = await this.mediator.request(
record,
this.permissionTimeoutMs,
);
return resolutionToAcpResponse(resolution);
} finally {
entry.pendingPermissionIds.delete(requestId);
}
}
async sessionUpdate(params: SessionNotification): Promise<void> {
if (
!this.ownsSession(params.sessionId) &&
!this.inFlightRestoreIds.has(params.sessionId)
) {
writeStderrLine(
`[demux] session=${params.sessionId} type=session_update action=dropped reason=session_not_owned`,
);
return;
}
const entry = this.resolveEntry(params.sessionId);
const events =
entry?.events ?? this.resolvePendingRestoreEvents(params.sessionId);
if (!events) return;
const prepared = this.prepareSessionUpdateFrames(params, entry);
for (const frame of prepared.frames) {
events.publish(frame);
}
// Daemon token-burn accounting for LIVE turns only (see method doc). Batch
// load-replay routes through seedSessionUpdates, not here, so replayed
// history never lands in the current metrics window. Wrapped so a throwing
// injected onTokenUsage callback can't skip the critical artifact processing
// below — metrics are optional, artifacts are not.
try {
this.recordLiveTokenUsage(params, entry);
} catch {
// Metrics callback failed; artifact processing must still run.
}
if (entry && prepared.artifacts.length > 0) {
await this.upsertAndPublishArtifacts(entry, prepared.artifacts, {
trustedPublisher: prepared.trustedPublisher,
});
}
}
prepareSessionUpdateFrames(
params: SessionNotification,
entry?: BridgeClientSessionEntry,
): PreparedSessionUpdateFrames {
const originator = entry?.activePromptOriginatorClientId
? { originatorClientId: entry.activePromptOriginatorClientId }
: {};
const frames: Array<Omit<BridgeEvent, 'id' | 'v'>> = [];
// A2UI-over-MCP: tool_call_update results from an A2UI UI server carry
// the A2UI command JSON flattened by core (EmbeddedResource -> text, the
// application/a2ui+json mime is dropped, so detection keys off the
// server/tool identity). Extract the commands, publish them as a separate
// `sessionUpdate:'a2ui'` frame for renderer clients, and sanitize the
// original tool frame so raw command JSON never reaches transcripts/SSE.
const a2ui = extractA2uiToolUpdate(params);
if (a2ui) {
// One frame per surface: tool results carrying commands for multiple
// surfaces are split so every consumer sees a single-surface frame.
for (const surface of a2ui.surfaces) {
frames.push({
type: 'session_update',
data: {
sessionId: params.sessionId,
update: {
sessionUpdate: 'a2ui',
a2ui: {
surfaceId: surface.surfaceId,
callId: a2ui.callId,
commands: surface.commands,
},
_meta: { serverTimestamp: Date.now(), source: 'a2ui-bridge' },
},
},
...originator,
});
}
params = a2ui.sanitizedParams;
}
// History replay re-emits each persisted record carrying its ORIGINAL
// wall-clock time as an epoch-ms `timestamp` nested in `update._meta` (set
// by the message/tool emitters). Lift it to the envelope-level
// `serverTimestamp` so `EventBus.publish` preserves it instead of stamping
// publish-time `Date.now()` — otherwise a resumed session renders every
// historical message at the resume moment instead of when it was sent.
// Live updates without such a timestamp pass no envelope `_meta` and keep
// the EventBus `Date.now()` fallback unchanged.
const updateMeta = (params.update as { _meta?: Record<string, unknown> })
._meta;
const originalTs =
updateMeta?.['serverTimestamp'] ?? updateMeta?.['timestamp'];
const serverTimestamp =
typeof originalTs === 'number' && Number.isFinite(originalTs)
? originalTs
: undefined;
const artifacts = entry?.artifacts
? extractSessionUpdateArtifacts(
params,
updateMeta,
entry.artifacts.inputBatchLimit(),
entry.sessionId,
)
: [];
const publishParams = sanitizeSessionUpdateArtifacts(params, updateMeta);
frames.push({
type: 'session_update',
data: publishParams,
...originator,
...(serverTimestamp !== undefined ? { _meta: { serverTimestamp } } : {}),
});
return {
frames,
artifacts,
trustedPublisher: isTrustedArtifactToolUpdate(params, updateMeta),
};
}
async seedSessionUpdates(
entry: BridgeClientSessionEntry,
updates: SessionUpdate[],
): Promise<void> {
const frames: Array<Omit<BridgeEvent, 'id' | 'v'>> = [];
const artifactBatches: Array<{
artifacts: SessionArtifactInput[];
trustedPublisher: boolean;
}> = [];
for (const update of updates) {
const prepared = this.prepareSessionUpdateFrames(
{ sessionId: entry.sessionId, update },
entry,
);
frames.push(...prepared.frames);
if (prepared.artifacts.length > 0) {
artifactBatches.push({
artifacts: prepared.artifacts,
trustedPublisher: prepared.trustedPublisher,
});
}
}
entry.events.seedReplayEvents(frames);
for (const batch of artifactBatches) {
await this.upsertAndPublishArtifacts(entry, batch.artifacts, {
trustedPublisher: batch.trustedPublisher,
});
}
}
/**
* Daemon token-burn accounting for LIVE model turns. Called only from
* `sessionUpdate` (the live session/update fan-in), never from
* `seedSessionUpdates` — so batch load-replay never lands historical usage in
* the current metrics window. Additionally guarded on a live `entry`: a stray
* pending-restore frame (entry not yet registered) is skipped too, so replayed
* history can't post a phantom burn spike with no model call.
*
* Usage rides an otherwise-empty `agent_message_chunk` as `update._meta.usage`
* with per-round camelCase increments; subagent frames carry their own usage
* (tagged `parentToolCallId`) and are independent turns, so counting each
* frame once is the correct total. `_meta`/`usage` are optional and untyped.
*/
private recordLiveTokenUsage(
params: SessionNotification,
entry: BridgeClientSessionEntry | undefined,
): void {
if (!this.onTokenUsage || !entry) return;
const updateMeta = (params.update as { _meta?: Record<string, unknown> })
._meta;
const usage = updateMeta?.['usage'];
if (usage === null || typeof usage !== 'object') return;
const inputTokens = (usage as { inputTokens?: unknown }).inputTokens;
const outputTokens = (usage as { outputTokens?: unknown }).outputTokens;
if (typeof inputTokens !== 'number' && typeof outputTokens !== 'number') {
return;
}
// `_meta.durationMs` (the LLM API round-trip) rides the same frame.
const durationMs = updateMeta?.['durationMs'];
this.onTokenUsage(
typeof inputTokens === 'number' ? inputTokens : 0,
typeof outputTokens === 'number' ? outputTokens : 0,
typeof durationMs === 'number' ? durationMs : undefined,
);
}
/**
* Bounded early-event buffer. Frames are keyed by sessionId; each
* entry tracks its `expiresAt` for lazy TTL-based eviction in
* `bufferEarlyEvent`. Drained by `drainEarlyEvents` whenever the
* bridge registers a session with a matching id. See
* MAX_EARLY_EVENT_* constants for capacity bounds.
*/
private readonly earlyEvents = new Map<
string,
{
frames: Array<Omit<BridgeEvent, 'id' | 'v'>>;
expiresAt: number;
}
>();
/**
* Tombstone for closed/killed session ids. Prevents late
* `extNotification` from a dying child from leaking into the
* early-event buffer and being replayed onto a future session
* that reuses the same id via `session/load` or `session/resume`.
*
* Tombstone semantics:
* - Marked when the bridge removes a sessionId from `byId` (kill
* path, channel.exited handler, closeSession).
* - Concurrently purges any in-flight `earlyEvents[id]`.
* - `bufferEarlyEvent` rejects tombstoned ids.
* - `drainEarlyEvents` clears the tombstone — a fresh
* `createSessionEntry` for the same id is a legitimate
* "load/resume of a persisted session id" case.
* - TTL = `EARLY_EVENT_TTL_MS` (60s) — same as the early-event
* buffer, so by the time a tombstone expires there can be no
* stale frame for that id anywhere in the system.
*/
private readonly tombstonedSessionIds = new Map<string, number>();
/**
* Allow-list of sessionIds currently being restored via
* `session/load` / `session/resume`. Bypasses the tombstone check
* in `bufferEarlyEvent` so restore-time guardrail events for a
* previously-closed id flow through to the future
* `createSessionEntry -> drainEarlyEvents` call.
*
* Without this, the tombstone set before a future `load` can clear
* it via `drainEarlyEvents` would silently drop legitimate
* restore-time events (e.g. MCP discovery budget events firing
* during the ACP call window).
*
* Bridge factory enters the set before awaiting the ACP restore
* call and exits on settle (success or failure).
*/
private readonly inFlightRestoreIds = new Set<string>();
/**
* Handle child->bridge ACP `extMethod` requests (calls that expect a
* response, unlike `extNotification`). Served methods:
* `qwen/control/client_mcp/message` (reverse tool channel),
* `qwen/control/create-sub-session` (the `create_sub_session` tool → daemon
* spawns a sub-session and, for `'first-turn'`, returns its first-turn
* result), and `craft/drainMidTurnQueue`: the ACP child calls the last one
* between tool batches to pull any messages the browser queued mid-turn. We splice the per-session
* queue, return them to the child as the response, and — when non-empty —
* publish a `mid_turn_message_injected` SSE frame so the browser can move
* those messages out of its pending queue (a dedupe signal, not a transcript
* render). Unknown methods reject with ACP `methodNotFound` (-32601), matching
* the SDK's
* default for an unimplemented client surface; the child's drain caller
* treats that as "drain unsupported" and stops asking.
*/
async extMethod(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
// Reverse tool channel (issue #5626, Phase 2): the child's session
// `McpClientManager` routes a client-hosted MCP server's
// `sendSdkMcpMessage` UP to the parent through this method. We hand the
// JSON-RPC `payload` to the per-WS-connection `ClientMcpRegistrar` (looked
// up by `server` name), which carries it down the daemon WS to the
// extension and returns the correlated response.
if (method === SERVE_CONTROL_EXT_METHODS.clientMcpMessage) {
return this.handleClientMcpMessage(params);
}
if (method === SERVE_CONTROL_EXT_METHODS.createSubSession) {
return this.handleCreateSubSession(params);
}
if (method !== MID_TURN_QUEUE_DRAIN_METHOD) {
throw RequestError.methodNotFound(method);
}
const sessionId =
typeof params['sessionId'] === 'string'
? (params['sessionId'] as string)
: undefined;
// The drain always carries a sessionId; without one we can't route it on a
// multi-session channel (and `resolveEntry(undefined)` would throw there),
// so answer with an empty drain rather than poisoning the turn.
if (!sessionId) return { messages: [] };
const entry = this.resolveEntry(sessionId);
if (!entry) return { messages: [] };
const drained = entry.midTurnMessageQueue.splice(0);
const messages = drained.map((item) => item.text);
if (drained.length > 0) {
// `publish()` never throws — it returns `undefined` on a closed bus (see
// EventBus.publish's never-throws contract: "Don't add try/catch wrappers
// around publish()"). Capture the result instead. A dropped frame is
// teardown-only: the child still gets the spliced messages below, but the
// browser won't receive the echo and would resend them next turn — so log
// it.
//
// Publish ONE frame per originator client. The frame is broadcast to every
// SSE subscriber on the session, so it carries `originatorClientId` for
// clients to filter on — a peer that didn't queue the message must not
// dedupe its own coincidentally-equal entry. A mixed-originator batch (two
// clients pushing in the same window) is rare but still routed correctly.
const byOriginator = new Map<string | undefined, string[]>();
for (const item of drained) {
const group = byOriginator.get(item.originatorClientId);
if (group) group.push(item.text);
else byOriginator.set(item.originatorClientId, [item.text]);
}
for (const [originatorClientId, texts] of byOriginator) {
const published = entry.events.publish({
type: MID_TURN_MESSAGE_INJECTED_EVENT,
data: { sessionId: entry.sessionId, messages: texts },
...(originatorClientId ? { originatorClientId } : {}),
});
writeStderrLine(
published
? `[mid-turn] session=${entry.sessionId} drained=${texts.length}${originatorClientId ? ` originator=${originatorClientId}` : ''} injected into running turn`
: `[mid-turn] session=${entry.sessionId} drained=${texts.length} echo frame dropped (bus closed); browser may resend next turn`,
);
}
}
return { messages };
}
/**
* Reverse tool channel (issue #5626, Phase 2) — answer the child's
* `qwen/control/client_mcp/message` ext-method. The child's session
* `McpClientManager` calls this when its agent drives a client-hosted
* (extension) MCP server: `params` carries the advertised `server` name and
* the JSON-RPC `payload` (initialize / tools/list / tools/call / a
* notification). We resolve the per-WS-connection sender via the injected
* `clientMcpSender` lookup, deliver the payload over the daemon WS, and
* return the correlated response as `{ payload }`.
*
* Rejects with ACP `methodNotFound` when no `clientMcpSender` is wired (Mode
* A / tests can't host a client MCP server), and `invalidParams` when the
* frame is malformed or the named server is no longer hosted (e.g. the
* extension disconnected mid-turn) — the agent's `SdkControlClientTransport`
* surfaces that as a transport error rather than hanging.
*/
private async handleClientMcpMessage(
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (!this.clientMcpSender) {
throw RequestError.methodNotFound(
SERVE_CONTROL_EXT_METHODS.clientMcpMessage,
);
}
const server = params['server'];
if (typeof server !== 'string' || server.length === 0) {
throw RequestError.invalidParams(
undefined,
'`server` must be a non-empty string',
);
}
const payload = params['payload'];
if (payload === null || typeof payload !== 'object') {
throw RequestError.invalidParams(
undefined,
'`payload` must be a JSON-RPC message object',
);
}
const send = this.clientMcpSender(server);
if (!send) {
// The client that hosted this server is gone (WS closed / unregistered).
throw RequestError.invalidParams(
undefined,
`client-hosted MCP server '${server}' is not currently connected`,
);
}
const response = await send(payload);
return { payload: response as Record<string, unknown> };
}
/**
* Handle the `create_sub_session` tool's request: validate, then forward to
* the daemon-host `onCreateSubSession` callback (which spawns a fresh
* top-level sub-session and, for `'first-turn'`, waits for its first turn and
* returns the result). No host wired → `methodNotFound`, which the tool
* surfaces as "daemon-only".
*/
private async handleCreateSubSession(
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (!this.onCreateSubSession) {
throw RequestError.methodNotFound(
SERVE_CONTROL_EXT_METHODS.createSubSession,
);
}
const prompt = params['prompt'];
if (typeof prompt !== 'string' || prompt.length === 0) {
throw RequestError.invalidParams(
undefined,
'`prompt` must be a non-empty string',
);
}
const completion = params['completion'];
if (completion !== 'sent' && completion !== 'first-turn') {
throw RequestError.invalidParams(
undefined,
"`completion` must be 'sent' or 'first-turn'",
);
}
const model = params['model'];
const name = params['name'];
const callerSessionId = params['callerSessionId'];
const result = await this.onCreateSubSession({
prompt,
completion,
...(typeof model === 'string' && model.length > 0 && model.length <= 128
? { model }
: {}),