agent-gear 1.1.5 → 1.1.7

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/src/acp/bridge.ts CHANGED
@@ -8,7 +8,9 @@ const ACP_PROTOCOL_VERSION = 1;
8
8
  export interface AcpBridgeOptions {
9
9
  sessionId: string;
10
10
  cwd: string;
11
+ additionalDirectories?: string[];
11
12
  mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
13
+ mcpEndpoint?: { url: string; token: string; serverNames: string[] };
12
14
  }
13
15
 
14
16
  interface QueuedInput {
@@ -16,6 +18,39 @@ interface QueuedInput {
16
18
  content: string;
17
19
  }
18
20
 
21
+ export interface SessionConfigOptionData {
22
+ id: string;
23
+ name: string;
24
+ description?: string | null;
25
+ category?: string | null;
26
+ type: 'select' | 'boolean';
27
+ currentValue: string | null;
28
+ options: Array<{ value: string; name?: string }>;
29
+ }
30
+
31
+ export interface AcpAuthMethodData {
32
+ id: string;
33
+ kind: 'env_var' | 'terminal' | 'agent' | null;
34
+ description?: string | null;
35
+ url?: string | null;
36
+ }
37
+
38
+ export interface AcpInitializedPayload {
39
+ sessionId: string;
40
+ acpSessionId: string | null;
41
+ modes: Array<{ id: string; name: string; description?: string }>;
42
+ currentModeId: string | null;
43
+ currentModel: { id: string; name: string; provider?: string } | null;
44
+ availableModels: Array<{ id: string; name: string; provider?: string }>;
45
+ currentModelId: string | null;
46
+ configOptions: SessionConfigOptionData[];
47
+ promptCapabilities: { image: boolean; audio: boolean; embeddedContext: boolean } | null;
48
+ agentInfo: { name: string; version?: string | null } | null;
49
+ authMethods: AcpAuthMethodData[];
50
+ supportsAdditionalDirectories: boolean;
51
+ protocolVersion?: number | null;
52
+ }
53
+
19
54
  function toMcpServers(servers: AcpBridgeOptions['mcpServers']): acp.McpServer[] {
20
55
  return (servers ?? []).map((s) => ({
21
56
  name: s.name,
@@ -25,17 +60,138 @@ function toMcpServers(servers: AcpBridgeOptions['mcpServers']): acp.McpServer[]
25
60
  }));
26
61
  }
27
62
 
63
+ // Sanitized, token-free summary of the MCP servers about to be sent to the
64
+ // agent, so launch failures are diagnosable from local logs alone.
65
+ function describeMcpServersForLog(servers: acp.McpServer[]): string {
66
+ if (servers.length === 0) return 'none';
67
+ return servers.map((s) => {
68
+ if ('url' in s) {
69
+ const headerNames = (s.headers ?? []).map((h) => h.name).join('+');
70
+ return `${s.name}(type=${(s as { type?: string }).type ?? 'http'}, url=${s.url}${headerNames ? `, headers=${headerNames}` : ''})`;
71
+ }
72
+ return `${s.name}(type=stdio, cmd=${(s as { command?: string }).command})`;
73
+ }).join(' ');
74
+ }
75
+
76
+ function toMcpEndpointServers(mcpEndpoint: NonNullable<AcpBridgeOptions['mcpEndpoint']>): acp.McpServer[] {
77
+ // Fail loudly with a diagnosable message instead of a raw TypeError when the
78
+ // upstream payload drifts from the contract (serverNames is required).
79
+ if (!Array.isArray(mcpEndpoint.serverNames) || mcpEndpoint.serverNames.length === 0) {
80
+ throw new Error(
81
+ `Invalid mcpEndpoint payload: "serverNames" must be a non-empty array ` +
82
+ `(received: ${JSON.stringify(mcpEndpoint.serverNames)}, url: ${mcpEndpoint.url})`,
83
+ );
84
+ }
85
+ const headers: acp.HttpHeader[] = [{ name: 'Authorization', value: `Bearer ${mcpEndpoint.token}` }];
86
+ // Each assembled server gets its own URL so the entry point can route
87
+ // standard MCP clients (which never send a serverName param) correctly.
88
+ return mcpEndpoint.serverNames.map((name) => ({
89
+ type: 'http' as const,
90
+ name,
91
+ url: `${mcpEndpoint.url}/${encodeURIComponent(name)}`,
92
+ headers,
93
+ }));
94
+ }
95
+
96
+ export function extractConfigOptions(
97
+ configOptions: acp.SessionConfigOption[] | null | undefined,
98
+ ): SessionConfigOptionData[] {
99
+ if (!configOptions) return [];
100
+ return configOptions.map((opt) => {
101
+ const base = {
102
+ id: opt.id,
103
+ name: opt.name,
104
+ description: opt.description ?? null,
105
+ category: opt.category ?? null,
106
+ };
107
+ if (opt.type === 'boolean') {
108
+ return {
109
+ ...base,
110
+ type: 'boolean' as const,
111
+ currentValue: String(opt.currentValue),
112
+ options: [],
113
+ };
114
+ }
115
+ const options = Array.isArray(opt.options)
116
+ ? (opt.options as Array<acp.SessionConfigSelectOption | acp.SessionConfigSelectGroup>).flatMap((o) =>
117
+ 'group' in o
118
+ ? o.options.map((inner) => ({ value: inner.value, name: `${o.name}/${inner.name}` }))
119
+ : [{ value: o.value, name: o.name }],
120
+ )
121
+ : [];
122
+ return {
123
+ ...base,
124
+ type: 'select' as const,
125
+ currentValue: opt.currentValue,
126
+ options,
127
+ };
128
+ });
129
+ }
130
+
131
+ function findConfigByCategory(
132
+ configOptions: SessionConfigOptionData[],
133
+ category: string,
134
+ ): SessionConfigOptionData | null {
135
+ return configOptions.find((o) => o.category === category) ?? null;
136
+ }
137
+
138
+ function extractConfigModes(
139
+ configOptions: SessionConfigOptionData[],
140
+ ): { modes: Array<{ id: string; name: string }>; currentModeId: string | null } | null {
141
+ const opt = findConfigByCategory(configOptions, 'mode');
142
+ if (!opt || opt.type !== 'select') return null;
143
+ return {
144
+ modes: opt.options.map((o) => ({ id: o.value, name: o.name ?? o.value })),
145
+ currentModeId: opt.currentValue,
146
+ };
147
+ }
148
+
149
+ function extractConfigModels(
150
+ configOptions: SessionConfigOptionData[],
151
+ ): {
152
+ availableModels: Array<{ id: string; name: string; provider?: string }>;
153
+ currentModelId: string | null;
154
+ currentModel: { id: string; name: string; provider?: string } | null;
155
+ } | null {
156
+ const opt = findConfigByCategory(configOptions, 'model');
157
+ if (!opt || opt.type !== 'select') return null;
158
+ const availableModels = opt.options.map((o) => ({ id: o.value, name: o.name ?? o.value }));
159
+ const currentModelId = opt.currentValue;
160
+ const current = availableModels.find((m) => m.id === currentModelId) ?? null;
161
+ return { availableModels, currentModelId, currentModel: current };
162
+ }
163
+
164
+ function toAuthMethods(authMethods: acp.AuthMethod[] | undefined): AcpAuthMethodData[] {
165
+ if (!authMethods || authMethods.length === 0) return [];
166
+ return authMethods.map((m) => {
167
+ if ('type' in m) {
168
+ const kind = m.type === 'env_var' ? 'env_var' : m.type === 'terminal' ? 'terminal' : 'agent';
169
+ return {
170
+ id: m.id,
171
+ kind,
172
+ description: (m as { description?: string | null }).description ?? null,
173
+ url: (m as { url?: string | null }).url ?? null,
174
+ };
175
+ }
176
+ // AuthMethodAgent 无 type 字段(SDK 1.3.0),是默认认证方式
177
+ return { id: m.id, kind: 'agent', description: null, url: null };
178
+ });
179
+ }
180
+
28
181
  export class AcpSessionBridge extends EventEmitter implements acp.Client {
29
182
  private spawner: AcpSpawner;
30
183
  private socket: Socket;
31
184
  private sessionId: string;
32
185
  private cwd: string;
186
+ private additionalDirectories: string[];
33
187
  private mcpServers: AcpBridgeOptions['mcpServers'];
188
+ private mcpEndpoint: AcpBridgeOptions['mcpEndpoint'];
34
189
  private connection: acp.ClientSideConnection | null = null;
35
190
  private acpSessionId: string | null = null;
36
191
  private activePrompt: Promise<acp.PromptResponse> | null = null;
37
192
  private inputQueue: QueuedInput[] = [];
38
193
  private textChunkBuffer = '';
194
+ private textChunkMessageId: string | null | undefined;
39
195
  private textChunkTimer: ReturnType<typeof setTimeout> | null = null;
40
196
  private initializing = false;
41
197
  private stopped = false;
@@ -43,16 +199,52 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
43
199
  resolve: (response: acp.RequestPermissionResponse) => void;
44
200
  timeout: NodeJS.Timeout;
45
201
  }>();
202
+ private pendingElicitations = new Map<string, {
203
+ resolve: (response: acp.CreateElicitationResponse) => void;
204
+ timeout: NodeJS.Timeout;
205
+ }>();
206
+
207
+ // Capability snapshot from initialize / session/new / resume
208
+ private configOptions: SessionConfigOptionData[] = [];
209
+ private promptCapabilities: AcpInitializedPayload['promptCapabilities'] = null;
210
+ private agentInfo: AcpInitializedPayload['agentInfo'] = null;
211
+ private authMethods: AcpAuthMethodData[] = [];
212
+ private supportsAdditionalDirectories = false;
213
+ private agentProtocolVersion: number | null = null;
214
+ private agentMcpCapabilities: { http?: boolean | null; sse?: boolean | null } | null = null;
46
215
 
47
216
  constructor(socket: Socket, options: AcpBridgeOptions, spawner?: AcpSpawner) {
48
217
  super();
49
218
  this.socket = socket;
50
219
  this.sessionId = options.sessionId;
51
220
  this.cwd = options.cwd;
221
+ this.additionalDirectories = options.additionalDirectories ?? [];
52
222
  this.mcpServers = options.mcpServers;
223
+ this.mcpEndpoint = options.mcpEndpoint;
53
224
  this.spawner = spawner ?? new AcpSpawner({ sessionId: options.sessionId, cwd: options.cwd });
54
225
  }
55
226
 
227
+ private resolveMcpServers(): acp.McpServer[] {
228
+ if (this.mcpEndpoint) {
229
+ return toMcpEndpointServers(this.mcpEndpoint);
230
+ }
231
+ return toMcpServers(this.mcpServers);
232
+ }
233
+
234
+ // Log the MCP configuration that is about to be handed to the agent via
235
+ // session/new (or session/resume), and warn when the agent did not
236
+ // advertise the required MCP transport capability in initialize.
237
+ private logMcpServersForSession(method: 'session/new' | 'session/resume', servers: acp.McpServer[]): void {
238
+ console.log(`[ACP bridge] session ${this.sessionId}: passing MCP servers via ${method}: ${describeMcpServersForLog(servers)}`);
239
+ const httpServers = servers.filter((s) => 'url' in s && (s as { type?: string }).type === 'http');
240
+ if (httpServers.length > 0 && this.agentMcpCapabilities && this.agentMcpCapabilities.http === false) {
241
+ console.warn(
242
+ `[ACP bridge] session ${this.sessionId}: agent did not advertise mcpCapabilities.http ` +
243
+ `but ${httpServers.length} http MCP server(s) are being passed: ${httpServers.map((s) => s.name).join(', ')}`,
244
+ );
245
+ }
246
+ }
247
+
56
248
  async start(): Promise<void> {
57
249
  if (this.initializing) return;
58
250
  this.initializing = true;
@@ -60,23 +252,17 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
60
252
  this.spawner.start();
61
253
  try {
62
254
  await this.initConnection();
255
+ const mcpServers = this.resolveMcpServers();
256
+ this.logMcpServersForSession('session/new', mcpServers);
63
257
  const result = await this.connection!.newSession({
64
258
  cwd: this.cwd,
65
- mcpServers: toMcpServers(this.mcpServers),
259
+ mcpServers,
260
+ additionalDirectories: this.additionalDirectories.length > 0 ? this.additionalDirectories : undefined,
66
261
  });
67
262
  this.acpSessionId = result.sessionId;
68
263
  this.initializing = false;
69
264
 
70
- this.emit('acp:initialized', {
71
- sessionId: this.sessionId,
72
- acpSessionId: this.acpSessionId,
73
- modes: this.extractModes(result.modes),
74
- currentModeId: result.modes?.currentModeId ?? null,
75
- currentModel: this.extractCurrentModel(result.models),
76
- availableModels: this.extractAvailableModels(result.models),
77
- currentModelId: result.models?.currentModelId ?? null,
78
- });
79
-
265
+ this.emit('acp:initialized', this.buildInitializedPayload(result));
80
266
  this.flushInputQueue();
81
267
  } catch (error) {
82
268
  this.initializing = false;
@@ -91,42 +277,31 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
91
277
  this.spawner.start();
92
278
  try {
93
279
  await this.initConnection();
280
+ const mcpServers = this.resolveMcpServers();
281
+ this.logMcpServersForSession('session/resume', mcpServers);
94
282
  const result = await this.connection!.resumeSession({
95
283
  sessionId: externalSessionId,
96
284
  cwd: this.cwd,
285
+ mcpServers,
286
+ additionalDirectories: this.additionalDirectories.length > 0 ? this.additionalDirectories : undefined,
97
287
  });
98
288
  this.acpSessionId = externalSessionId;
99
289
  this.initializing = false;
100
290
 
101
- this.emit('acp:initialized', {
102
- sessionId: this.sessionId,
103
- acpSessionId: this.acpSessionId,
104
- modes: this.extractModes(result.modes),
105
- currentModeId: result.modes?.currentModeId ?? null,
106
- currentModel: this.extractCurrentModel(result.models),
107
- availableModels: this.extractAvailableModels(result.models),
108
- currentModelId: result.models?.currentModelId ?? null,
109
- });
291
+ this.emit('acp:initialized', this.buildInitializedPayload(result));
110
292
  } catch (error) {
111
293
  // resumeSession may fail if agent doesn't support session.resume
112
294
  // fallback to newSession
113
295
  try {
114
296
  const result = await this.connection!.newSession({
115
297
  cwd: this.cwd,
116
- mcpServers: toMcpServers(this.mcpServers),
298
+ mcpServers: this.resolveMcpServers(),
299
+ additionalDirectories: this.additionalDirectories.length > 0 ? this.additionalDirectories : undefined,
117
300
  });
118
301
  this.acpSessionId = result.sessionId;
119
302
  this.initializing = false;
120
303
 
121
- this.emit('acp:initialized', {
122
- sessionId: this.sessionId,
123
- acpSessionId: this.acpSessionId,
124
- modes: this.extractModes(result.modes),
125
- currentModeId: result.modes?.currentModeId ?? null,
126
- currentModel: this.extractCurrentModel(result.models),
127
- availableModels: this.extractAvailableModels(result.models),
128
- currentModelId: result.models?.currentModelId ?? null,
129
- });
304
+ this.emit('acp:initialized', this.buildInitializedPayload(result));
130
305
  } catch (fallbackError) {
131
306
  this.initializing = false;
132
307
  this.handleError(fallbackError);
@@ -155,6 +330,16 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
155
330
 
156
331
  async setMode(modeId: string): Promise<void> {
157
332
  if (!this.connection || !this.acpSessionId) return;
333
+ const modeConfig = findConfigByCategory(this.configOptions, 'mode');
334
+ if (modeConfig) {
335
+ // 配置项通道:Agent 提供 configOptions 时以配置项为准
336
+ await this.connection.setSessionConfigOption({
337
+ sessionId: this.acpSessionId,
338
+ configId: modeConfig.id,
339
+ value: modeId,
340
+ });
341
+ return;
342
+ }
158
343
  await this.connection.setSessionMode({
159
344
  sessionId: this.acpSessionId,
160
345
  modeId,
@@ -163,13 +348,70 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
163
348
 
164
349
  async setModel(modelId: string): Promise<void> {
165
350
  if (!this.connection || !this.acpSessionId) return;
166
- await this.connection.unstable_setSessionModel({
351
+ const modelConfig = findConfigByCategory(this.configOptions, 'model');
352
+ if (modelConfig) {
353
+ await this.connection.setSessionConfigOption({
354
+ sessionId: this.acpSessionId,
355
+ configId: modelConfig.id,
356
+ value: modelId,
357
+ });
358
+ return;
359
+ }
360
+ // SDK 1.x 已移除 unstable_setSessionModel:无 configOptions 的旧 Agent 无法走配置项通道。
361
+ // 模型切换暂不支持,由上层提示(不保留 unstable 调用)。
362
+ throw new Error('Model switching is not supported: agent does not expose a model config option');
363
+ }
364
+
365
+ async setConfigOption(configId: string, value: string | boolean): Promise<acp.SetSessionConfigOptionResponse> {
366
+ if (!this.connection || !this.acpSessionId) {
367
+ throw new Error('ACP bridge is not initialized');
368
+ }
369
+ const response = await this.connection.setSessionConfigOption({
167
370
  sessionId: this.acpSessionId,
168
- modelId,
371
+ configId,
372
+ ...(typeof value === 'boolean' ? { value, type: 'boolean' as const } : { value }),
169
373
  });
374
+ if (response.configOptions) {
375
+ // SDK 1.3.0:set_config_option 响应携带全量快照,与 config_option_update 通知同源
376
+ this.configOptions = extractConfigOptions(response.configOptions);
377
+ this.socket.emit('acp:config-options', {
378
+ sessionId: this.sessionId,
379
+ configOptions: this.configOptions,
380
+ });
381
+ }
382
+ return response;
383
+ }
384
+
385
+ async authenticate(methodId: string): Promise<void> {
386
+ if (!this.connection) {
387
+ throw new Error('ACP bridge is not initialized');
388
+ }
389
+ await this.connection.authenticate({ methodId });
390
+ }
391
+
392
+ async logout(): Promise<void> {
393
+ if (!this.connection) {
394
+ throw new Error('ACP bridge is not initialized');
395
+ }
396
+ await this.connection.logout({});
397
+ }
398
+
399
+ async deleteSession(): Promise<void> {
400
+ if (!this.connection || !this.acpSessionId) {
401
+ throw new Error('ACP bridge is not initialized');
402
+ }
403
+ await this.connection.deleteSession({ sessionId: this.acpSessionId });
170
404
  }
171
405
 
172
- sendInput(message: { type: string; content: string }): void {
406
+ getConfigOptions(): SessionConfigOptionData[] {
407
+ return this.configOptions;
408
+ }
409
+
410
+ getSupportsAdditionalDirectories(): boolean {
411
+ return this.supportsAdditionalDirectories;
412
+ }
413
+
414
+ sendInput(message: { type: string; content: string; attachments?: Array<Record<string, unknown>> }): void {
173
415
  if (this.activePrompt) {
174
416
  this.inputQueue.push(message);
175
417
  return;
@@ -177,12 +419,18 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
177
419
  this.processPrompt(message);
178
420
  }
179
421
 
180
- private async processPrompt(message: QueuedInput): Promise<void> {
422
+ private async processPrompt(message: QueuedInput & { attachments?: Array<Record<string, unknown>> }): Promise<void> {
181
423
  if (!this.connection || !this.acpSessionId || this.stopped) return;
182
424
 
425
+ const prompt: acp.ContentBlock[] = [];
426
+ for (const attachment of message.attachments ?? []) {
427
+ prompt.push(attachment as acp.ContentBlock);
428
+ }
429
+ prompt.push({ type: 'text' as const, text: message.content });
430
+
183
431
  this.activePrompt = this.connection.prompt({
184
432
  sessionId: this.acpSessionId,
185
- prompt: [{ type: 'text' as const, text: message.content }],
433
+ prompt,
186
434
  });
187
435
 
188
436
  this.socket.emit('acp:prompt:start', { sessionId: this.sessionId });
@@ -212,44 +460,113 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
212
460
  const output = this.spawner.stdout;
213
461
  const stream = acp.ndJsonStream(input, output);
214
462
  this.connection = new acp.ClientSideConnection(() => this, stream);
215
- await this.connection.initialize({
463
+ const result = await this.connection.initialize({
216
464
  protocolVersion: ACP_PROTOCOL_VERSION,
217
- clientCapabilities: {},
465
+ clientCapabilities: {
466
+ session: {
467
+ configOptions: { boolean: {} },
468
+ },
469
+ elicitation: { form: {}, url: {} },
470
+ },
218
471
  clientInfo: {
219
472
  name: 'agent-gear',
220
- version: '1.0.0',
473
+ version: '1.1.5',
221
474
  },
222
475
  });
223
- }
224
476
 
225
- private flushInputQueue(): void {
226
- while (this.inputQueue.length > 0) {
227
- this.processPrompt(this.inputQueue.shift()!);
477
+ this.agentProtocolVersion = result.protocolVersion ?? null;
478
+ if (result.protocolVersion != null && result.protocolVersion !== ACP_PROTOCOL_VERSION) {
479
+ // 版本不兼容:不断连,由上层提示用户
480
+ this.socket.emit('acp:protocol-version-mismatch', {
481
+ sessionId: this.sessionId,
482
+ expectedVersion: ACP_PROTOCOL_VERSION,
483
+ agentVersion: result.protocolVersion,
484
+ });
228
485
  }
229
- }
230
486
 
231
- private extractModes(modes: acp.SessionModeState | null | undefined): Array<{ id: string; name: string; description?: string }> {
232
- if (!modes?.availableModes) return [];
233
- return modes.availableModes.map((m) => ({
234
- id: m.id,
235
- name: m.name,
236
- description: m.description ?? undefined,
237
- }));
487
+ this.promptCapabilities = result.agentCapabilities?.promptCapabilities
488
+ ? {
489
+ image: result.agentCapabilities.promptCapabilities.image ?? false,
490
+ audio: result.agentCapabilities.promptCapabilities.audio ?? false,
491
+ embeddedContext: result.agentCapabilities.promptCapabilities.embeddedContext ?? false,
492
+ }
493
+ : null;
494
+ this.agentInfo = result.agentInfo
495
+ ? { name: result.agentInfo.name, version: result.agentInfo.version ?? null }
496
+ : null;
497
+ this.authMethods = toAuthMethods(result.authMethods);
498
+ this.agentMcpCapabilities = result.agentCapabilities?.mcpCapabilities ?? null;
499
+ console.log(
500
+ `[ACP bridge] session ${this.sessionId}: agent capabilities: ` +
501
+ `protocolVersion=${result.protocolVersion ?? 'n/a'} mcp=${JSON.stringify(this.agentMcpCapabilities)} ` +
502
+ `agent=${this.agentInfo ? `${this.agentInfo.name}@${this.agentInfo.version ?? 'n/a'}` : 'n/a'}`,
503
+ );
504
+ this.supportsAdditionalDirectories =
505
+ result.agentCapabilities?.sessionCapabilities?.additionalDirectories !== undefined &&
506
+ result.agentCapabilities?.sessionCapabilities?.additionalDirectories !== null;
507
+
508
+ if (this.authMethods.length > 0) {
509
+ this.socket.emit('acp:auth:required', {
510
+ sessionId: this.sessionId,
511
+ authMethods: this.authMethods,
512
+ });
513
+ }
238
514
  }
239
515
 
240
- private extractCurrentModel(models: acp.SessionModelState | null | undefined): { id: string; name: string; provider?: string } | null {
241
- if (!models?.availableModels || !models.currentModelId) return null;
242
- const found = models.availableModels.find((m) => m.modelId === models.currentModelId);
243
- if (!found) return null;
244
- return { id: found.modelId, name: found.name };
516
+ private buildInitializedPayload(
517
+ result: acp.NewSessionResponse | acp.ResumeSessionResponse,
518
+ ): AcpInitializedPayload {
519
+ const configOptions = extractConfigOptions(result.configOptions);
520
+ this.configOptions = configOptions.length > 0 ? configOptions : this.configOptions;
521
+
522
+ // 协议要求:响应含 configOptions 时以其为准(忽略 modes)
523
+ const configModes = extractConfigModes(this.configOptions);
524
+ const configModels = extractConfigModels(this.configOptions);
525
+
526
+ let modes: Array<{ id: string; name: string; description?: string }> = [];
527
+ let currentModeId: string | null = null;
528
+ if (configModes) {
529
+ modes = configModes.modes;
530
+ currentModeId = configModes.currentModeId;
531
+ } else if (result.modes?.availableModes) {
532
+ modes = result.modes.availableModes.map((m) => ({
533
+ id: m.id,
534
+ name: m.name,
535
+ description: m.description ?? undefined,
536
+ }));
537
+ currentModeId = result.modes.currentModeId ?? null;
538
+ }
539
+
540
+ let currentModel: AcpInitializedPayload['currentModel'] = null;
541
+ let availableModels: Array<{ id: string; name: string; provider?: string }> = [];
542
+ let currentModelId: string | null = null;
543
+ if (configModels) {
544
+ availableModels = configModels.availableModels;
545
+ currentModelId = configModels.currentModelId;
546
+ currentModel = configModels.currentModel;
547
+ }
548
+
549
+ return {
550
+ sessionId: this.sessionId,
551
+ acpSessionId: this.acpSessionId,
552
+ modes,
553
+ currentModeId,
554
+ currentModel,
555
+ availableModels,
556
+ currentModelId,
557
+ configOptions: this.configOptions,
558
+ promptCapabilities: this.promptCapabilities,
559
+ agentInfo: this.agentInfo,
560
+ authMethods: this.authMethods,
561
+ supportsAdditionalDirectories: this.supportsAdditionalDirectories,
562
+ protocolVersion: this.agentProtocolVersion,
563
+ };
245
564
  }
246
565
 
247
- private extractAvailableModels(models: acp.SessionModelState | null | undefined): Array<{ id: string; name: string; provider?: string }> {
248
- if (!models?.availableModels) return [];
249
- return models.availableModels.map((m) => ({
250
- id: m.modelId,
251
- name: m.name,
252
- }));
566
+ private flushInputQueue(): void {
567
+ while (this.inputQueue.length > 0) {
568
+ this.processPrompt(this.inputQueue.shift()!);
569
+ }
253
570
  }
254
571
 
255
572
  // ── acp.Client implementation ──
@@ -259,8 +576,22 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
259
576
  switch (update.sessionUpdate) {
260
577
  case 'agent_message_chunk':
261
578
  if (update.content.type === 'text') {
579
+ if (this.textChunkBuffer && this.textChunkMessageId !== update.messageId) {
580
+ // messageId 边界:不同消息立即 flush
581
+ this.flushTextBuffer();
582
+ }
262
583
  this.textChunkBuffer += update.content.text;
263
- this.scheduleTextFlush();
584
+ this.textChunkMessageId = update.messageId;
585
+ if (update.messageId) {
586
+ // 有 messageId 时跳过 50ms debounce 猜测,按 ID 边界 flush
587
+ this.flushTextBuffer();
588
+ } else {
589
+ this.scheduleTextFlush();
590
+ }
591
+ } else {
592
+ // 非 text chunk(image/audio/resource)原样透传
593
+ this.flushTextBuffer();
594
+ this.sendUpdate(update);
264
595
  }
265
596
  break;
266
597
  case 'tool_call':
@@ -270,6 +601,14 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
270
601
  this.flushTextBuffer();
271
602
  this.sendUpdate(update);
272
603
  break;
604
+ case 'config_option_update':
605
+ this.flushTextBuffer();
606
+ this.configOptions = extractConfigOptions(update.configOptions);
607
+ this.socket.emit('acp:config-options', {
608
+ sessionId: this.sessionId,
609
+ configOptions: this.configOptions,
610
+ });
611
+ break;
273
612
  case 'user_message_chunk':
274
613
  break;
275
614
  default:
@@ -315,6 +654,78 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
315
654
  pending.resolve({ outcome });
316
655
  }
317
656
 
657
+ async unstable_createElicitation(
658
+ params: acp.CreateElicitationRequest,
659
+ ): Promise<acp.CreateElicitationResponse> {
660
+ // custom/未知模式不支持:立即取消,避免挂起 Agent
661
+ if (!acp.CreateElicitationRequest.isForm(params) && !acp.CreateElicitationRequest.isUrl(params)) {
662
+ return { action: 'cancel' };
663
+ }
664
+
665
+ const requestId = `elic_${Date.now()}_${Math.random().toString(36).slice(2)}`;
666
+ const toolCallId = 'toolCallId' in params ? params.toolCallId : undefined;
667
+
668
+ return new Promise((resolve) => {
669
+ const timeout = setTimeout(() => {
670
+ this.pendingElicitations.delete(requestId);
671
+ resolve({ action: 'cancel' });
672
+ }, 65_000);
673
+
674
+ this.pendingElicitations.set(requestId, { resolve, timeout });
675
+
676
+ if (acp.CreateElicitationRequest.isForm(params)) {
677
+ this.socket.emit('acp:elicitation', {
678
+ requestId,
679
+ sessionId: this.sessionId,
680
+ mode: 'form',
681
+ message: params.message,
682
+ ...(toolCallId != null ? { toolCallId } : {}),
683
+ requestedSchema: params.requestedSchema,
684
+ });
685
+ } else {
686
+ this.socket.emit('acp:elicitation', {
687
+ requestId,
688
+ sessionId: this.sessionId,
689
+ mode: 'url',
690
+ message: params.message,
691
+ ...(toolCallId != null ? { toolCallId } : {}),
692
+ elicitationId: params.elicitationId,
693
+ url: params.url,
694
+ });
695
+ }
696
+
697
+ this.emit('elicitation:sent', requestId);
698
+ });
699
+ }
700
+
701
+ handleElicitationResponse(
702
+ requestId: string,
703
+ action: 'accept' | 'decline' | 'cancel',
704
+ content?: Record<string, unknown>,
705
+ ): void {
706
+ const pending = this.pendingElicitations.get(requestId);
707
+ if (!pending) {
708
+ console.warn(`Elicitation request ${requestId} not found`);
709
+ return;
710
+ }
711
+
712
+ clearTimeout(pending.timeout);
713
+ this.pendingElicitations.delete(requestId);
714
+ pending.resolve({
715
+ action,
716
+ ...(action === 'accept' && content
717
+ ? { content: content as Record<string, acp.ElicitationContentValue> }
718
+ : {}),
719
+ });
720
+ }
721
+
722
+ async unstable_completeElicitation(params: acp.CompleteElicitationNotification): Promise<void> {
723
+ this.socket.emit('acp:elicitation:complete', {
724
+ sessionId: this.sessionId,
725
+ elicitationId: params.elicitationId,
726
+ });
727
+ }
728
+
318
729
  // ── text buffering ──
319
730
 
320
731
  private scheduleTextFlush(): void {
@@ -331,8 +742,10 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
331
742
  this.sendUpdate({
332
743
  sessionUpdate: 'agent_message_chunk' as const,
333
744
  content: { type: 'text' as const, text: this.textChunkBuffer },
745
+ ...(this.textChunkMessageId ? { messageId: this.textChunkMessageId } : {}),
334
746
  } as acp.SessionUpdate);
335
747
  this.textChunkBuffer = '';
748
+ this.textChunkMessageId = undefined;
336
749
  }
337
750
  }
338
751
 
@@ -346,14 +759,17 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
346
759
  }
347
760
 
348
761
  private emitStopped(reason: string, exitCode?: number): void {
762
+ console.log(`[ACP bridge] session ${this.sessionId} stopped: reason=${reason} exitCode=${exitCode ?? 'n/a'}`);
349
763
  this.stopped = true;
350
764
  this.socket.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
351
765
  this.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
352
766
  }
353
767
 
354
768
  private handleError(error: unknown): void {
355
- this.stopped = true;
356
769
  const msg = error instanceof Error ? error.message : String(error);
770
+ const stack = error instanceof Error ? error.stack : undefined;
771
+ console.error(`[ACP bridge] session ${this.sessionId} failed: ${msg}${stack ? `\n${stack}` : ''}`);
772
+ this.stopped = true;
357
773
  this.socket.emit('acp:stopped', {
358
774
  sessionId: this.sessionId,
359
775
  reason: 'error',