@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.
@@ -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
- private channelLoopMcpRegistered;
31
- private channelLoopMcpRegistration;
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
@@ -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,EAE5B,MAAM,yBAAyB,CAAC;AAUjC,YAAY,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAM/E,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,+BAA+B,QAAgB,CAAC;AAC7D,eAAO,MAAM,oBAAoB,QAAY,CAAC;AAC9C,eAAO,MAAM,kCAAkC,QAAgB,CAAC;AAIhE;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,GAAG,EAAE,OAAO,GACX,MAAM,EAAE,GAAG,SAAS,CAgBtB;AAED,qBAAa,SAAU,SAAQ,YAAa,YAAW,kBAAkB;IACvE,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,UAAU,CAAqC;IACvD,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,kBAAkB,CAA0B;IACpD,OAAO,CAAC,oBAAoB,CAAmC;IAC/D,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAgC;IACxE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAyC;IAC9E,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAGnC;IACJ,OAAO,CAAC,wBAAwB,CAAS;IACzC,OAAO,CAAC,0BAA0B,CAA8B;IAChE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAO/B;gBAEQ,OAAO,EAAE,gBAAgB;IAKrC,IAAI,iBAAiB,IAAI,gBAAgB,EAAE,CAE1C;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAwG5B,8BAA8B,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI;YAevD,wBAAwB;IAoBhC,UAAU,CACd,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gCAAgC,EAC1C,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,CAAC;IAcZ,WAAW,CACf,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,gCAAgC,EAC1C,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,CAAC;IAcZ,MAAM,CACV,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,+BAA+B,GACxC,OAAO,CAAC,MAAM,CAAC;IAwEZ,GAAG,CACP,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,gBAAgB,CAAC;IA0BtB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS/C,cAAc,CAClB,SAAS,EAAE,MAAM,EACjB,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAAC,IAAI,CAAC;IAiBV,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,yBAAyB,GAClC,OAAO,CAAC,OAAO,CAAC;IAenB,IAAI,IAAI,IAAI;IAYZ,IAAI,WAAW,IAAI,OAAO,CAIzB;IAED,OAAO,CAAC,mBAAmB;IA0I3B,OAAO,CAAC,gBAAgB;IAOxB,OAAO,CAAC,iBAAiB;IAuCzB,OAAO,CAAC,oBAAoB;IAI5B,OAAO,CAAC,yBAAyB;IAkBjC,OAAO,CAAC,yBAAyB;YAoBnB,4BAA4B;YA2C5B,eAAe;YAqBf,sBAAsB;IA4BpC,OAAO,CAAC,6BAA6B;CAmBtC"}
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, WORKSPACE_MCP_RUNTIME_ADD_METHOD, } from './ChannelLoopTools.js';
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
- channelLoopMcpRegistered = false;
50
- channelLoopMcpRegistration = null;
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.channelLoopMcpServer ??= new ChannelLoopMcpServer({
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
- void this.registerChannelLoopMcpServer();
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
- async registerChannelLoopMcpServer() {
504
- if (!this.connection ||
505
- !this.channelLoopMcpServer ||
506
- this.channelLoopMcpRegistered) {
507
- return;
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
- if (this.channelLoopMcpRegistration) {
510
- await this.channelLoopMcpRegistration;
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 = typeof params['sessionId'] === 'string'
568
- ? params['sessionId']
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 &&