@yeaft/webchat-agent 0.1.860 → 0.1.864

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.
@@ -5,20 +5,34 @@ import { homedir } from 'os';
5
5
  import { join } from 'path';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
7
  import ctx from '../context.js';
8
+ import { AcpClient } from './acp-client.js';
9
+ import { COPILOT_MODELS, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
8
10
 
9
11
  export const name = 'copilot';
10
12
 
13
+ export const capabilities = Object.freeze({
14
+ compact: false, // TODO: probe ACP for /compact equivalent
15
+ clear: true, // session/new gives us a fresh transcript
16
+ expert: false, // Copilot has /fleet, different model
17
+ mcp: true, // ACP advertises mcpCapabilities at init
18
+ subagents: false,
19
+ attachments: true, // ACP promptCapabilities.image + embeddedContext
20
+ askUser: true, // session/request_permission round-trip
21
+ modelPicker: true,
22
+ });
23
+
11
24
  const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
12
- // Opt-in only: --allow-all-tools is a destructive footgun by default in a
13
- // multi-tenant agent. Set COPILOT_YOLO=1 (and only if you know what you're
14
- // doing) to skip Copilot's tool prompts.
25
+ // YOLO is now opt-in only; per-conv allowAllTools is the normal channel.
15
26
  const YOLO = process.env.COPILOT_YOLO === '1';
27
+ const ACP_PROTOCOL_VERSION = 1;
16
28
 
17
29
  /**
18
- * Start (or resume) a Copilot session.
19
- * Copilot's `-p` mode is one-shot per turn, so "start" just prepares state.
20
- * Each sendInput() spawns one `copilot -p ...` child with the same
21
- * --session-id for continuity.
30
+ * Start (or resume) a Copilot ACP session.
31
+ *
32
+ * Spawns one persistent `copilot --acp` child per conversation and runs the
33
+ * ACP handshake: initialize → session/new (or session/load). The child stays
34
+ * alive for the conversation's lifetime; each turn is a `session/prompt`
35
+ * JSONRPC request, not a fresh process.
22
36
  */
23
37
  export async function start(opts) {
24
38
  const conversationId = opts.conversationId;
@@ -27,83 +41,82 @@ export async function start(opts) {
27
41
  if (prior?.copilotChild) {
28
42
  try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
29
43
  }
44
+ if (prior?.acpClient) {
45
+ try { prior.acpClient.close('replaced'); } catch { /* noop */ }
46
+ }
30
47
 
31
- const sessionId = opts.resumeSessionId || randomUUID();
32
48
  const providerOptions = opts.providerOptions || prior?.providerOptions || {};
49
+ const model = providerOptions.model || DEFAULT_COPILOT_MODEL;
50
+ const allowAllTools = YOLO || !!providerOptions.allowAllTools;
51
+
33
52
  const state = {
34
53
  providerName: name,
35
- conversationId: opts.conversationId,
54
+ conversationId,
36
55
  query: null,
37
56
  inputStream: null,
38
57
  workDir: opts.workDir,
39
- claudeSessionId: sessionId,
40
- sessionId,
58
+ claudeSessionId: opts.resumeSessionId || null, // set after session/new or session/load
59
+ sessionId: opts.resumeSessionId || null,
41
60
  createdAt: prior?.createdAt || Date.now(),
42
61
  abortController: null,
43
62
  tools: [],
44
63
  slashCommands: [],
45
- model: providerOptions.model || 'copilot',
64
+ model,
46
65
  userId: opts.userId,
47
66
  username: opts.username,
48
67
  disallowedTools: prior?.disallowedTools || null,
49
68
  copilotChild: null,
69
+ acpClient: null,
50
70
  providerOptions,
71
+ allowAllTools,
72
+ capabilities,
73
+ initialized: false,
74
+ pendingPermissions: new Map(), // requestId → { resolve, reject } for ask-user round-trip
51
75
  usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
52
76
  };
53
77
  ctx.conversations.set(conversationId, state);
54
- return state;
55
- }
56
-
57
- export async function sendInput(state, prompt, opts = {}) {
58
- const conversationId = opts.conversationId || state.conversationId;
59
- if (!conversationId) throw new Error('copilot: conversationId required');
60
- if (!state.sessionId) state.sessionId = randomUUID();
61
-
62
- // Abort any in-flight turn.
63
- if (state.copilotChild) {
64
- try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
65
- state.copilotChild = null;
66
- }
67
- const abortController = new AbortController();
68
- state.abortController = abortController;
69
- state.turnActive = true;
70
- state.turnResultReceived = false;
71
78
 
72
- const args = ['-p', prompt, '--output-format', 'json', '-C', state.workDir, '--session-id', state.sessionId];
73
- const po = { ...(state.providerOptions || {}), ...(opts.providerOptions || {}) };
74
- if (po.model) args.push('--model', String(po.model));
75
- if (po.effort) args.push('--effort', String(po.effort));
76
- if (Array.isArray(po.addDirs)) {
77
- for (const d of po.addDirs) args.push('--add-dir', String(d));
78
- }
79
- // YOLO env var still wins as a global override; per-conv allowAllTools
80
- // lets the user opt in from the UI without setting an env var.
81
- if (YOLO || po.allowAllTools) args.push('--allow-all-tools');
82
-
83
- let child;
79
+ // Best-effort start; failures emit a result envelope and leave state in place
80
+ // so the next sendInput retries.
84
81
  try {
85
- child = spawn(COPILOT_BIN, args, {
86
- cwd: state.workDir,
87
- env: process.env,
88
- stdio: ['ignore', 'pipe', 'pipe'],
89
- });
82
+ await _bootAcp(state, opts.resumeSessionId || null, model);
90
83
  } catch (err) {
91
84
  sendOutput(conversationId, {
92
85
  type: 'result',
93
86
  subtype: 'error',
94
87
  session_id: state.sessionId,
95
88
  is_error: true,
96
- error: `copilot spawn failed: ${err?.message || err}`,
89
+ error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
97
90
  });
98
- state.turnActive = false;
99
- ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
100
- return;
101
91
  }
92
+ return state;
93
+ }
94
+
95
+ async function _bootAcp(state, resumeSessionId, model) {
96
+ const args = ['--acp'];
97
+ // ACP doesn't yet expose a per-session model param in its public schema, so
98
+ // pass --model at spawn for the lifetime of this child.
99
+ if (model) args.push('--model', String(model));
100
+ if (Array.isArray(state.providerOptions?.addDirs)) {
101
+ for (const d of state.providerOptions.addDirs) args.push('--add-dir', String(d));
102
+ }
103
+
104
+ const child = spawn(COPILOT_BIN, args, {
105
+ cwd: state.workDir,
106
+ env: process.env,
107
+ stdio: ['pipe', 'pipe', 'pipe'],
108
+ });
102
109
  state.copilotChild = child;
103
110
 
104
- // Pre-register error handler so async ENOENT from spawn is never unhandled.
111
+ let stderrBuf = '';
112
+ const STDERR_CAP = 64 * 1024;
113
+ child.stderr.on('data', (chunk) => {
114
+ if (stderrBuf.length < STDERR_CAP) {
115
+ stderrBuf += chunk.toString('utf8').slice(0, STDERR_CAP - stderrBuf.length);
116
+ }
117
+ });
105
118
  child.on('error', (err) => {
106
- sendOutput(conversationId, {
119
+ sendOutput(state.conversationId, {
107
120
  type: 'result',
108
121
  subtype: 'error',
109
122
  session_id: state.sessionId,
@@ -111,69 +124,236 @@ export async function sendInput(state, prompt, opts = {}) {
111
124
  error: `copilot process error: ${err?.message || err}`,
112
125
  });
113
126
  });
127
+ child.on('close', (code) => {
128
+ if (state.turnActive) {
129
+ const tail = stderrBuf.trim().slice(-2000);
130
+ sendOutput(state.conversationId, {
131
+ type: 'result',
132
+ subtype: 'error',
133
+ session_id: state.sessionId,
134
+ is_error: true,
135
+ error: tail || `copilot exited mid-turn (code ${code})`,
136
+ });
137
+ ctx.sendToServer({
138
+ type: 'turn_completed',
139
+ conversationId: state.conversationId,
140
+ claudeSessionId: state.sessionId,
141
+ workDir: state.workDir,
142
+ });
143
+ state.turnActive = false;
144
+ }
145
+ // Drain any in-flight permission prompts so the frontend dialog unwedges
146
+ // and the Promise GC roots release.
147
+ _drainPendingPermissions(state, 'child closed');
148
+ state.copilotChild = null;
149
+ state.acpClient = null;
150
+ state.initialized = false;
151
+ });
114
152
 
115
- let killTimer = null;
116
- abortController.signal.addEventListener('abort', () => {
117
- try { child.kill('SIGTERM'); } catch { /* noop */ }
118
- // Escalate to SIGKILL if the child ignores SIGTERM, so the awaited
119
- // close promise resolves and the next turn isn't blocked forever.
120
- killTimer = setTimeout(() => {
121
- try { child.kill('SIGKILL'); } catch { /* noop */ }
122
- }, 5000);
153
+ const client = new AcpClient({
154
+ stdin: child.stdin,
155
+ stdout: child.stdout,
156
+ onNotification: (method, params) => _handleAcpNotification(state, method, params),
157
+ onRequest: (method, params) => _handleAcpRequest(state, method, params),
158
+ onError: (err) => {
159
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] acp transport:', err?.message || err);
160
+ },
123
161
  });
162
+ state.acpClient = client;
124
163
 
125
- let stderrBuf = '';
126
- const STDERR_CAP = 64 * 1024;
127
- let sawResult = false;
164
+ // 1) initialize
165
+ const initResp = await client.request('initialize', {
166
+ protocolVersion: ACP_PROTOCOL_VERSION,
167
+ clientCapabilities: {},
168
+ });
169
+ state.acpCapabilities = initResp?.agentCapabilities || {};
170
+ state.initialized = true;
128
171
 
129
- const parser = createNdjsonParser((evt) => {
130
- const envelopes = translateCopilotEvent(evt, state);
131
- for (const e of envelopes) {
132
- sendOutput(conversationId, e);
133
- if (e?.type === 'result') sawResult = true;
172
+ // 2) session/new or session/load
173
+ if (resumeSessionId && state.acpCapabilities.loadSession) {
174
+ const r = await client.request('session/load', {
175
+ sessionId: resumeSessionId,
176
+ cwd: state.workDir,
177
+ mcpServers: [],
178
+ });
179
+ state.sessionId = resumeSessionId;
180
+ state.claudeSessionId = resumeSessionId;
181
+ if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
182
+ } else {
183
+ if (resumeSessionId && !state.acpCapabilities.loadSession) {
184
+ // Surface the downgrade — silently handing back a fresh session would
185
+ // confuse a user who asked to resume.
186
+ sendOutput(state.conversationId, {
187
+ type: 'system',
188
+ subtype: 'info',
189
+ message: 'Copilot CLI does not advertise loadSession capability — starting a new session instead of resuming.',
190
+ });
134
191
  }
192
+ const r = await client.request('session/new', {
193
+ cwd: state.workDir,
194
+ mcpServers: [],
195
+ });
196
+ state.sessionId = r?.sessionId || randomUUID();
197
+ state.claudeSessionId = state.sessionId;
198
+ if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
199
+ }
200
+
201
+ // 3) Emit a system_init envelope so the UI populates tools / model panels.
202
+ // Copilot's built-in toolset is not enumerated over ACP today; advertise the
203
+ // well-known core set so the panel isn't empty.
204
+ state.tools = _knownCopilotTools();
205
+ sendOutput(state.conversationId, {
206
+ type: 'system',
207
+ subtype: 'init',
208
+ session_id: state.sessionId,
209
+ model: state.model,
210
+ tools: state.tools,
211
+ mcp_servers: [],
212
+ permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
135
213
  });
214
+ }
136
215
 
137
- child.stdout.on('data', (chunk) => parser.push(chunk));
138
- child.stderr.on('data', (chunk) => {
139
- if (stderrBuf.length < STDERR_CAP) {
140
- stderrBuf += chunk.toString('utf8').slice(0, STDERR_CAP - stderrBuf.length);
216
+ export async function sendInput(state, prompt, opts = {}) {
217
+ const conversationId = opts.conversationId || state.conversationId;
218
+ if (!conversationId) throw new Error('copilot: conversationId required');
219
+
220
+ // Ensure ACP child + session are up; reboot if a prior crash dropped them.
221
+ if (!state.initialized || !state.acpClient) {
222
+ try {
223
+ await _bootAcp(state, state.sessionId || null, state.model);
224
+ } catch (err) {
225
+ sendOutput(conversationId, {
226
+ type: 'result',
227
+ subtype: 'error',
228
+ session_id: state.sessionId,
229
+ is_error: true,
230
+ error: `copilot ACP reinit failed: ${err?.message || err}`,
231
+ });
232
+ ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
233
+ return;
141
234
  }
142
- });
235
+ }
143
236
 
144
- await new Promise((resolve) => {
145
- child.on('close', (code) => {
146
- if (killTimer) clearTimeout(killTimer);
147
- parser.flush();
148
- if (!sawResult) {
149
- const ok = code === 0;
150
- sendOutput(conversationId, {
151
- type: 'result',
152
- subtype: ok ? 'success' : 'error',
153
- session_id: state.sessionId,
154
- is_error: !ok,
155
- error: ok ? undefined : (stderrBuf.trim().slice(0, 2000) || `copilot exited with code ${code}`),
156
- });
237
+ // Per-turn provider option overrides (e.g. updated model). If the model
238
+ // changed we don't restart the child; ACP doesn't expose model switch yet,
239
+ // so we leave a warning rather than silently drop the request.
240
+ const po = { ...(state.providerOptions || {}), ...(opts.providerOptions || {}) };
241
+ if (po.model && po.model !== state.model) {
242
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] mid-conversation model switch not supported; ignoring');
243
+ }
244
+
245
+ const abortController = new AbortController();
246
+ state.abortController = abortController;
247
+ state.turnActive = true;
248
+ state.turnResultReceived = false;
249
+
250
+ // Build prompt content blocks. ACP ContentBlock variants: text, image,
251
+ // audio, resource, resource_link. Web attachments arrive on `opts.attachments`
252
+ // (existing wire shape: [{type:'image', data, mimeType} | {type:'text', text}]).
253
+ const promptBlocks = [{ type: 'text', text: String(prompt ?? '') }];
254
+ if (Array.isArray(opts.attachments)) {
255
+ for (const a of opts.attachments) {
256
+ if (!a) continue;
257
+ if (a.type === 'image' && a.data) {
258
+ promptBlocks.push({ type: 'image', data: a.data, mimeType: a.mimeType || 'image/png' });
259
+ } else if (a.type === 'text' && a.text) {
260
+ promptBlocks.push({ type: 'text', text: String(a.text) });
261
+ } else if (typeof a === 'string') {
262
+ promptBlocks.push({ type: 'text', text: a });
157
263
  }
158
- state.copilotChild = null;
159
- state.turnActive = false;
160
- ctx.sendToServer({
161
- type: 'turn_completed',
162
- conversationId,
163
- claudeSessionId: state.sessionId,
164
- workDir: state.workDir,
165
- });
166
- resolve();
167
- });
264
+ }
265
+ }
266
+
267
+ abortController.signal.addEventListener('abort', () => {
268
+ if (state.acpClient && state.sessionId) {
269
+ try { state.acpClient.notify('session/cancel', { sessionId: state.sessionId }); }
270
+ catch { /* noop */ }
271
+ }
168
272
  });
273
+
274
+ try {
275
+ const resp = await state.acpClient.request('session/prompt', {
276
+ sessionId: state.sessionId,
277
+ prompt: promptBlocks,
278
+ });
279
+ const stopReason = resp?.stopReason || 'end_turn';
280
+ const isErr = stopReason === 'refusal' || stopReason === 'error';
281
+ sendOutput(conversationId, {
282
+ type: 'result',
283
+ subtype: isErr ? 'error' : 'success',
284
+ session_id: state.sessionId,
285
+ stop_reason: stopReason,
286
+ is_error: isErr,
287
+ error: isErr ? `copilot stop_reason=${stopReason}` : undefined,
288
+ });
289
+ } catch (err) {
290
+ sendOutput(conversationId, {
291
+ type: 'result',
292
+ subtype: 'error',
293
+ session_id: state.sessionId,
294
+ is_error: true,
295
+ error: err?.message || String(err),
296
+ });
297
+ } finally {
298
+ state.turnActive = false;
299
+ ctx.sendToServer({
300
+ type: 'turn_completed',
301
+ conversationId,
302
+ claudeSessionId: state.sessionId,
303
+ workDir: state.workDir,
304
+ });
305
+ }
169
306
  }
170
307
 
171
308
  export function abort(state) {
172
309
  if (state?.abortController) {
173
310
  try { state.abortController.abort(); } catch { /* noop */ }
174
311
  }
175
- if (state?.copilotChild) {
176
- try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
312
+ if (state?.acpClient && state.sessionId) {
313
+ try { state.acpClient.notify('session/cancel', { sessionId: state.sessionId }); }
314
+ catch { /* noop */ }
315
+ }
316
+ _drainPendingPermissions(state, 'aborted');
317
+ // Fallback: if Copilot ignores session/cancel and the prompt never resolves,
318
+ // SIGTERM the child after a grace period — the close handler will then
319
+ // synthesize the result envelope + turn_completed.
320
+ if (state?.copilotChild && !state._abortKillTimer) {
321
+ state._abortKillTimer = setTimeout(() => {
322
+ state._abortKillTimer = null;
323
+ if (state.turnActive && state.copilotChild) {
324
+ try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
325
+ }
326
+ }, 10000);
327
+ state._abortKillTimer.unref?.();
328
+ }
329
+ }
330
+
331
+ /**
332
+ * /clear support: ask ACP for a brand-new session under the same
333
+ * conversationId. Keeps the child alive — no spawn cost.
334
+ */
335
+ export async function clear(state) {
336
+ if (!state?.acpClient) return;
337
+ // A fresh session invalidates any in-flight permission prompts.
338
+ _drainPendingPermissions(state, 'session cleared');
339
+ try {
340
+ const r = await state.acpClient.request('session/new', {
341
+ cwd: state.workDir,
342
+ mcpServers: [],
343
+ });
344
+ state.sessionId = r?.sessionId || randomUUID();
345
+ state.claudeSessionId = state.sessionId;
346
+ sendOutput(state.conversationId, {
347
+ type: 'system',
348
+ subtype: 'init',
349
+ session_id: state.sessionId,
350
+ model: state.model,
351
+ tools: state.tools,
352
+ mcp_servers: [],
353
+ permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
354
+ });
355
+ } catch (err) {
356
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] clear failed:', err?.message || err);
177
357
  }
178
358
  }
179
359
 
@@ -183,113 +363,232 @@ function sendOutput(conversationId, data) {
183
363
  ctx.sendToServer({ type: 'claude_output', conversationId, data });
184
364
  }
185
365
 
186
- export function createNdjsonParser(onEvent) {
187
- let buf = '';
188
- return {
189
- push(chunk) {
190
- buf += chunk.toString('utf8');
191
- let idx;
192
- while ((idx = buf.indexOf('\n')) >= 0) {
193
- const line = buf.slice(0, idx).trim();
194
- buf = buf.slice(idx + 1);
195
- if (!line) continue;
196
- let evt;
197
- try { evt = JSON.parse(line); }
198
- catch (err) {
199
- if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unparsable line:', line.slice(0, 200));
200
- continue;
201
- }
202
- try { onEvent(evt); }
203
- catch (err) { console.warn('[copilot] event handler error:', err?.message || err); }
366
+ function _handleAcpNotification(state, method, params) {
367
+ if (method === 'session/update') {
368
+ _handleSessionUpdate(state, params);
369
+ return;
370
+ }
371
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] unknown ACP notification:', method);
372
+ }
373
+
374
+ async function _handleAcpRequest(state, method, params) {
375
+ if (method === 'session/request_permission') {
376
+ return _handlePermissionRequest(state, params);
377
+ }
378
+ // fs/read_text_file, fs/write_text_file, terminal/* — Copilot's agent
379
+ // doesn't need them because it runs its own tools, but answer politely
380
+ // to anything we don't implement.
381
+ throw Object.assign(new Error(`unsupported method: ${method}`), { code: -32601 });
382
+ }
383
+
384
+ function _handleSessionUpdate(state, params) {
385
+ if (!params || !params.sessionId || params.sessionId !== state.sessionId) {
386
+ // Stale update from a prior session; ignore.
387
+ return;
388
+ }
389
+ const upd = params.update || params;
390
+ const kind = upd.sessionUpdate;
391
+ switch (kind) {
392
+ case 'agent_message_chunk': {
393
+ const text = _extractText(upd.content);
394
+ if (!text) return;
395
+ sendOutput(state.conversationId, {
396
+ type: 'assistant',
397
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
398
+ });
399
+ return;
400
+ }
401
+ case 'agent_thought_chunk': {
402
+ const text = _extractText(upd.content);
403
+ if (!text) return;
404
+ sendOutput(state.conversationId, {
405
+ type: 'assistant',
406
+ message: { role: 'assistant', content: [{ type: 'thinking', thinking: text }] },
407
+ });
408
+ return;
409
+ }
410
+ case 'user_message_chunk': {
411
+ // Echo of our own prompt — drop (frontend already shows it).
412
+ return;
413
+ }
414
+ case 'tool_call': {
415
+ const id = upd.toolCallId || upd.id || randomUUID();
416
+ const toolName = upd.title || upd.kind || 'tool';
417
+ const input = upd.rawInput || upd.input || {};
418
+ sendOutput(state.conversationId, {
419
+ type: 'assistant',
420
+ message: { role: 'assistant', content: [{ type: 'tool_use', id, name: toolName, input }] },
421
+ });
422
+ return;
423
+ }
424
+ case 'tool_call_update': {
425
+ const id = upd.toolCallId || upd.id;
426
+ if (!id) return;
427
+ const status = upd.status;
428
+ // Only emit a tool_result when the call reaches a terminal state with
429
+ // some content/output; intermediate "in_progress" updates would render
430
+ // as duplicate empty results in the existing renderer.
431
+ const isTerminal = status === 'completed' || status === 'failed';
432
+ if (!isTerminal) return;
433
+ const text = _extractToolContent(upd.content) || (upd.rawOutput ? _stringify(upd.rawOutput) : '');
434
+ sendOutput(state.conversationId, {
435
+ type: 'user',
436
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, is_error: status === 'failed' }] },
437
+ });
438
+ return;
439
+ }
440
+ case 'plan': {
441
+ // Optional: render as a todo-style assistant message. Keep it minimal.
442
+ const entries = Array.isArray(upd.entries) ? upd.entries : [];
443
+ if (!entries.length) return;
444
+ const text = entries.map(e => `- [${e.status === 'completed' ? 'x' : ' '}] ${e.content}`).join('\n');
445
+ sendOutput(state.conversationId, {
446
+ type: 'assistant',
447
+ message: { role: 'assistant', content: [{ type: 'text', text: `**Plan:**\n${text}` }] },
448
+ });
449
+ return;
450
+ }
451
+ case 'available_commands_update': {
452
+ if (Array.isArray(upd.availableCommands)) {
453
+ state.slashCommands = upd.availableCommands.map(c => c.name || c).filter(Boolean);
204
454
  }
205
- },
206
- flush() {
207
- const line = buf.trim();
208
- buf = '';
209
- if (!line) return;
210
- try {
211
- const evt = JSON.parse(line);
212
- onEvent(evt);
213
- } catch { /* discard trailing junk */ }
214
- },
215
- };
455
+ return;
456
+ }
457
+ default:
458
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] unhandled session update:', kind);
459
+ }
460
+ }
461
+
462
+ async function _handlePermissionRequest(state, params) {
463
+ const opt = Array.isArray(params?.options) ? params.options : [];
464
+ // Defensive: ACP forbids empty options but if a buggy server sends one,
465
+ // reject the request rather than fabricating an optionId Copilot won't
466
+ // recognise.
467
+ if (opt.length === 0) {
468
+ return { outcome: { outcome: 'cancelled' } };
469
+ }
470
+ // Auto-approve if the user enabled allowAllTools (or YOLO env).
471
+ if (state.allowAllTools) {
472
+ const allow = opt.find(o => o.kind === 'allow_always' || o.kind === 'allow_once') || opt[0];
473
+ return { outcome: { outcome: 'selected', optionId: allow.optionId } };
474
+ }
475
+ // Otherwise route through the existing ask-user wire path. We do it inline
476
+ // here using a per-state Promise; the frontend responds via the standard
477
+ // `ask_user_response` message which conversation.js routes back into the
478
+ // driver via `respondToPermissionRequest(state, requestId, optionId)`.
479
+ const requestId = `copilot-perm-${randomUUID()}`;
480
+ return new Promise((resolve) => {
481
+ state.pendingPermissions.set(requestId, { resolve, options: opt });
482
+ ctx.sendToServer({
483
+ type: 'ask_user_question',
484
+ conversationId: state.conversationId,
485
+ requestId,
486
+ question: _formatPermissionPrompt(params),
487
+ options: opt.map(o => ({ id: o.optionId, label: o.name || o.optionId, kind: o.kind })),
488
+ });
489
+ });
216
490
  }
217
491
 
218
492
  /**
219
- * Map a Copilot NDJSON event to zero-or-more claude_output envelopes.
220
- * Defensive: unknown shapes are logged and dropped.
221
- *
222
- * Recognized loose schemas (Copilot CLI JSON output is not yet stable, so
223
- * we accept several aliases and forward only what we understand):
224
- * - text: { type: 'text'|'text_delta'|'assistant_text', text|delta }
225
- * - message: { type: 'message', role, content }
226
- * - tool_call: { type: 'tool_call'|'tool_use', id, name|tool, input|arguments }
227
- * - tool_result: { type: 'tool_result', tool_use_id|id, content|output }
228
- * - done: { type: 'result'|'done'|'complete', session_id?, error? }
229
- * - error: { type: 'error', message|error }
493
+ * Drain every in-flight permission prompt with a "cancelled" outcome. Called
494
+ * on child close, abort, and clear so dangling Promises don't pin GC roots
495
+ * and the frontend ask-user dialog unwedges.
230
496
  */
231
- export function translateCopilotEvent(evt, state) {
232
- if (!evt || typeof evt !== 'object') return [];
233
- const t = evt.type;
234
-
235
- if (t === 'text' || t === 'text_delta' || t === 'assistant_text') {
236
- const text = typeof evt.text === 'string' ? evt.text : (typeof evt.delta === 'string' ? evt.delta : '');
237
- if (!text) return [];
238
- return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } }];
497
+ function _drainPendingPermissions(state, reason) {
498
+ const m = state?.pendingPermissions;
499
+ if (!m || m.size === 0) return;
500
+ for (const { resolve } of m.values()) {
501
+ try { resolve({ outcome: { outcome: 'cancelled' } }); } catch { /* noop */ }
239
502
  }
503
+ m.clear();
504
+ if (ctx?.CONFIG?.debug) console.warn(`[copilot] drained pending permissions: ${reason}`);
505
+ }
240
506
 
241
- if (t === 'message' && evt.role === 'assistant') {
242
- const content = normalizeContent(evt.content);
243
- return [{ type: 'assistant', message: { role: 'assistant', content } }];
507
+ /**
508
+ * Called by conversation.js when the frontend posts an ask_user_response for
509
+ * a permission prompt we issued. Exported so the message router can hand it
510
+ * back without exposing the state internals.
511
+ */
512
+ export function respondToPermissionRequest(state, requestId, optionId) {
513
+ const slot = state?.pendingPermissions?.get(requestId);
514
+ if (!slot) {
515
+ console.warn(`[copilot] respondToPermissionRequest: no pending permission for ${requestId}`);
516
+ return false;
244
517
  }
518
+ state.pendingPermissions.delete(requestId);
519
+ const opt = slot.options.find(o => o.optionId === optionId) || slot.options[0];
520
+ slot.resolve({ outcome: { outcome: 'selected', optionId: opt?.optionId || optionId } });
521
+ return true;
522
+ }
245
523
 
246
- if (t === 'tool_call' || t === 'tool_use') {
247
- const id = evt.id || evt.call_id || randomUUID();
248
- const toolName = evt.name || evt.tool || 'unknown';
249
- const input = evt.input ?? evt.arguments ?? {};
250
- return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name: toolName, input }] } }];
524
+ function _formatPermissionPrompt(params) {
525
+ const tc = params?.toolCall || {};
526
+ const title = tc.title || tc.kind || 'tool';
527
+ const rawIn = tc.rawInput;
528
+ let suffix = '';
529
+ if (rawIn && typeof rawIn === 'object') {
530
+ try { suffix = '\n```\n' + JSON.stringify(rawIn, null, 2).slice(0, 600) + '\n```'; }
531
+ catch { /* noop */ }
251
532
  }
533
+ return `Copilot wants to run \`${title}\`. Allow?${suffix}`;
534
+ }
252
535
 
253
- if (t === 'tool_result') {
254
- const tool_use_id = evt.tool_use_id || evt.id || 'unknown';
255
- const content = typeof evt.content === 'string'
256
- ? evt.content
257
- : (typeof evt.output === 'string' ? evt.output : JSON.stringify(evt.content ?? evt.output ?? ''));
258
- return [{ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id, content }] } }];
536
+ function _extractText(content) {
537
+ if (!content) return '';
538
+ if (typeof content === 'string') return content;
539
+ if (content.type === 'text' && typeof content.text === 'string') return content.text;
540
+ if (Array.isArray(content)) {
541
+ return content.map(c => (c?.type === 'text' ? c.text : '')).filter(Boolean).join('');
259
542
  }
543
+ return '';
544
+ }
260
545
 
261
- if (t === 'result' || t === 'done' || t === 'complete') {
262
- const isErr = !!evt.error || evt.is_error === true;
263
- return [{
264
- type: 'result',
265
- subtype: isErr ? 'error' : 'success',
266
- session_id: evt.session_id || state?.sessionId || null,
267
- is_error: isErr,
268
- error: isErr ? (evt.error || evt.message || 'copilot error') : undefined,
269
- }];
546
+ function _extractToolContent(content) {
547
+ if (!content) return '';
548
+ if (Array.isArray(content)) {
549
+ const parts = [];
550
+ for (const c of content) {
551
+ if (!c) continue;
552
+ // ToolCallContent variants: content (with ContentBlock), diff
553
+ if (c.type === 'content' && c.content) parts.push(_extractText(c.content));
554
+ else if (c.type === 'diff') parts.push(`diff: ${c.path || ''}\n${c.newText || ''}`);
555
+ else if (typeof c === 'string') parts.push(c);
556
+ else parts.push(_stringify(c));
557
+ }
558
+ return parts.filter(Boolean).join('\n');
270
559
  }
560
+ return _stringify(content);
561
+ }
271
562
 
272
- if (t === 'error') {
273
- return [{
274
- type: 'result',
275
- subtype: 'error',
276
- session_id: state?.sessionId || null,
277
- is_error: true,
278
- error: evt.message || evt.error || 'copilot error',
279
- }];
280
- }
563
+ function _stringify(v) {
564
+ if (v == null) return '';
565
+ if (typeof v === 'string') return v;
566
+ try { return JSON.stringify(v); } catch { return String(v); }
567
+ }
281
568
 
282
- if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unknown event type:', t);
283
- return [];
569
+ function _knownCopilotTools() {
570
+ // Best-effort static list (Copilot doesn't expose its toolset over ACP).
571
+ return [
572
+ { name: 'bash', description: 'Execute shell commands' },
573
+ { name: 'read', description: 'Read file contents' },
574
+ { name: 'write', description: 'Write to a file' },
575
+ { name: 'edit', description: 'Edit a file in place' },
576
+ { name: 'grep', description: 'Search file contents' },
577
+ { name: 'glob', description: 'Find files by glob' },
578
+ { name: 'list_dir', description: 'List directory contents' },
579
+ { name: 'web_fetch', description: 'Fetch URL contents' },
580
+ { name: 'web_search', description: 'Search the web' },
581
+ { name: 'ask_user', description: 'Ask the user a question' },
582
+ ];
284
583
  }
285
584
 
286
- function normalizeContent(content) {
287
- if (typeof content === 'string') return [{ type: 'text', text: content }];
288
- if (Array.isArray(content)) return content;
289
- return [{ type: 'text', text: String(content ?? '') }];
585
+ /** Exported for the model picker UI. */
586
+ export function listModels() {
587
+ return COPILOT_MODELS.slice();
290
588
  }
291
589
 
292
- export default { name, start, sendInput, abort, listFolders, listSessions, loadHistory };
590
+ export default { name, capabilities, start, sendInput, abort, clear, listFolders, listSessions, loadHistory, listModels, respondToPermissionRequest };
591
+
293
592
 
294
593
  // ---------- history surface (reads ~/.copilot/session-store.db) ----------
295
594