@yeaft/webchat-agent 0.1.859 → 0.1.863

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,20 +1,38 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { randomUUID } from 'crypto';
3
+ import { existsSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+ import { DatabaseSync } from 'node:sqlite';
3
7
  import ctx from '../context.js';
8
+ import { AcpClient } from './acp-client.js';
9
+ import { COPILOT_MODELS, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
4
10
 
5
11
  export const name = 'copilot';
6
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
+
7
24
  const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
8
- // Opt-in only: --allow-all-tools is a destructive footgun by default in a
9
- // multi-tenant agent. Set COPILOT_YOLO=1 (and only if you know what you're
10
- // doing) to skip Copilot's tool prompts.
25
+ // YOLO is now opt-in only; per-conv allowAllTools is the normal channel.
11
26
  const YOLO = process.env.COPILOT_YOLO === '1';
27
+ const ACP_PROTOCOL_VERSION = 1;
12
28
 
13
29
  /**
14
- * Start (or resume) a Copilot session.
15
- * Copilot's `-p` mode is one-shot per turn, so "start" just prepares state.
16
- * Each sendInput() spawns one `copilot -p ...` child with the same
17
- * --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.
18
36
  */
19
37
  export async function start(opts) {
20
38
  const conversationId = opts.conversationId;
@@ -23,73 +41,82 @@ export async function start(opts) {
23
41
  if (prior?.copilotChild) {
24
42
  try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
25
43
  }
44
+ if (prior?.acpClient) {
45
+ try { prior.acpClient.close('replaced'); } catch { /* noop */ }
46
+ }
47
+
48
+ const providerOptions = opts.providerOptions || prior?.providerOptions || {};
49
+ const model = providerOptions.model || DEFAULT_COPILOT_MODEL;
50
+ const allowAllTools = YOLO || !!providerOptions.allowAllTools;
26
51
 
27
- const sessionId = opts.resumeSessionId || randomUUID();
28
52
  const state = {
29
53
  providerName: name,
30
- conversationId: opts.conversationId,
54
+ conversationId,
31
55
  query: null,
32
56
  inputStream: null,
33
57
  workDir: opts.workDir,
34
- claudeSessionId: sessionId,
35
- sessionId,
58
+ claudeSessionId: opts.resumeSessionId || null, // set after session/new or session/load
59
+ sessionId: opts.resumeSessionId || null,
36
60
  createdAt: prior?.createdAt || Date.now(),
37
61
  abortController: null,
38
62
  tools: [],
39
63
  slashCommands: [],
40
- model: 'copilot',
64
+ model,
41
65
  userId: opts.userId,
42
66
  username: opts.username,
43
67
  disallowedTools: prior?.disallowedTools || null,
44
68
  copilotChild: null,
69
+ acpClient: null,
70
+ providerOptions,
71
+ allowAllTools,
72
+ capabilities,
73
+ initialized: false,
74
+ pendingPermissions: new Map(), // requestId → { resolve, reject } for ask-user round-trip
45
75
  usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
46
76
  };
47
77
  ctx.conversations.set(conversationId, state);
48
- return state;
49
- }
50
-
51
- export async function sendInput(state, prompt, opts = {}) {
52
- const conversationId = opts.conversationId || state.conversationId;
53
- if (!conversationId) throw new Error('copilot: conversationId required');
54
- if (!state.sessionId) state.sessionId = randomUUID();
55
78
 
56
- // Abort any in-flight turn.
57
- if (state.copilotChild) {
58
- try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
59
- state.copilotChild = null;
60
- }
61
- const abortController = new AbortController();
62
- state.abortController = abortController;
63
- state.turnActive = true;
64
- state.turnResultReceived = false;
65
-
66
- const args = ['-p', prompt, '--output-format', 'json', '-C', state.workDir, '--session-id', state.sessionId];
67
- if (YOLO) args.push('--allow-all-tools');
68
-
69
- let child;
79
+ // Best-effort start; failures emit a result envelope and leave state in place
80
+ // so the next sendInput retries.
70
81
  try {
71
- child = spawn(COPILOT_BIN, args, {
72
- cwd: state.workDir,
73
- env: process.env,
74
- stdio: ['ignore', 'pipe', 'pipe'],
75
- });
82
+ await _bootAcp(state, opts.resumeSessionId || null, model);
76
83
  } catch (err) {
77
84
  sendOutput(conversationId, {
78
85
  type: 'result',
79
86
  subtype: 'error',
80
87
  session_id: state.sessionId,
81
88
  is_error: true,
82
- 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.`,
83
90
  });
84
- state.turnActive = false;
85
- ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
86
- return;
87
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
+ });
88
109
  state.copilotChild = child;
89
110
 
90
- // 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
+ });
91
118
  child.on('error', (err) => {
92
- sendOutput(conversationId, {
119
+ sendOutput(state.conversationId, {
93
120
  type: 'result',
94
121
  subtype: 'error',
95
122
  session_id: state.sessionId,
@@ -97,69 +124,236 @@ export async function sendInput(state, prompt, opts = {}) {
97
124
  error: `copilot process error: ${err?.message || err}`,
98
125
  });
99
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
+ });
100
152
 
101
- let killTimer = null;
102
- abortController.signal.addEventListener('abort', () => {
103
- try { child.kill('SIGTERM'); } catch { /* noop */ }
104
- // Escalate to SIGKILL if the child ignores SIGTERM, so the awaited
105
- // close promise resolves and the next turn isn't blocked forever.
106
- killTimer = setTimeout(() => {
107
- try { child.kill('SIGKILL'); } catch { /* noop */ }
108
- }, 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
+ },
109
161
  });
162
+ state.acpClient = client;
110
163
 
111
- let stderrBuf = '';
112
- const STDERR_CAP = 64 * 1024;
113
- 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;
114
171
 
115
- const parser = createNdjsonParser((evt) => {
116
- const envelopes = translateCopilotEvent(evt, state);
117
- for (const e of envelopes) {
118
- sendOutput(conversationId, e);
119
- 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
+ });
120
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',
121
213
  });
214
+ }
122
215
 
123
- child.stdout.on('data', (chunk) => parser.push(chunk));
124
- child.stderr.on('data', (chunk) => {
125
- if (stderrBuf.length < STDERR_CAP) {
126
- 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;
127
234
  }
128
- });
235
+ }
129
236
 
130
- await new Promise((resolve) => {
131
- child.on('close', (code) => {
132
- if (killTimer) clearTimeout(killTimer);
133
- parser.flush();
134
- if (!sawResult) {
135
- const ok = code === 0;
136
- sendOutput(conversationId, {
137
- type: 'result',
138
- subtype: ok ? 'success' : 'error',
139
- session_id: state.sessionId,
140
- is_error: !ok,
141
- error: ok ? undefined : (stderrBuf.trim().slice(0, 2000) || `copilot exited with code ${code}`),
142
- });
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 });
143
263
  }
144
- state.copilotChild = null;
145
- state.turnActive = false;
146
- ctx.sendToServer({
147
- type: 'turn_completed',
148
- conversationId,
149
- claudeSessionId: state.sessionId,
150
- workDir: state.workDir,
151
- });
152
- resolve();
153
- });
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
+ }
154
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
+ }
155
306
  }
156
307
 
157
308
  export function abort(state) {
158
309
  if (state?.abortController) {
159
310
  try { state.abortController.abort(); } catch { /* noop */ }
160
311
  }
161
- if (state?.copilotChild) {
162
- 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);
163
357
  }
164
358
  }
165
359
 
@@ -169,110 +363,421 @@ function sendOutput(conversationId, data) {
169
363
  ctx.sendToServer({ type: 'claude_output', conversationId, data });
170
364
  }
171
365
 
172
- export function createNdjsonParser(onEvent) {
173
- let buf = '';
174
- return {
175
- push(chunk) {
176
- buf += chunk.toString('utf8');
177
- let idx;
178
- while ((idx = buf.indexOf('\n')) >= 0) {
179
- const line = buf.slice(0, idx).trim();
180
- buf = buf.slice(idx + 1);
181
- if (!line) continue;
182
- let evt;
183
- try { evt = JSON.parse(line); }
184
- catch (err) {
185
- if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unparsable line:', line.slice(0, 200));
186
- continue;
187
- }
188
- try { onEvent(evt); }
189
- 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);
190
454
  }
191
- },
192
- flush() {
193
- const line = buf.trim();
194
- buf = '';
195
- if (!line) return;
196
- try {
197
- const evt = JSON.parse(line);
198
- onEvent(evt);
199
- } catch { /* discard trailing junk */ }
200
- },
201
- };
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
+ });
202
490
  }
203
491
 
204
492
  /**
205
- * Map a Copilot NDJSON event to zero-or-more claude_output envelopes.
206
- * Defensive: unknown shapes are logged and dropped.
207
- *
208
- * Recognized loose schemas (Copilot CLI JSON output is not yet stable, so
209
- * we accept several aliases and forward only what we understand):
210
- * - text: { type: 'text'|'text_delta'|'assistant_text', text|delta }
211
- * - message: { type: 'message', role, content }
212
- * - tool_call: { type: 'tool_call'|'tool_use', id, name|tool, input|arguments }
213
- * - tool_result: { type: 'tool_result', tool_use_id|id, content|output }
214
- * - done: { type: 'result'|'done'|'complete', session_id?, error? }
215
- * - 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.
216
496
  */
217
- export function translateCopilotEvent(evt, state) {
218
- if (!evt || typeof evt !== 'object') return [];
219
- const t = evt.type;
220
-
221
- if (t === 'text' || t === 'text_delta' || t === 'assistant_text') {
222
- const text = typeof evt.text === 'string' ? evt.text : (typeof evt.delta === 'string' ? evt.delta : '');
223
- if (!text) return [];
224
- 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 */ }
225
502
  }
503
+ m.clear();
504
+ if (ctx?.CONFIG?.debug) console.warn(`[copilot] drained pending permissions: ${reason}`);
505
+ }
226
506
 
227
- if (t === 'message' && evt.role === 'assistant') {
228
- const content = normalizeContent(evt.content);
229
- 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;
230
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
+ }
231
523
 
232
- if (t === 'tool_call' || t === 'tool_use') {
233
- const id = evt.id || evt.call_id || randomUUID();
234
- const toolName = evt.name || evt.tool || 'unknown';
235
- const input = evt.input ?? evt.arguments ?? {};
236
- 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 */ }
237
532
  }
533
+ return `Copilot wants to run \`${title}\`. Allow?${suffix}`;
534
+ }
238
535
 
239
- if (t === 'tool_result') {
240
- const tool_use_id = evt.tool_use_id || evt.id || 'unknown';
241
- const content = typeof evt.content === 'string'
242
- ? evt.content
243
- : (typeof evt.output === 'string' ? evt.output : JSON.stringify(evt.content ?? evt.output ?? ''));
244
- 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('');
245
542
  }
543
+ return '';
544
+ }
246
545
 
247
- if (t === 'result' || t === 'done' || t === 'complete') {
248
- const isErr = !!evt.error || evt.is_error === true;
249
- return [{
250
- type: 'result',
251
- subtype: isErr ? 'error' : 'success',
252
- session_id: evt.session_id || state?.sessionId || null,
253
- is_error: isErr,
254
- error: isErr ? (evt.error || evt.message || 'copilot error') : undefined,
255
- }];
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');
256
559
  }
560
+ return _stringify(content);
561
+ }
257
562
 
258
- if (t === 'error') {
259
- return [{
260
- type: 'result',
261
- subtype: 'error',
262
- session_id: state?.sessionId || null,
263
- is_error: true,
264
- error: evt.message || evt.error || 'copilot error',
265
- }];
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
+ }
568
+
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
+ ];
583
+ }
584
+
585
+ /** Exported for the model picker UI. */
586
+ export function listModels() {
587
+ return COPILOT_MODELS.slice();
588
+ }
589
+
590
+ export default { name, capabilities, start, sendInput, abort, clear, listFolders, listSessions, loadHistory, listModels, respondToPermissionRequest };
591
+
592
+
593
+ // ---------- history surface (reads ~/.copilot/session-store.db) ----------
594
+
595
+ export function getCopilotDbPath() {
596
+ return process.env.COPILOT_DB_PATH || join(homedir(), '.copilot', 'session-store.db');
597
+ }
598
+
599
+ let _dbHandle = null;
600
+ let _dbHandlePath = null;
601
+ function openDb() {
602
+ const path = getCopilotDbPath();
603
+ if (!existsSync(path)) return null;
604
+ if (_dbHandle && _dbHandlePath === path) return _dbHandle;
605
+ if (_dbHandle) {
606
+ try { _dbHandle.close(); } catch { /* noop */ }
607
+ _dbHandle = null;
608
+ }
609
+ try {
610
+ _dbHandle = new DatabaseSync(path, { readOnly: true });
611
+ _dbHandlePath = path;
612
+ return _dbHandle;
613
+ } catch (err) {
614
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] cannot open session DB:', err?.message || err);
615
+ return null;
266
616
  }
617
+ }
618
+
619
+ // Exposed for tests so they can drop the cached handle when swapping
620
+ // COPILOT_DB_PATH between cases.
621
+ export function _resetCopilotDbHandle() {
622
+ if (_dbHandle) {
623
+ try { _dbHandle.close(); } catch { /* noop */ }
624
+ }
625
+ _dbHandle = null;
626
+ _dbHandlePath = null;
627
+ }
267
628
 
268
- if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unknown event type:', t);
269
- return [];
629
+ function toEpochMs(iso) {
630
+ if (!iso) return 0;
631
+ const t = Date.parse(iso);
632
+ return Number.isFinite(t) ? t : 0;
270
633
  }
271
634
 
272
- function normalizeContent(content) {
273
- if (typeof content === 'string') return [{ type: 'text', text: content }];
274
- if (Array.isArray(content)) return content;
275
- return [{ type: 'text', text: String(content ?? '') }];
635
+ export async function listFolders() {
636
+ const db = openDb();
637
+ if (!db) return [];
638
+ try {
639
+ const rows = db.prepare(`
640
+ SELECT cwd, COUNT(*) AS sessionCount, MAX(updated_at) AS lastUpdated
641
+ FROM sessions
642
+ WHERE cwd IS NOT NULL AND cwd <> ''
643
+ GROUP BY cwd
644
+ ORDER BY lastUpdated DESC
645
+ `).all();
646
+ return rows.map(r => ({
647
+ name: r.cwd,
648
+ path: r.cwd,
649
+ sessionCount: Number(r.sessionCount) || 0,
650
+ lastModified: toEpochMs(r.lastUpdated),
651
+ }));
652
+ } catch (err) {
653
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] listFolders failed:', err?.message || err);
654
+ return [];
655
+ }
656
+ }
657
+
658
+ export async function listSessions(workDir) {
659
+ if (!workDir) return [];
660
+ const db = openDb();
661
+ if (!db) return [];
662
+ try {
663
+ const rows = db.prepare(`
664
+ SELECT s.id, s.summary, s.created_at, s.updated_at,
665
+ (SELECT user_message FROM turns WHERE session_id = s.id ORDER BY turn_index ASC LIMIT 1) AS first_user
666
+ FROM sessions s
667
+ WHERE s.cwd = ?
668
+ ORDER BY s.updated_at DESC
669
+ `).all(workDir);
670
+ return rows.map(r => {
671
+ const preview = (r.first_user || '').toString().slice(0, 100);
672
+ const title = (r.summary && r.summary.trim()) || preview || r.id.slice(0, 8);
673
+ return {
674
+ sessionId: r.id,
675
+ workDir,
676
+ title,
677
+ preview,
678
+ lastModified: toEpochMs(r.updated_at) || toEpochMs(r.created_at),
679
+ };
680
+ }).filter(s => s.title);
681
+ } catch (err) {
682
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] listSessions failed:', err?.message || err);
683
+ return [];
684
+ }
685
+ }
686
+
687
+ export async function loadHistory(workDir, sessionId, limit = 500) {
688
+ if (!sessionId) return [];
689
+ const db = openDb();
690
+ if (!db) return [];
691
+ try {
692
+ const allTurns = db.prepare(`
693
+ SELECT turn_index, user_message, assistant_response, timestamp
694
+ FROM turns WHERE session_id = ? ORDER BY turn_index ASC
695
+ `).all(sessionId);
696
+ // Apply limit at the turn level so we never split a tool_use / tool_result
697
+ // pair when truncating. Each turn typically expands to <=4 messages, so
698
+ // ceil(limit/4) turns is a safe upper bound that preserves recency.
699
+ const turns = limit && allTurns.length > Math.ceil(limit / 4)
700
+ ? allTurns.slice(-Math.ceil(limit / 4))
701
+ : allTurns;
702
+
703
+ // Tool-call events per turn, in order. Copilot's schema is not fully
704
+ // documented; in some versions a single tool call writes multiple rows
705
+ // (e.g. a "started" row with the command and a "completed" row with
706
+ // output/exit_code). Dedupe by tool_call_id, preferring rows that
707
+ // carry output so we render one tool_use + one tool_result per call.
708
+ const events = db.prepare(`
709
+ SELECT id, turn_index, tool_call_id, event_type, command, output, exit_code,
710
+ event_key, event_value, created_at
711
+ FROM forge_trajectory_events
712
+ WHERE session_id = ?
713
+ ORDER BY turn_index ASC, id ASC
714
+ `).all(sessionId);
715
+
716
+ const mergedByCallId = new Map();
717
+ const orderedKeys = [];
718
+ for (const e of events) {
719
+ const key = e.tool_call_id || `__row:${e.id}`;
720
+ const prev = mergedByCallId.get(key);
721
+ if (!prev) {
722
+ orderedKeys.push(key);
723
+ mergedByCallId.set(key, { ...e });
724
+ } else {
725
+ // Merge later rows in; non-null values win so the "completed" row
726
+ // adds output/exit_code without erasing the "started" row's command.
727
+ for (const [k, v] of Object.entries(e)) {
728
+ if (v != null && v !== '') prev[k] = v;
729
+ }
730
+ }
731
+ }
732
+ const eventsByTurn = new Map();
733
+ for (const key of orderedKeys) {
734
+ const e = mergedByCallId.get(key);
735
+ const arr = eventsByTurn.get(e.turn_index) || [];
736
+ arr.push(e);
737
+ eventsByTurn.set(e.turn_index, arr);
738
+ }
739
+
740
+ const messages = [];
741
+ for (const t of turns) {
742
+ if (t.user_message) {
743
+ messages.push({
744
+ type: 'user',
745
+ message: { role: 'user', content: [{ type: 'text', text: String(t.user_message) }] },
746
+ });
747
+ }
748
+ // Render any tool calls captured for this turn as assistant tool_use +
749
+ // user tool_result pairs, then the final assistant text.
750
+ const turnEvents = eventsByTurn.get(t.turn_index) || [];
751
+ for (const e of turnEvents) {
752
+ if (!e.event_type) continue;
753
+ const toolId = e.tool_call_id || `copilot-${t.turn_index}-${messages.length}`;
754
+ const toolName = e.event_type;
755
+ const input = e.command
756
+ ? { command: e.command }
757
+ : (e.event_key ? { [e.event_key]: e.event_value } : {});
758
+ messages.push({
759
+ type: 'assistant',
760
+ message: { role: 'assistant', content: [{ type: 'tool_use', id: toolId, name: toolName, input }] },
761
+ });
762
+ const outText = e.output != null
763
+ ? String(e.output)
764
+ : (e.exit_code != null ? `exit ${e.exit_code}` : '');
765
+ messages.push({
766
+ type: 'user',
767
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: outText }] },
768
+ });
769
+ }
770
+ if (t.assistant_response) {
771
+ messages.push({
772
+ type: 'assistant',
773
+ message: { role: 'assistant', content: [{ type: 'text', text: String(t.assistant_response) }] },
774
+ });
775
+ }
776
+ }
777
+ return messages;
778
+ } catch (err) {
779
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] loadHistory failed:', err?.message || err);
780
+ return [];
781
+ }
276
782
  }
277
783
 
278
- export default { name, start, sendInput, abort };