@yeaft/webchat-agent 0.1.857 → 0.1.860

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.
@@ -0,0 +1,484 @@
1
+ import { spawn } from 'child_process';
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';
7
+ import ctx from '../context.js';
8
+
9
+ export const name = 'copilot';
10
+
11
+ 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.
15
+ const YOLO = process.env.COPILOT_YOLO === '1';
16
+
17
+ /**
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.
22
+ */
23
+ export async function start(opts) {
24
+ const conversationId = opts.conversationId;
25
+ // Tear down any prior entry so we don't leak children.
26
+ const prior = ctx.conversations.get(conversationId);
27
+ if (prior?.copilotChild) {
28
+ try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
29
+ }
30
+
31
+ const sessionId = opts.resumeSessionId || randomUUID();
32
+ const providerOptions = opts.providerOptions || prior?.providerOptions || {};
33
+ const state = {
34
+ providerName: name,
35
+ conversationId: opts.conversationId,
36
+ query: null,
37
+ inputStream: null,
38
+ workDir: opts.workDir,
39
+ claudeSessionId: sessionId,
40
+ sessionId,
41
+ createdAt: prior?.createdAt || Date.now(),
42
+ abortController: null,
43
+ tools: [],
44
+ slashCommands: [],
45
+ model: providerOptions.model || 'copilot',
46
+ userId: opts.userId,
47
+ username: opts.username,
48
+ disallowedTools: prior?.disallowedTools || null,
49
+ copilotChild: null,
50
+ providerOptions,
51
+ usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
52
+ };
53
+ 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
+
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;
84
+ try {
85
+ child = spawn(COPILOT_BIN, args, {
86
+ cwd: state.workDir,
87
+ env: process.env,
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ });
90
+ } catch (err) {
91
+ sendOutput(conversationId, {
92
+ type: 'result',
93
+ subtype: 'error',
94
+ session_id: state.sessionId,
95
+ is_error: true,
96
+ error: `copilot spawn failed: ${err?.message || err}`,
97
+ });
98
+ state.turnActive = false;
99
+ ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
100
+ return;
101
+ }
102
+ state.copilotChild = child;
103
+
104
+ // Pre-register error handler so async ENOENT from spawn is never unhandled.
105
+ child.on('error', (err) => {
106
+ sendOutput(conversationId, {
107
+ type: 'result',
108
+ subtype: 'error',
109
+ session_id: state.sessionId,
110
+ is_error: true,
111
+ error: `copilot process error: ${err?.message || err}`,
112
+ });
113
+ });
114
+
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);
123
+ });
124
+
125
+ let stderrBuf = '';
126
+ const STDERR_CAP = 64 * 1024;
127
+ let sawResult = false;
128
+
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;
134
+ }
135
+ });
136
+
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);
141
+ }
142
+ });
143
+
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
+ });
157
+ }
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
+ });
168
+ });
169
+ }
170
+
171
+ export function abort(state) {
172
+ if (state?.abortController) {
173
+ try { state.abortController.abort(); } catch { /* noop */ }
174
+ }
175
+ if (state?.copilotChild) {
176
+ try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
177
+ }
178
+ }
179
+
180
+ // ---------- internals ----------
181
+
182
+ function sendOutput(conversationId, data) {
183
+ ctx.sendToServer({ type: 'claude_output', conversationId, data });
184
+ }
185
+
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); }
204
+ }
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
+ };
216
+ }
217
+
218
+ /**
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 }
230
+ */
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 }] } }];
239
+ }
240
+
241
+ if (t === 'message' && evt.role === 'assistant') {
242
+ const content = normalizeContent(evt.content);
243
+ return [{ type: 'assistant', message: { role: 'assistant', content } }];
244
+ }
245
+
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 }] } }];
251
+ }
252
+
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 }] } }];
259
+ }
260
+
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
+ }];
270
+ }
271
+
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
+ }
281
+
282
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unknown event type:', t);
283
+ return [];
284
+ }
285
+
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 ?? '') }];
290
+ }
291
+
292
+ export default { name, start, sendInput, abort, listFolders, listSessions, loadHistory };
293
+
294
+ // ---------- history surface (reads ~/.copilot/session-store.db) ----------
295
+
296
+ export function getCopilotDbPath() {
297
+ return process.env.COPILOT_DB_PATH || join(homedir(), '.copilot', 'session-store.db');
298
+ }
299
+
300
+ let _dbHandle = null;
301
+ let _dbHandlePath = null;
302
+ function openDb() {
303
+ const path = getCopilotDbPath();
304
+ if (!existsSync(path)) return null;
305
+ if (_dbHandle && _dbHandlePath === path) return _dbHandle;
306
+ if (_dbHandle) {
307
+ try { _dbHandle.close(); } catch { /* noop */ }
308
+ _dbHandle = null;
309
+ }
310
+ try {
311
+ _dbHandle = new DatabaseSync(path, { readOnly: true });
312
+ _dbHandlePath = path;
313
+ return _dbHandle;
314
+ } catch (err) {
315
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] cannot open session DB:', err?.message || err);
316
+ return null;
317
+ }
318
+ }
319
+
320
+ // Exposed for tests so they can drop the cached handle when swapping
321
+ // COPILOT_DB_PATH between cases.
322
+ export function _resetCopilotDbHandle() {
323
+ if (_dbHandle) {
324
+ try { _dbHandle.close(); } catch { /* noop */ }
325
+ }
326
+ _dbHandle = null;
327
+ _dbHandlePath = null;
328
+ }
329
+
330
+ function toEpochMs(iso) {
331
+ if (!iso) return 0;
332
+ const t = Date.parse(iso);
333
+ return Number.isFinite(t) ? t : 0;
334
+ }
335
+
336
+ export async function listFolders() {
337
+ const db = openDb();
338
+ if (!db) return [];
339
+ try {
340
+ const rows = db.prepare(`
341
+ SELECT cwd, COUNT(*) AS sessionCount, MAX(updated_at) AS lastUpdated
342
+ FROM sessions
343
+ WHERE cwd IS NOT NULL AND cwd <> ''
344
+ GROUP BY cwd
345
+ ORDER BY lastUpdated DESC
346
+ `).all();
347
+ return rows.map(r => ({
348
+ name: r.cwd,
349
+ path: r.cwd,
350
+ sessionCount: Number(r.sessionCount) || 0,
351
+ lastModified: toEpochMs(r.lastUpdated),
352
+ }));
353
+ } catch (err) {
354
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] listFolders failed:', err?.message || err);
355
+ return [];
356
+ }
357
+ }
358
+
359
+ export async function listSessions(workDir) {
360
+ if (!workDir) return [];
361
+ const db = openDb();
362
+ if (!db) return [];
363
+ try {
364
+ const rows = db.prepare(`
365
+ SELECT s.id, s.summary, s.created_at, s.updated_at,
366
+ (SELECT user_message FROM turns WHERE session_id = s.id ORDER BY turn_index ASC LIMIT 1) AS first_user
367
+ FROM sessions s
368
+ WHERE s.cwd = ?
369
+ ORDER BY s.updated_at DESC
370
+ `).all(workDir);
371
+ return rows.map(r => {
372
+ const preview = (r.first_user || '').toString().slice(0, 100);
373
+ const title = (r.summary && r.summary.trim()) || preview || r.id.slice(0, 8);
374
+ return {
375
+ sessionId: r.id,
376
+ workDir,
377
+ title,
378
+ preview,
379
+ lastModified: toEpochMs(r.updated_at) || toEpochMs(r.created_at),
380
+ };
381
+ }).filter(s => s.title);
382
+ } catch (err) {
383
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] listSessions failed:', err?.message || err);
384
+ return [];
385
+ }
386
+ }
387
+
388
+ export async function loadHistory(workDir, sessionId, limit = 500) {
389
+ if (!sessionId) return [];
390
+ const db = openDb();
391
+ if (!db) return [];
392
+ try {
393
+ const allTurns = db.prepare(`
394
+ SELECT turn_index, user_message, assistant_response, timestamp
395
+ FROM turns WHERE session_id = ? ORDER BY turn_index ASC
396
+ `).all(sessionId);
397
+ // Apply limit at the turn level so we never split a tool_use / tool_result
398
+ // pair when truncating. Each turn typically expands to <=4 messages, so
399
+ // ceil(limit/4) turns is a safe upper bound that preserves recency.
400
+ const turns = limit && allTurns.length > Math.ceil(limit / 4)
401
+ ? allTurns.slice(-Math.ceil(limit / 4))
402
+ : allTurns;
403
+
404
+ // Tool-call events per turn, in order. Copilot's schema is not fully
405
+ // documented; in some versions a single tool call writes multiple rows
406
+ // (e.g. a "started" row with the command and a "completed" row with
407
+ // output/exit_code). Dedupe by tool_call_id, preferring rows that
408
+ // carry output so we render one tool_use + one tool_result per call.
409
+ const events = db.prepare(`
410
+ SELECT id, turn_index, tool_call_id, event_type, command, output, exit_code,
411
+ event_key, event_value, created_at
412
+ FROM forge_trajectory_events
413
+ WHERE session_id = ?
414
+ ORDER BY turn_index ASC, id ASC
415
+ `).all(sessionId);
416
+
417
+ const mergedByCallId = new Map();
418
+ const orderedKeys = [];
419
+ for (const e of events) {
420
+ const key = e.tool_call_id || `__row:${e.id}`;
421
+ const prev = mergedByCallId.get(key);
422
+ if (!prev) {
423
+ orderedKeys.push(key);
424
+ mergedByCallId.set(key, { ...e });
425
+ } else {
426
+ // Merge later rows in; non-null values win so the "completed" row
427
+ // adds output/exit_code without erasing the "started" row's command.
428
+ for (const [k, v] of Object.entries(e)) {
429
+ if (v != null && v !== '') prev[k] = v;
430
+ }
431
+ }
432
+ }
433
+ const eventsByTurn = new Map();
434
+ for (const key of orderedKeys) {
435
+ const e = mergedByCallId.get(key);
436
+ const arr = eventsByTurn.get(e.turn_index) || [];
437
+ arr.push(e);
438
+ eventsByTurn.set(e.turn_index, arr);
439
+ }
440
+
441
+ const messages = [];
442
+ for (const t of turns) {
443
+ if (t.user_message) {
444
+ messages.push({
445
+ type: 'user',
446
+ message: { role: 'user', content: [{ type: 'text', text: String(t.user_message) }] },
447
+ });
448
+ }
449
+ // Render any tool calls captured for this turn as assistant tool_use +
450
+ // user tool_result pairs, then the final assistant text.
451
+ const turnEvents = eventsByTurn.get(t.turn_index) || [];
452
+ for (const e of turnEvents) {
453
+ if (!e.event_type) continue;
454
+ const toolId = e.tool_call_id || `copilot-${t.turn_index}-${messages.length}`;
455
+ const toolName = e.event_type;
456
+ const input = e.command
457
+ ? { command: e.command }
458
+ : (e.event_key ? { [e.event_key]: e.event_value } : {});
459
+ messages.push({
460
+ type: 'assistant',
461
+ message: { role: 'assistant', content: [{ type: 'tool_use', id: toolId, name: toolName, input }] },
462
+ });
463
+ const outText = e.output != null
464
+ ? String(e.output)
465
+ : (e.exit_code != null ? `exit ${e.exit_code}` : '');
466
+ messages.push({
467
+ type: 'user',
468
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: outText }] },
469
+ });
470
+ }
471
+ if (t.assistant_response) {
472
+ messages.push({
473
+ type: 'assistant',
474
+ message: { role: 'assistant', content: [{ type: 'text', text: String(t.assistant_response) }] },
475
+ });
476
+ }
477
+ }
478
+ return messages;
479
+ } catch (err) {
480
+ if (ctx?.CONFIG?.debug) console.warn('[copilot] loadHistory failed:', err?.message || err);
481
+ return [];
482
+ }
483
+ }
484
+
@@ -0,0 +1,19 @@
1
+ import { PROVIDER_NAMES, DEFAULT_PROVIDER, isValidProvider } from './base.js';
2
+ import * as claudeCode from './claude-code.js';
3
+ import * as copilot from './copilot.js';
4
+
5
+ const REGISTRY = Object.freeze({
6
+ 'claude-code': claudeCode,
7
+ 'copilot': copilot,
8
+ });
9
+
10
+ export function getProvider(nameOrUndef) {
11
+ const key = nameOrUndef || DEFAULT_PROVIDER;
12
+ const driver = REGISTRY[key];
13
+ if (!driver) {
14
+ throw new Error(`Unknown chat provider: ${nameOrUndef} (known: ${PROVIDER_NAMES.join(', ')})`);
15
+ }
16
+ return driver;
17
+ }
18
+
19
+ export { PROVIDER_NAMES, DEFAULT_PROVIDER, isValidProvider };