@qwen-code/channel-base 0.26.4 → 0.26.5
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/AcpBridge.d.ts +76 -3
- package/dist/AcpBridge.d.ts.map +1 -1
- package/dist/AcpBridge.js +287 -44
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +31 -1
- package/dist/ChannelAgentBridge.d.ts.map +1 -1
- package/dist/ChannelAgentBridge.js.map +1 -1
- package/dist/ChannelLoopTools.d.ts +36 -3
- package/dist/ChannelLoopTools.d.ts.map +1 -1
- package/dist/ChannelLoopTools.js +58 -4
- package/dist/ChannelLoopTools.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +20 -1
- package/dist/DaemonChannelBridge.d.ts.map +1 -1
- package/dist/DaemonChannelBridge.js +68 -11
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/AcpBridge.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import type { RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
|
3
|
-
import { type AvailableCommand, type ChannelAgentBridge, type ChannelBtwResult, type ChannelAgentBridgePromptOptions, type ChannelAgentBridgeSessionOptions, type ChannelLoopToolHandler } from './ChannelAgentBridge.js';
|
|
3
|
+
import { type AvailableCommand, type ChannelAgentBridge, type ChannelBtwResult, type ChannelAgentBridgePromptOptions, type ChannelAgentBridgeSessionOptions, type ChannelLoopToolHandler, type ChannelToolDefinition } from './ChannelAgentBridge.js';
|
|
4
4
|
export type { AvailableCommand, ToolCallEvent } from './ChannelAgentBridge.js';
|
|
5
5
|
export interface AcpBridgeOptions {
|
|
6
6
|
cliEntryPath: string;
|
|
@@ -9,6 +9,14 @@ export interface AcpBridgeOptions {
|
|
|
9
9
|
}
|
|
10
10
|
export declare const ACP_EVENT_LOOP_STALL_RESTART_MS: number;
|
|
11
11
|
export declare const ACP_START_TIMEOUT_MS: number;
|
|
12
|
+
/**
|
|
13
|
+
* Bound for the session-scoped channel_loop MCP runtime add/remove. The add is
|
|
14
|
+
* not a local write: the child discovers the server inside the call and drives
|
|
15
|
+
* `initialize`/`tools/list` back into this bridge, so a stalled child would
|
|
16
|
+
* otherwise hang session creation (and with it every later message for that
|
|
17
|
+
* chat) with no stderr line.
|
|
18
|
+
*/
|
|
19
|
+
export declare const CHANNEL_TOOL_BIND_TIMEOUT_MS: number;
|
|
12
20
|
export declare const ACP_PERMISSION_RESPONSE_TIMEOUT_MS: number;
|
|
13
21
|
/**
|
|
14
22
|
* Read a command's aliases off a raw wire `available_commands_update` entry. ACP
|
|
@@ -24,16 +32,61 @@ export declare class AcpBridge extends EventEmitter implements ChannelAgentBridg
|
|
|
24
32
|
private _availableCommands;
|
|
25
33
|
private channelLoopMcpServer;
|
|
26
34
|
private readonly channelLoopToolHandlers;
|
|
35
|
+
private readonly channelTools;
|
|
36
|
+
/** Channel instance that owns each session, for adapter-tool scoping. */
|
|
37
|
+
private readonly sessionOwners;
|
|
27
38
|
private readonly knownSessionIds;
|
|
28
39
|
private readonly sessionBindingTokens;
|
|
29
40
|
private readonly toolCallKindsBySession;
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Sessions this bridge bound the channel_loop MCP server to. The binding is
|
|
43
|
+
* the session's channel-tool identity: a `client_mcp/message` frame is served
|
|
44
|
+
* only for a session in this ledger, never because the frame names one.
|
|
45
|
+
*/
|
|
46
|
+
private readonly channelLoopMcpBoundSessions;
|
|
47
|
+
/**
|
|
48
|
+
* Ownership token of the bind attempt that added each session to the bound
|
|
49
|
+
* ledger. A bind can outlive a discard/reload of its session, so a failed
|
|
50
|
+
* attempt must only release a claim it still owns.
|
|
51
|
+
*/
|
|
52
|
+
private readonly channelLoopMcpBindClaims;
|
|
53
|
+
/** In-flight per-session bindings, so one session binds once. */
|
|
54
|
+
private readonly channelLoopMcpBindings;
|
|
32
55
|
private readonly pendingPermissions;
|
|
33
56
|
constructor(options: AcpBridgeOptions);
|
|
34
57
|
get availableCommands(): AvailableCommand[];
|
|
35
58
|
start(): Promise<void>;
|
|
36
59
|
registerChannelLoopToolHandler(handler: ChannelLoopToolHandler): void;
|
|
60
|
+
registerChannelTool(tool: ChannelToolDefinition): void;
|
|
61
|
+
/**
|
|
62
|
+
* Shared registration guard: one tool per name per owning channel, and never
|
|
63
|
+
* a name the built-in channel_loop_* tools already own. Two instances of the
|
|
64
|
+
* same channel type register the same names, so the owner scopes the entry
|
|
65
|
+
* instead of the second registration being dropped.
|
|
66
|
+
*/
|
|
67
|
+
private addChannelTool;
|
|
68
|
+
/**
|
|
69
|
+
* Adapter tools one bound session may see. Visibility follows the session's
|
|
70
|
+
* channel: a session this bridge bound the server to sees its owner's tools
|
|
71
|
+
* plus the ownerless ones; a session that never bound it sees nothing.
|
|
72
|
+
*/
|
|
73
|
+
private channelToolsForSession;
|
|
74
|
+
/**
|
|
75
|
+
* Channel-tool identity for one `client_mcp/message` frame. The frame's
|
|
76
|
+
* `sessionId` is a carrier, not a credential: it resolves only when this
|
|
77
|
+
* bridge itself bound the channel_loop server to that session, so an id the
|
|
78
|
+
* bridge never bound resolves to nothing and no channel tool can run.
|
|
79
|
+
*
|
|
80
|
+
* This is a membership check, not a transport check: every id in the bound
|
|
81
|
+
* ledger resolves, so a bound session that named another bound session would
|
|
82
|
+
* be treated as that one. The child injects the id into its own reverse
|
|
83
|
+
* frames and the model cannot set it, which is what makes the ledger — not
|
|
84
|
+
* the frame — the authority on which chat a call may touch.
|
|
85
|
+
*/
|
|
86
|
+
private boundSessionId;
|
|
87
|
+
/** Bind the channel_loop server to every session this bridge already knows. */
|
|
88
|
+
private bindKnownSessions;
|
|
89
|
+
private ensureChannelToolServer;
|
|
37
90
|
private applySessionApprovalMode;
|
|
38
91
|
newSession(cwd: string, options?: ChannelAgentBridgeSessionOptions, bindingToken?: object): Promise<string>;
|
|
39
92
|
loadSession(sessionId: string, cwd: string, options?: ChannelAgentBridgeSessionOptions, bindingToken?: object): Promise<string>;
|
|
@@ -50,9 +103,29 @@ export declare class AcpBridge extends EventEmitter implements ChannelAgentBridg
|
|
|
50
103
|
private emitResponseBoundary;
|
|
51
104
|
private resolvePendingPermissions;
|
|
52
105
|
private maybeKillOnEventLoopStall;
|
|
106
|
+
/**
|
|
107
|
+
* Bind the channel_loop SDK MCP server to ONE session through the child's
|
|
108
|
+
* session-scoped runtime-add. The child then drives that server through the
|
|
109
|
+
* session's own `sendSdkMcpMessage`, so its reverse frames carry the session
|
|
110
|
+
* id, and the binding here is what makes that id trustworthy.
|
|
111
|
+
*/
|
|
53
112
|
private registerChannelLoopMcpServer;
|
|
113
|
+
private bindChannelLoopMcpServer;
|
|
114
|
+
/**
|
|
115
|
+
* Release a failed bind's optimistic claim, but only while this attempt
|
|
116
|
+
* still owns it: a newer bind for the same session may have claimed it.
|
|
117
|
+
*/
|
|
118
|
+
private releaseChannelLoopMcpBindClaim;
|
|
119
|
+
private removeChannelLoopMcpServer;
|
|
54
120
|
private handleExtMethod;
|
|
55
121
|
private handleClientMcpMessage;
|
|
122
|
+
/**
|
|
123
|
+
* `QWEN_CHANNEL_TOOL_DIAG=1` trace of one `client_mcp/message` frame: which
|
|
124
|
+
* method it is, which session id the frame carried, whether this bridge had
|
|
125
|
+
* bound that session, and what the reply contained. Off by default because
|
|
126
|
+
* the read runs per frame on a hot path.
|
|
127
|
+
*/
|
|
128
|
+
private logChannelToolDiagnostic;
|
|
56
129
|
private resolveChannelLoopToolHandler;
|
|
57
130
|
}
|
|
58
131
|
//# sourceMappingURL=AcpBridge.d.ts.map
|
package/dist/AcpBridge.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AcpBridge.d.ts","sourceRoot":"","sources":["../src/AcpBridge.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAO3C,OAAO,KAAK,EAIV,yBAAyB,EAC1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAaL,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,+BAA+B,EACpC,KAAK,gCAAgC,EACrC,KAAK,sBAAsB,
|
|
1
|
+
{"version":3,"file":"AcpBridge.d.ts","sourceRoot":"","sources":["../src/AcpBridge.ts"],"names":[],"mappings":"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"}
|
package/dist/AcpBridge.js
CHANGED
|
@@ -4,12 +4,27 @@ import { Readable, Writable } from 'node:stream';
|
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
5
5
|
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
6
6
|
import { ACP_PRIVATE_PARENT_CAPABILITY_ENV, ACP_PRIVATE_PARENT_CAPABILITY_META_KEY, CHANNEL_BTW_METHOD, CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY, CHANNEL_PROMPT_META_KEY, CHANNEL_OUTPUT_MODE_META_KEY, CHANNEL_TASK_RESULT_META_KEY, CHANNEL_TASK_RESULT_PARTIAL_META_KEY, CHANNEL_TASK_OUTPUT_META_KEY, ChannelPromptCancelledError, parseBackgroundResponseContext, resolvePromptImages, } from './ChannelAgentBridge.js';
|
|
7
|
-
import { CHANNEL_LOOP_MCP_SERVER_NAME, CLIENT_MCP_MESSAGE_METHOD, CLIENT_MCP_OVER_WS_CONFIG_FLAG, ChannelLoopMcpServer,
|
|
7
|
+
import { CHANNEL_LOOP_MCP_SERVER_NAME, CHANNEL_LOOP_MCP_TOOLS, CLIENT_MCP_MESSAGE_METHOD, CLIENT_MCP_OVER_WS_CONFIG_FLAG, ChannelLoopMcpServer, SESSION_MCP_RUNTIME_ADD_METHOD, SESSION_MCP_RUNTIME_REMOVE_METHOD, selectChannelToolsForSession, } from './ChannelLoopTools.js';
|
|
8
8
|
import { sanitizeLogText } from './sanitize.js';
|
|
9
9
|
const MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue';
|
|
10
10
|
const TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD = 'craft/claimTodoStopGuardContinuation';
|
|
11
|
+
/**
|
|
12
|
+
* Opt-in trace of the session→owner resolution the channel tool surface
|
|
13
|
+
* depends on. An unresolvable session silently narrows `tools/list` to no
|
|
14
|
+
* tools, which is invisible in the model transcript, so operators need a
|
|
15
|
+
* switch that costs nothing when unset.
|
|
16
|
+
*/
|
|
17
|
+
const CHANNEL_TOOL_DIAG_ENV = 'QWEN_CHANNEL_TOOL_DIAG';
|
|
11
18
|
export const ACP_EVENT_LOOP_STALL_RESTART_MS = 5 * 60 * 1000;
|
|
12
19
|
export const ACP_START_TIMEOUT_MS = 30 * 1000;
|
|
20
|
+
/**
|
|
21
|
+
* Bound for the session-scoped channel_loop MCP runtime add/remove. The add is
|
|
22
|
+
* not a local write: the child discovers the server inside the call and drives
|
|
23
|
+
* `initialize`/`tools/list` back into this bridge, so a stalled child would
|
|
24
|
+
* otherwise hang session creation (and with it every later message for that
|
|
25
|
+
* chat) with no stderr line.
|
|
26
|
+
*/
|
|
27
|
+
export const CHANNEL_TOOL_BIND_TIMEOUT_MS = 30 * 1000;
|
|
13
28
|
export const ACP_PERMISSION_RESPONSE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
14
29
|
const ACP_EVENT_LOOP_STALL_RE = /^\[perf\] acp agent event loop stall: max=(\d+(?:\.\d+)?)ms/m;
|
|
15
30
|
/**
|
|
@@ -43,11 +58,26 @@ export class AcpBridge extends EventEmitter {
|
|
|
43
58
|
_availableCommands = [];
|
|
44
59
|
channelLoopMcpServer;
|
|
45
60
|
channelLoopToolHandlers = [];
|
|
61
|
+
channelTools = [];
|
|
62
|
+
/** Channel instance that owns each session, for adapter-tool scoping. */
|
|
63
|
+
sessionOwners = new Map();
|
|
46
64
|
knownSessionIds = new Set();
|
|
47
65
|
sessionBindingTokens = new Map();
|
|
48
66
|
toolCallKindsBySession = new Map();
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Sessions this bridge bound the channel_loop MCP server to. The binding is
|
|
69
|
+
* the session's channel-tool identity: a `client_mcp/message` frame is served
|
|
70
|
+
* only for a session in this ledger, never because the frame names one.
|
|
71
|
+
*/
|
|
72
|
+
channelLoopMcpBoundSessions = new Set();
|
|
73
|
+
/**
|
|
74
|
+
* Ownership token of the bind attempt that added each session to the bound
|
|
75
|
+
* ledger. A bind can outlive a discard/reload of its session, so a failed
|
|
76
|
+
* attempt must only release a claim it still owns.
|
|
77
|
+
*/
|
|
78
|
+
channelLoopMcpBindClaims = new Map();
|
|
79
|
+
/** In-flight per-session bindings, so one session binds once. */
|
|
80
|
+
channelLoopMcpBindings = new Map();
|
|
51
81
|
pendingPermissions = new Map();
|
|
52
82
|
constructor(options) {
|
|
53
83
|
super();
|
|
@@ -94,6 +124,10 @@ export class AcpBridge extends EventEmitter {
|
|
|
94
124
|
this.resolvePendingPermissions();
|
|
95
125
|
this.knownSessionIds.clear();
|
|
96
126
|
this.sessionBindingTokens.clear();
|
|
127
|
+
this.sessionOwners.clear();
|
|
128
|
+
this.channelLoopMcpBoundSessions.clear();
|
|
129
|
+
this.channelLoopMcpBindClaims.clear();
|
|
130
|
+
this.channelLoopMcpBindings.clear();
|
|
97
131
|
this.toolCallKindsBySession.clear();
|
|
98
132
|
this.connection = null;
|
|
99
133
|
this.child = null;
|
|
@@ -124,7 +158,6 @@ export class AcpBridge extends EventEmitter {
|
|
|
124
158
|
[ACP_PRIVATE_PARENT_CAPABILITY_META_KEY]: privateParentCapability,
|
|
125
159
|
},
|
|
126
160
|
}), ACP_START_TIMEOUT_MS, `ACP initialization timed out after ${ACP_START_TIMEOUT_MS}ms`);
|
|
127
|
-
await this.registerChannelLoopMcpServer();
|
|
128
161
|
}
|
|
129
162
|
catch (error) {
|
|
130
163
|
this.stop();
|
|
@@ -135,12 +168,81 @@ export class AcpBridge extends EventEmitter {
|
|
|
135
168
|
if (!this.channelLoopToolHandlers.includes(handler)) {
|
|
136
169
|
this.channelLoopToolHandlers.push(handler);
|
|
137
170
|
}
|
|
138
|
-
this.
|
|
171
|
+
this.ensureChannelToolServer();
|
|
172
|
+
this.bindKnownSessions();
|
|
173
|
+
}
|
|
174
|
+
registerChannelTool(tool) {
|
|
175
|
+
if (!this.addChannelTool(tool))
|
|
176
|
+
return;
|
|
177
|
+
this.ensureChannelToolServer();
|
|
178
|
+
this.bindKnownSessions();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Shared registration guard: one tool per name per owning channel, and never
|
|
182
|
+
* a name the built-in channel_loop_* tools already own. Two instances of the
|
|
183
|
+
* same channel type register the same names, so the owner scopes the entry
|
|
184
|
+
* instead of the second registration being dropped.
|
|
185
|
+
*/
|
|
186
|
+
addChannelTool(tool) {
|
|
187
|
+
if (CHANNEL_LOOP_MCP_TOOLS.some((loopTool) => loopTool.name === tool.name)) {
|
|
188
|
+
process.stderr.write(`[AcpBridge] Channel tool "${sanitizeLogText(tool.name, 64)}" ignored: name is reserved for the built-in channel loop tools\n`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (this.channelTools.some((existing) => existing.name === tool.name && existing.owner === tool.owner)) {
|
|
192
|
+
process.stderr.write(`[AcpBridge] Channel tool "${sanitizeLogText(tool.name, 64)}" ignored: already registered for owner "${sanitizeLogText(tool.owner ?? '', 64)}"\n`);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
this.channelTools.push(tool);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Adapter tools one bound session may see. Visibility follows the session's
|
|
200
|
+
* channel: a session this bridge bound the server to sees its owner's tools
|
|
201
|
+
* plus the ownerless ones; a session that never bound it sees nothing.
|
|
202
|
+
*/
|
|
203
|
+
channelToolsForSession(sessionId) {
|
|
204
|
+
if (sessionId.length === 0 ||
|
|
205
|
+
!this.channelLoopMcpBoundSessions.has(sessionId)) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
return selectChannelToolsForSession(this.channelTools, this.sessionOwners.get(sessionId));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Channel-tool identity for one `client_mcp/message` frame. The frame's
|
|
212
|
+
* `sessionId` is a carrier, not a credential: it resolves only when this
|
|
213
|
+
* bridge itself bound the channel_loop server to that session, so an id the
|
|
214
|
+
* bridge never bound resolves to nothing and no channel tool can run.
|
|
215
|
+
*
|
|
216
|
+
* This is a membership check, not a transport check: every id in the bound
|
|
217
|
+
* ledger resolves, so a bound session that named another bound session would
|
|
218
|
+
* be treated as that one. The child injects the id into its own reverse
|
|
219
|
+
* frames and the model cannot set it, which is what makes the ledger — not
|
|
220
|
+
* the frame — the authority on which chat a call may touch.
|
|
221
|
+
*/
|
|
222
|
+
boundSessionId(params) {
|
|
223
|
+
const sessionId = params['sessionId'];
|
|
224
|
+
return typeof sessionId === 'string' &&
|
|
225
|
+
this.channelLoopMcpBoundSessions.has(sessionId)
|
|
226
|
+
? sessionId
|
|
227
|
+
: undefined;
|
|
228
|
+
}
|
|
229
|
+
/** Bind the channel_loop server to every session this bridge already knows. */
|
|
230
|
+
bindKnownSessions() {
|
|
231
|
+
for (const sessionId of this.knownSessionIds) {
|
|
232
|
+
if (!this.channelLoopMcpBoundSessions.has(sessionId)) {
|
|
233
|
+
void this.registerChannelLoopMcpServer(sessionId);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
ensureChannelToolServer() {
|
|
238
|
+
return (this.channelLoopMcpServer ??= new ChannelLoopMcpServer({
|
|
139
239
|
create: (sessionId, input) => this.resolveChannelLoopToolHandler(sessionId).create(sessionId, input),
|
|
140
240
|
list: (sessionId) => this.resolveChannelLoopToolHandler(sessionId).list(sessionId),
|
|
141
241
|
cancel: (sessionId, id) => this.resolveChannelLoopToolHandler(sessionId).cancel(sessionId, id),
|
|
142
|
-
}
|
|
143
|
-
|
|
242
|
+
}, {
|
|
243
|
+
extraTools: (sessionId) => this.channelToolsForSession(sessionId),
|
|
244
|
+
loopToolsEnabled: () => this.channelLoopToolHandlers.length > 0,
|
|
245
|
+
}));
|
|
144
246
|
}
|
|
145
247
|
async applySessionApprovalMode(conn, sessionId, approvalMode) {
|
|
146
248
|
if (!approvalMode)
|
|
@@ -159,16 +261,18 @@ export class AcpBridge extends EventEmitter {
|
|
|
159
261
|
}
|
|
160
262
|
async newSession(cwd, options, bindingToken) {
|
|
161
263
|
const conn = this.ensureConnection();
|
|
162
|
-
await this.registerChannelLoopMcpServer();
|
|
163
264
|
const response = await conn.newSession({ cwd, mcpServers: [] });
|
|
164
265
|
await this.applySessionApprovalMode(conn, response.sessionId, options?.approvalMode);
|
|
165
266
|
this.knownSessionIds.add(response.sessionId);
|
|
166
267
|
this.sessionBindingTokens.set(response.sessionId, bindingToken);
|
|
268
|
+
if (options?.sourceId) {
|
|
269
|
+
this.sessionOwners.set(response.sessionId, options.sourceId);
|
|
270
|
+
}
|
|
271
|
+
await this.registerChannelLoopMcpServer(response.sessionId);
|
|
167
272
|
return response.sessionId;
|
|
168
273
|
}
|
|
169
274
|
async loadSession(sessionId, cwd, options, bindingToken) {
|
|
170
275
|
const conn = this.ensureConnection();
|
|
171
|
-
await this.registerChannelLoopMcpServer();
|
|
172
276
|
await conn.unstable_resumeSession({
|
|
173
277
|
sessionId,
|
|
174
278
|
cwd,
|
|
@@ -177,10 +281,23 @@ export class AcpBridge extends EventEmitter {
|
|
|
177
281
|
await this.applySessionApprovalMode(conn, sessionId, options?.approvalMode);
|
|
178
282
|
this.knownSessionIds.add(sessionId);
|
|
179
283
|
this.sessionBindingTokens.set(sessionId, bindingToken);
|
|
284
|
+
if (options?.sourceId) {
|
|
285
|
+
this.sessionOwners.set(sessionId, options.sourceId);
|
|
286
|
+
}
|
|
287
|
+
await this.registerChannelLoopMcpServer(sessionId);
|
|
180
288
|
return sessionId;
|
|
181
289
|
}
|
|
182
290
|
async prompt(sessionId, text, options) {
|
|
183
291
|
const conn = this.ensureConnection();
|
|
292
|
+
// A binding skipped at session creation (MCP budget, one transient
|
|
293
|
+
// runtime-add failure) must not cost the session every channel tool for its
|
|
294
|
+
// lifetime. Retry it on the next turn, fire-and-forget so a slow or skipped
|
|
295
|
+
// bind never delays the prompt; registerChannelLoopMcpServer is idempotent
|
|
296
|
+
// and deduplicates concurrent attempts.
|
|
297
|
+
if (this.knownSessionIds.has(sessionId) &&
|
|
298
|
+
!this.channelLoopMcpBoundSessions.has(sessionId)) {
|
|
299
|
+
void this.registerChannelLoopMcpServer(sessionId);
|
|
300
|
+
}
|
|
184
301
|
const chunks = [];
|
|
185
302
|
let slashCommandOutput = '';
|
|
186
303
|
const onChunk = (sid, chunk) => {
|
|
@@ -284,11 +401,18 @@ export class AcpBridge extends EventEmitter {
|
|
|
284
401
|
if (!this.knownSessionIds.delete(sessionId))
|
|
285
402
|
return;
|
|
286
403
|
this.sessionBindingTokens.delete(sessionId);
|
|
404
|
+
this.sessionOwners.delete(sessionId);
|
|
287
405
|
this.toolCallKindsBySession.delete(sessionId);
|
|
406
|
+
this.channelLoopMcpBindings.delete(sessionId);
|
|
407
|
+
this.channelLoopMcpBindClaims.delete(sessionId);
|
|
408
|
+
const wasBound = this.channelLoopMcpBoundSessions.delete(sessionId);
|
|
288
409
|
this.resolvePendingPermissions(sessionId);
|
|
289
410
|
const conn = this.connection;
|
|
290
411
|
if (!conn || !this.isConnected)
|
|
291
412
|
return;
|
|
413
|
+
if (wasBound) {
|
|
414
|
+
await this.removeChannelLoopMcpServer(conn, sessionId);
|
|
415
|
+
}
|
|
292
416
|
await conn.extMethod('qwen/control/session/close', { sessionId });
|
|
293
417
|
}
|
|
294
418
|
async respondToPermission(requestId, response) {
|
|
@@ -309,6 +433,10 @@ export class AcpBridge extends EventEmitter {
|
|
|
309
433
|
this.resolvePendingPermissions();
|
|
310
434
|
this.knownSessionIds.clear();
|
|
311
435
|
this.sessionBindingTokens.clear();
|
|
436
|
+
this.sessionOwners.clear();
|
|
437
|
+
this.channelLoopMcpBoundSessions.clear();
|
|
438
|
+
this.channelLoopMcpBindClaims.clear();
|
|
439
|
+
this.channelLoopMcpBindings.clear();
|
|
312
440
|
this.toolCallKindsBySession.clear();
|
|
313
441
|
if (this.child) {
|
|
314
442
|
this.child.kill();
|
|
@@ -500,41 +628,82 @@ export class AcpBridge extends EventEmitter {
|
|
|
500
628
|
process.stderr.write(`[AcpBridge] ACP agent event loop stalled for ${Math.round(maxMs)}ms; killing child process to trigger restart\n`);
|
|
501
629
|
child.kill('SIGKILL');
|
|
502
630
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
631
|
+
/**
|
|
632
|
+
* Bind the channel_loop SDK MCP server to ONE session through the child's
|
|
633
|
+
* session-scoped runtime-add. The child then drives that server through the
|
|
634
|
+
* session's own `sendSdkMcpMessage`, so its reverse frames carry the session
|
|
635
|
+
* id, and the binding here is what makes that id trustworthy.
|
|
636
|
+
*/
|
|
637
|
+
async registerChannelLoopMcpServer(sessionId) {
|
|
638
|
+
if (!this.connection || !this.channelLoopMcpServer)
|
|
639
|
+
return false;
|
|
640
|
+
const inFlight = this.channelLoopMcpBindings.get(sessionId);
|
|
641
|
+
if (inFlight)
|
|
642
|
+
return inFlight;
|
|
643
|
+
if (this.channelLoopMcpBoundSessions.has(sessionId))
|
|
644
|
+
return true;
|
|
645
|
+
const binding = this.bindChannelLoopMcpServer(sessionId).finally(() => {
|
|
646
|
+
if (this.channelLoopMcpBindings.get(sessionId) === binding) {
|
|
647
|
+
this.channelLoopMcpBindings.delete(sessionId);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
this.channelLoopMcpBindings.set(sessionId, binding);
|
|
651
|
+
return binding;
|
|
652
|
+
}
|
|
653
|
+
async bindChannelLoopMcpServer(sessionId) {
|
|
654
|
+
// Claim the binding before the runtime-add can answer: the child discovers
|
|
655
|
+
// the newly added server inside that call, so its `tools/list` frame can
|
|
656
|
+
// arrive before the add resolves, and it must already resolve here.
|
|
657
|
+
this.channelLoopMcpBoundSessions.add(sessionId);
|
|
658
|
+
// The claim is ownership-checked: a discard can drop it and a reload can
|
|
659
|
+
// bind the same session again while this attempt is still in flight, so a
|
|
660
|
+
// late failure must not erase the newer attempt's claim.
|
|
661
|
+
const claim = {};
|
|
662
|
+
this.channelLoopMcpBindClaims.set(sessionId, claim);
|
|
663
|
+
try {
|
|
664
|
+
const result = await withTimeout(this.connection.extMethod(SESSION_MCP_RUNTIME_ADD_METHOD, {
|
|
665
|
+
sessionId,
|
|
666
|
+
name: CHANNEL_LOOP_MCP_SERVER_NAME,
|
|
667
|
+
originatorClientId: 'channel',
|
|
668
|
+
config: {
|
|
669
|
+
type: 'sdk',
|
|
670
|
+
[CLIENT_MCP_OVER_WS_CONFIG_FLAG]: true,
|
|
671
|
+
},
|
|
672
|
+
}), CHANNEL_TOOL_BIND_TIMEOUT_MS, `channel loop MCP bind timed out after ${CHANNEL_TOOL_BIND_TIMEOUT_MS}ms`);
|
|
673
|
+
if (isSkippedMcpRegistration(result)) {
|
|
674
|
+
this.releaseChannelLoopMcpBindClaim(sessionId, claim);
|
|
675
|
+
process.stderr.write(`[AcpBridge] Channel loop MCP server binding for session ${sanitizeLogText(sessionId, 128)} skipped${formatSkippedRegistrationReason(result)}\n`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
return true;
|
|
508
679
|
}
|
|
509
|
-
|
|
510
|
-
|
|
680
|
+
catch (error) {
|
|
681
|
+
this.releaseChannelLoopMcpBindClaim(sessionId, claim);
|
|
682
|
+
process.stderr.write(`[AcpBridge] Failed to bind channel loop MCP server to session ${sanitizeLogText(sessionId, 128)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Release a failed bind's optimistic claim, but only while this attempt
|
|
688
|
+
* still owns it: a newer bind for the same session may have claimed it.
|
|
689
|
+
*/
|
|
690
|
+
releaseChannelLoopMcpBindClaim(sessionId, claim) {
|
|
691
|
+
if (this.channelLoopMcpBindClaims.get(sessionId) !== claim)
|
|
511
692
|
return;
|
|
693
|
+
this.channelLoopMcpBindClaims.delete(sessionId);
|
|
694
|
+
this.channelLoopMcpBoundSessions.delete(sessionId);
|
|
695
|
+
}
|
|
696
|
+
async removeChannelLoopMcpServer(conn, sessionId) {
|
|
697
|
+
try {
|
|
698
|
+
await withTimeout(conn.extMethod(SESSION_MCP_RUNTIME_REMOVE_METHOD, {
|
|
699
|
+
sessionId,
|
|
700
|
+
name: CHANNEL_LOOP_MCP_SERVER_NAME,
|
|
701
|
+
originatorClientId: 'channel',
|
|
702
|
+
}), CHANNEL_TOOL_BIND_TIMEOUT_MS, `channel loop MCP unbind timed out after ${CHANNEL_TOOL_BIND_TIMEOUT_MS}ms`);
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
process.stderr.write(`[AcpBridge] Failed to remove channel loop MCP server from session ${sanitizeLogText(sessionId, 128)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
|
|
512
706
|
}
|
|
513
|
-
this.channelLoopMcpRegistration = this.connection
|
|
514
|
-
.extMethod(WORKSPACE_MCP_RUNTIME_ADD_METHOD, {
|
|
515
|
-
name: CHANNEL_LOOP_MCP_SERVER_NAME,
|
|
516
|
-
originatorClientId: 'channel',
|
|
517
|
-
config: {
|
|
518
|
-
type: 'sdk',
|
|
519
|
-
[CLIENT_MCP_OVER_WS_CONFIG_FLAG]: true,
|
|
520
|
-
},
|
|
521
|
-
})
|
|
522
|
-
.then((result) => {
|
|
523
|
-
if (isSkippedMcpRegistration(result)) {
|
|
524
|
-
this.channelLoopMcpRegistered = false;
|
|
525
|
-
process.stderr.write(`[AcpBridge] Channel loop MCP server registration skipped${formatSkippedRegistrationReason(result)}\n`);
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
this.channelLoopMcpRegistered = true;
|
|
529
|
-
})
|
|
530
|
-
.catch((error) => {
|
|
531
|
-
this.channelLoopMcpRegistered = false;
|
|
532
|
-
process.stderr.write(`[AcpBridge] Failed to register channel loop MCP server: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
533
|
-
})
|
|
534
|
-
.finally(() => {
|
|
535
|
-
this.channelLoopMcpRegistration = null;
|
|
536
|
-
});
|
|
537
|
-
await this.channelLoopMcpRegistration;
|
|
538
707
|
}
|
|
539
708
|
async handleExtMethod(method, params) {
|
|
540
709
|
if (method === CLIENT_MCP_MESSAGE_METHOD) {
|
|
@@ -564,15 +733,72 @@ export class AcpBridge extends EventEmitter {
|
|
|
564
733
|
if (typeof payload !== 'object' || payload === null) {
|
|
565
734
|
throw new Error('Invalid client MCP payload.');
|
|
566
735
|
}
|
|
567
|
-
const sessionId =
|
|
568
|
-
|
|
736
|
+
const sessionId = this.boundSessionId(params);
|
|
737
|
+
const rawSessionId = params['sessionId'];
|
|
738
|
+
const carriedSessionId = sessionId === undefined &&
|
|
739
|
+
typeof rawSessionId === 'string' &&
|
|
740
|
+
rawSessionId.length > 0
|
|
741
|
+
? rawSessionId
|
|
569
742
|
: undefined;
|
|
570
|
-
const response = await this.channelLoopMcpServer.handleMessage(payload, { sessionId });
|
|
743
|
+
const response = await this.channelLoopMcpServer.handleMessage(payload, { sessionId, carriedSessionId });
|
|
744
|
+
this.logChannelToolDiagnostic(params, sessionId, response);
|
|
571
745
|
if (!response) {
|
|
572
746
|
return { payload: { jsonrpc: '2.0', id: 0, result: {} } };
|
|
573
747
|
}
|
|
574
748
|
return { payload: response };
|
|
575
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* `QWEN_CHANNEL_TOOL_DIAG=1` trace of one `client_mcp/message` frame: which
|
|
752
|
+
* method it is, which session id the frame carried, whether this bridge had
|
|
753
|
+
* bound that session, and what the reply contained. Off by default because
|
|
754
|
+
* the read runs per frame on a hot path.
|
|
755
|
+
*/
|
|
756
|
+
logChannelToolDiagnostic(params, sessionId, response) {
|
|
757
|
+
if (process.env[CHANNEL_TOOL_DIAG_ENV] !== '1')
|
|
758
|
+
return;
|
|
759
|
+
const payload = params['payload'];
|
|
760
|
+
const message = typeof payload === 'object' && payload !== null
|
|
761
|
+
? payload
|
|
762
|
+
: undefined;
|
|
763
|
+
const method = typeof message?.['method'] === 'string'
|
|
764
|
+
? message['method']
|
|
765
|
+
: '<none>';
|
|
766
|
+
if (method !== 'tools/list' && method !== 'tools/call')
|
|
767
|
+
return;
|
|
768
|
+
const rawSessionId = params['sessionId'];
|
|
769
|
+
const carried = typeof rawSessionId === 'string' && rawSessionId.length > 0
|
|
770
|
+
? rawSessionId
|
|
771
|
+
: undefined;
|
|
772
|
+
// A known-but-unbound session is exactly the state this switch exists to
|
|
773
|
+
// explain, so resolve the owner from the id the frame carried too. An id
|
|
774
|
+
// in neither ledger acquires nothing (fail closed).
|
|
775
|
+
const ownerId = sessionId ??
|
|
776
|
+
(carried !== undefined && this.knownSessionIds.has(carried)
|
|
777
|
+
? carried
|
|
778
|
+
: undefined);
|
|
779
|
+
const owner = ownerId === undefined ? undefined : this.sessionOwners.get(ownerId);
|
|
780
|
+
const result = response?.['result'];
|
|
781
|
+
const resultRecord = typeof result === 'object' && result !== null
|
|
782
|
+
? result
|
|
783
|
+
: undefined;
|
|
784
|
+
const listed = resultRecord?.['tools'];
|
|
785
|
+
const rpcError = response?.['error'];
|
|
786
|
+
let detail;
|
|
787
|
+
if (method === 'tools/list') {
|
|
788
|
+
detail = `tools=${Array.isArray(listed) ? listed.length : '?'}`;
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
// The tool name is what separates a loop-handler failure from a platform
|
|
792
|
+
// failure; the JSON-RPC error arm never carries a result, so read it
|
|
793
|
+
// alongside the result arm or a failed call looks like a success.
|
|
794
|
+
const toolName = readToolCallName(message);
|
|
795
|
+
detail =
|
|
796
|
+
`tool=${toolName === undefined ? '<none>' : sanitizeLogText(toolName, 64)}` +
|
|
797
|
+
` isError=${resultRecord?.['isError'] === true || rpcError !== undefined}` +
|
|
798
|
+
` rpcError=${rpcError === undefined ? 'none' : sanitizeLogText(describeRpcError(rpcError), 256)}`;
|
|
799
|
+
}
|
|
800
|
+
process.stderr.write(`[AcpBridge] channel-tool-diag method=${method} sessionId=${carried === undefined ? '<absent>' : sanitizeLogText(carried, 128)} bound=${sessionId !== undefined} known=${carried !== undefined && this.knownSessionIds.has(carried)} owner=${owner === undefined ? '<none>' : sanitizeLogText(owner, 64)} ${detail}\n`);
|
|
801
|
+
}
|
|
576
802
|
resolveChannelLoopToolHandler(sessionId) {
|
|
577
803
|
if (this.channelLoopToolHandlers.length === 1 &&
|
|
578
804
|
!this.channelLoopToolHandlers[0].canHandle) {
|
|
@@ -623,6 +849,23 @@ function createAbortError() {
|
|
|
623
849
|
error.name = 'AbortError';
|
|
624
850
|
return error;
|
|
625
851
|
}
|
|
852
|
+
/** Tool name a `tools/call` payload names, for the diagnostic trace. */
|
|
853
|
+
function readToolCallName(message) {
|
|
854
|
+
const callParams = message?.['params'];
|
|
855
|
+
if (typeof callParams !== 'object' || callParams === null)
|
|
856
|
+
return undefined;
|
|
857
|
+
const name = callParams['name'];
|
|
858
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
859
|
+
}
|
|
860
|
+
/** Stringify a JSON-RPC error reply for the diagnostic trace. */
|
|
861
|
+
function describeRpcError(error) {
|
|
862
|
+
try {
|
|
863
|
+
return JSON.stringify(error) ?? String(error);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
return String(error);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
626
869
|
function isSkippedMcpRegistration(result) {
|
|
627
870
|
return (typeof result === 'object' &&
|
|
628
871
|
result !== null &&
|