@yemi33/minions 0.1.1588 → 0.1.1589

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,566 @@
1
+ /**
2
+ * engine/runtimes/copilot.js — GitHub Copilot CLI runtime adapter (P-1d4a8e7c).
3
+ *
4
+ * Implements the same contract as engine/runtimes/claude.js (see the header of
5
+ * that file for the contract surface). Built against the empirical findings in
6
+ * docs/copilot-cli-schema.md (P-8f2c4d9b spike) — every flag and parser branch
7
+ * here traces back to a real CLI invocation captured during the spike.
8
+ *
9
+ * Headline behaviors that differ from Claude and surface as capability flags:
10
+ * - promptViaArg: false — stdin works; -p with a 40 KB prompt hits
11
+ * Windows ARG_MAX (~32 KB) and CreateProcess
12
+ * rejects the spawn outright. Stdin is the
13
+ * only safe path on Windows.
14
+ * - systemPromptFile: false — no --system-prompt-file flag exists, so
15
+ * buildPrompt() prepends a <system> block.
16
+ * - costTracking: false — result.usage has premiumRequests count
17
+ * and durations only; no USD or per-token.
18
+ * - modelShorthands: false — full model IDs like "claude-sonnet-4.5",
19
+ * "gpt-5.4". Bare "sonnet" / "opus" / "haiku"
20
+ * is a Claude-ism — log a one-time warning
21
+ * when seen so the user notices the mistake.
22
+ * - modelDiscovery: true — GET https://api.githubcopilot.com/models
23
+ * with `gh auth token` Bearer returns the
24
+ * catalog (24 models on the test account).
25
+ * - effortLevels: true (max → xhigh) — Copilot accepts low/medium/high/xhigh;
26
+ * 'max' is a Claude-ism that maps to 'xhigh'.
27
+ * - budgetCap / bareMode / fallbackModel: false — no equivalent flags.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const https = require('https');
32
+ const path = require('path');
33
+ const { execSync } = require('child_process');
34
+
35
+ const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
36
+ const isWin = process.platform === 'win32';
37
+
38
+ // ── Binary Resolution ───────────────────────────────────────────────────────
39
+ //
40
+ // Two install paths are supported:
41
+ // 1. Standalone `copilot` (preferred) — WinGet, scoop, or manual install. PATH
42
+ // probe finds it; we cache the resolved path with `leadingArgs: []`.
43
+ // 2. `gh copilot` extension fallback — invoked as `gh copilot ...`. We return
44
+ // `leadingArgs: ['copilot']` so engine/spawn-agent.js prepends "copilot"
45
+ // to the gh binary invocation. NOTE: the older gh-copilot extension is
46
+ // the explain/suggest UX, NOT the v1.0.36 agent CLI; flag support varies.
47
+ // We surface it as best-effort and let preflight warn.
48
+ //
49
+ // We deliberately do NOT npm-probe — Copilot is not an npm package. Doing so
50
+ // would be confusing dead code that suggests an install path that doesn't exist.
51
+
52
+ const CAPS_FILE = path.join(ENGINE_DIR, 'copilot-caps.json');
53
+ const MODELS_CACHE = path.join(ENGINE_DIR, 'copilot-models.json');
54
+
55
+ function _safeJson(p) {
56
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
57
+ }
58
+
59
+ function _safeWriteJson(p, obj) {
60
+ try { fs.writeFileSync(p, JSON.stringify(obj, null, 2)); } catch { /* best effort */ }
61
+ }
62
+
63
+ function _execSyncCapture(cmd, env, timeoutMs = 10000) {
64
+ return execSync(cmd, { encoding: 'utf8', env, timeout: timeoutMs, windowsHide: true });
65
+ }
66
+
67
+ /**
68
+ * Probe PATH for a standalone `copilot` binary. Returns the absolute path or
69
+ * null. Resilient to non-zero exits (where/which return 1 when nothing found).
70
+ */
71
+ function _findStandaloneCopilot(env) {
72
+ try {
73
+ const cmd = isWin ? 'where copilot 2>NUL' : 'which copilot 2>/dev/null';
74
+ const which = _execSyncCapture(cmd, env).trim().split('\n')[0].trim();
75
+ if (which && fs.existsSync(which)) return which;
76
+ } catch { /* PATH probe is optional */ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Probe `gh extension list` for the gh-copilot extension. Returns the absolute
82
+ * path of the `gh` binary when found, null otherwise.
83
+ *
84
+ * `gh extension list` exits 0 with a list of extensions on stdout. We grep for
85
+ * `gh-copilot`, the extension's repository slug. If `gh` isn't on PATH the
86
+ * outer try-catch swallows the ENOENT.
87
+ */
88
+ function _findGhCopilotExtension(env) {
89
+ let ghPath = null;
90
+ try {
91
+ const cmd = isWin ? 'where gh 2>NUL' : 'which gh 2>/dev/null';
92
+ const which = _execSyncCapture(cmd, env).trim().split('\n')[0].trim();
93
+ if (!which) return null;
94
+ ghPath = which;
95
+ } catch { return null; }
96
+ try {
97
+ const out = _execSyncCapture('gh extension list', env);
98
+ if (/gh-copilot/i.test(out)) return ghPath;
99
+ } catch { /* `gh` may have no extensions or be misconfigured */ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Resolve the Copilot CLI binary. Returns { bin, native, leadingArgs } or null.
105
+ *
106
+ * Cache shape (engine/copilot-caps.json):
107
+ * { copilotBin, copilotIsNative, leadingArgs, source, resolvedAt }
108
+ *
109
+ * `source` is 'standalone' or 'gh-extension' — it lets future preflight rules
110
+ * surface "you're on the older gh-copilot extension; consider installing the
111
+ * standalone CLI" warnings without re-probing.
112
+ */
113
+ function resolveBinary({ env = process.env } = {}) {
114
+ // 1. Cache hit — fastest path
115
+ const cached = _safeJson(CAPS_FILE);
116
+ if (cached?.copilotBin && fs.existsSync(cached.copilotBin)) {
117
+ const leadingArgs = Array.isArray(cached.leadingArgs) ? cached.leadingArgs : [];
118
+ return { bin: cached.copilotBin, native: !!cached.copilotIsNative, leadingArgs };
119
+ }
120
+
121
+ // 2. Standalone `copilot` first (preferred)
122
+ const standalone = _findStandaloneCopilot(env);
123
+ if (standalone) {
124
+ const native = !isWin || path.extname(standalone).toLowerCase() === '.exe';
125
+ _safeWriteJson(CAPS_FILE, {
126
+ copilotBin: standalone,
127
+ copilotIsNative: native,
128
+ leadingArgs: [],
129
+ source: 'standalone',
130
+ resolvedAt: new Date().toISOString(),
131
+ });
132
+ return { bin: standalone, native, leadingArgs: [] };
133
+ }
134
+
135
+ // 3. `gh copilot` extension fallback (best-effort)
136
+ const gh = _findGhCopilotExtension(env);
137
+ if (gh) {
138
+ const native = !isWin || path.extname(gh).toLowerCase() === '.exe';
139
+ _safeWriteJson(CAPS_FILE, {
140
+ copilotBin: gh,
141
+ copilotIsNative: native,
142
+ leadingArgs: ['copilot'],
143
+ source: 'gh-extension',
144
+ resolvedAt: new Date().toISOString(),
145
+ });
146
+ return { bin: gh, native, leadingArgs: ['copilot'] };
147
+ }
148
+
149
+ return null;
150
+ }
151
+
152
+ // ── Model Resolution ────────────────────────────────────────────────────────
153
+ //
154
+ // Copilot models are full IDs (`claude-sonnet-4.5`, `gpt-5.4`, ...). The
155
+ // adapter passes them through verbatim. When we see a Claude shorthand
156
+ // ('sonnet', 'opus', 'haiku') we log ONCE — a stronger signal than silently
157
+ // passing it to Copilot, which would respond with an unknown-model error.
158
+
159
+ const _CLAUDE_SHORTHANDS = new Set(['sonnet', 'opus', 'haiku']);
160
+ let _shorthandWarningLogged = false;
161
+
162
+ function _resetShorthandWarning() { _shorthandWarningLogged = false; }
163
+
164
+ function resolveModel(input, { logger = console } = {}) {
165
+ if (input == null || input === '') return undefined;
166
+ const s = String(input);
167
+ if (_CLAUDE_SHORTHANDS.has(s.toLowerCase()) && !_shorthandWarningLogged) {
168
+ _shorthandWarningLogged = true;
169
+ try {
170
+ const warn = (logger && typeof logger.warn === 'function') ? logger.warn.bind(logger) : null;
171
+ if (warn) warn(`[copilot] "${s}" is a Claude family shorthand; Copilot expects a full model id (e.g. claude-sonnet-4.5). Passing through verbatim — Copilot will likely reject it.`);
172
+ } catch { /* logger may be unwired during tests */ }
173
+ }
174
+ return s;
175
+ }
176
+
177
+ /**
178
+ * Map effort levels. Copilot accepts low|medium|high|xhigh. The Claude-ism
179
+ * 'max' (used loosely as "give it the most thinking budget") maps to 'xhigh'
180
+ * so a single fleet-wide effort knob works for both runtimes.
181
+ */
182
+ function _mapEffort(level) {
183
+ if (level == null || level === '') return undefined;
184
+ const s = String(level);
185
+ if (s === 'max') return 'xhigh';
186
+ return s;
187
+ }
188
+
189
+ // ── Argument Construction ───────────────────────────────────────────────────
190
+ //
191
+ // Always-on baseline (per docs/copilot-cli-schema.md §3 and the PRD spec):
192
+ // --output-format json -s --no-color --plain-diff --autopilot
193
+ // --allow-all --no-ask-user --log-level error
194
+ //
195
+ // Conditional flags only emitted when their corresponding opt is set/truthy.
196
+ // Copilot has no --verbose flag — never emit it. The `bare` / `maxBudget` /
197
+ // `fallbackModel` opts are silently ignored (their capability flags are false
198
+ // so engine code shouldn't pass them, but we tolerate them gracefully).
199
+
200
+ function buildArgs(opts = {}) {
201
+ const {
202
+ model,
203
+ maxTurns,
204
+ effort,
205
+ sessionId,
206
+ addDirs,
207
+ stream,
208
+ disableBuiltinMcps,
209
+ suppressAgentsMd,
210
+ reasoningSummaries,
211
+ } = opts;
212
+
213
+ const args = [
214
+ '--output-format', 'json',
215
+ '-s',
216
+ '--no-color',
217
+ '--plain-diff',
218
+ '--autopilot',
219
+ '--allow-all',
220
+ '--no-ask-user',
221
+ '--log-level', 'error',
222
+ ];
223
+
224
+ if (Array.isArray(addDirs)) {
225
+ for (const d of addDirs) {
226
+ if (d) args.push('--add-dir', String(d));
227
+ }
228
+ }
229
+
230
+ if (maxTurns != null && maxTurns !== '') {
231
+ args.push('--max-autopilot-continues', String(maxTurns));
232
+ }
233
+
234
+ if (model) args.push('--model', String(model));
235
+
236
+ const mappedEffort = _mapEffort(effort);
237
+ if (mappedEffort) args.push('--effort', mappedEffort);
238
+
239
+ // Toggle flags — strict-true gating to avoid surprise opt-in from truthy
240
+ // strings or 1/0 numbers in config.
241
+ if (disableBuiltinMcps === true) args.push('--disable-builtin-mcps');
242
+ if (suppressAgentsMd === true) args.push('--no-custom-instructions');
243
+ if (reasoningSummaries === true) args.push('--enable-reasoning-summaries');
244
+
245
+ // --stream takes a value: 'on' or 'off'. Caller passes that exact value.
246
+ if (stream === 'on' || stream === 'off') {
247
+ args.push('--stream', stream);
248
+ }
249
+
250
+ // --resume uses the equals-form per Copilot help: --resume[=value]. Without
251
+ // the `=`, commander.js treats the next token as a positional, not the value.
252
+ if (sessionId) args.push(`--resume=${sessionId}`);
253
+
254
+ return args;
255
+ }
256
+
257
+ // ── Prompt Construction ─────────────────────────────────────────────────────
258
+ //
259
+ // Copilot has no --system-prompt-file flag, so we deliver the system prompt
260
+ // as a <system>...</system> block prepended to the user prompt. Mirrors the
261
+ // convention from Anthropic tool-use docs and is recognized as "system role"
262
+ // content by every model in the Copilot catalog.
263
+
264
+ function buildPrompt(promptText, sysPromptText) {
265
+ const user = promptText == null ? '' : String(promptText);
266
+ if (sysPromptText == null || sysPromptText === '') return user;
267
+ return `<system>\n${String(sysPromptText)}\n</system>\n\n${user}`;
268
+ }
269
+
270
+ // ── Output Parsing ──────────────────────────────────────────────────────────
271
+ //
272
+ // Whitelist of event types observed during the spike (docs/copilot-cli-schema.md
273
+ // §5.1). Any other type is wrapped as `{ type: 'ignore', original: <type> }` so
274
+ // downstream consumers can drop them without crashing.
275
+
276
+ const KNOWN_EVENT_TYPES = new Set([
277
+ 'session.mcp_server_status_changed',
278
+ 'session.mcp_servers_loaded',
279
+ 'session.skills_loaded',
280
+ 'session.tools_updated',
281
+ 'session.info',
282
+ 'session.task_complete',
283
+ 'user.message',
284
+ 'assistant.turn_start',
285
+ 'assistant.turn_end',
286
+ 'assistant.reasoning',
287
+ 'assistant.reasoning_delta',
288
+ 'assistant.message_delta',
289
+ 'assistant.message',
290
+ 'tool.execution_start',
291
+ 'tool.execution_complete',
292
+ 'result',
293
+ // Edge case observed once during stdin testing — appears to be a meta event
294
+ // for tool invocation. Allowlisted so it doesn't get marked 'ignore'.
295
+ 'function',
296
+ ]);
297
+
298
+ /**
299
+ * Parse the full JSONL output of a Copilot CLI invocation.
300
+ * Returns { text, usage, sessionId, model } — same shape as the Claude adapter
301
+ * so engine/lifecycle.js can consume both transparently.
302
+ *
303
+ * - text: concatenation of every `assistant.message.data.content` value
304
+ * across turns (multi-turn autopilot loops emit one per turn)
305
+ * - usage: mapped from the terminal `result` event. Copilot doesn't
306
+ * report cost/tokens — those fields are NULL, not 0, so the
307
+ * dashboard can distinguish "Copilot didn't tell us" from
308
+ * "this turn cost $0".
309
+ * - sessionId: from `result.sessionId` (camelCase — Copilot's spelling)
310
+ * - model: from the first `session.tools_updated.data.model` event
311
+ *
312
+ * `maxTextLength` tail-slices the concatenated text — VERDICTs / completion
313
+ * blocks live at the END of agent output, so we slice from the tail.
314
+ */
315
+ function parseOutput(raw, { maxTextLength = 0 } = {}) {
316
+ const safeRaw = raw == null ? '' : String(raw);
317
+ if (!safeRaw) return { text: '', usage: null, sessionId: null, model: null };
318
+
319
+ const messageContents = [];
320
+ let usage = null;
321
+ let sessionId = null;
322
+ let model = null;
323
+ let outputTokensTotal = 0;
324
+ let turnEndCount = 0;
325
+
326
+ for (const rawLine of safeRaw.split('\n')) {
327
+ const line = rawLine.trim();
328
+ if (!line || !line.startsWith('{')) continue;
329
+ let obj;
330
+ try { obj = JSON.parse(line); } catch { continue; }
331
+ if (!obj || typeof obj !== 'object') continue;
332
+
333
+ const type = obj.type;
334
+ if (type === 'assistant.message') {
335
+ const content = obj.data?.content;
336
+ if (typeof content === 'string' && content) messageContents.push(content);
337
+ const ot = obj.data?.outputTokens;
338
+ if (typeof ot === 'number') outputTokensTotal += ot;
339
+ } else if (type === 'assistant.turn_end') {
340
+ turnEndCount += 1;
341
+ } else if (type === 'session.tools_updated' && model == null) {
342
+ const m = obj.data?.model;
343
+ if (typeof m === 'string' && m) model = m;
344
+ } else if (type === 'result') {
345
+ if (typeof obj.sessionId === 'string') sessionId = obj.sessionId;
346
+ const u = obj.usage || {};
347
+ usage = {
348
+ // Cost / token fields are NULL — Copilot doesn't expose them.
349
+ // Mapping them to 0 would falsely suggest "this turn cost $0" in the
350
+ // dashboard cost telemetry.
351
+ costUsd: null,
352
+ inputTokens: null,
353
+ // outputTokens is recovered from per-turn assistant.message events
354
+ // since the result event itself doesn't report it.
355
+ outputTokens: outputTokensTotal > 0 ? outputTokensTotal : null,
356
+ cacheRead: null,
357
+ cacheCreation: null,
358
+ durationMs: typeof u.totalApiDurationMs === 'number' ? u.totalApiDurationMs : 0,
359
+ numTurns: turnEndCount,
360
+ // Copilot-specific extension — preserved alongside the standard shape
361
+ // so the engine can distinguish "this turn cost N premium requests"
362
+ // from token accounting on the Claude path.
363
+ premiumRequests: typeof u.premiumRequests === 'number' ? u.premiumRequests : 0,
364
+ sessionDurationMs: typeof u.sessionDurationMs === 'number' ? u.sessionDurationMs : 0,
365
+ };
366
+ }
367
+ }
368
+
369
+ let text = messageContents.join('');
370
+ if (maxTextLength && text.length > maxTextLength) {
371
+ text = text.slice(-maxTextLength);
372
+ }
373
+
374
+ return { text, usage, sessionId, model };
375
+ }
376
+
377
+ /**
378
+ * Parse a single line from the Copilot JSONL stream. Returns the parsed event
379
+ * object, or null when the line is empty / non-JSON.
380
+ *
381
+ * Unknown event types are NOT dropped — they're rewrapped with
382
+ * `{ type: 'ignore', original }` so consumers can log/track schema drift
383
+ * without crashing on a new event the spike didn't observe.
384
+ */
385
+ function parseStreamChunk(line) {
386
+ const trimmed = line == null ? '' : String(line).trim();
387
+ if (!trimmed || !trimmed.startsWith('{')) return null;
388
+ let obj;
389
+ try { obj = JSON.parse(trimmed); } catch { return null; }
390
+ if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return obj || null;
391
+ if (!KNOWN_EVENT_TYPES.has(obj.type)) {
392
+ return { type: 'ignore', original: obj.type, raw: obj };
393
+ }
394
+ return obj;
395
+ }
396
+
397
+ // ── Error Normalization ─────────────────────────────────────────────────────
398
+
399
+ function parseError(rawOutput) {
400
+ const text = rawOutput == null ? '' : String(rawOutput);
401
+ if (!text) return { message: '', code: null, retriable: true };
402
+ const lower = text.toLowerCase();
403
+
404
+ if (/not authenticated|copilot login|please.*log.*in|401|403 forbidden|unauthorized/i.test(text)) {
405
+ return { message: 'Copilot authentication failed', code: 'auth-failure', retriable: false };
406
+ }
407
+ if (/rate limit|too many requests|\b429\b/i.test(text)) {
408
+ return { message: 'Copilot rate limit hit', code: 'rate-limit', retriable: true };
409
+ }
410
+ if (/unknown model|model not found|model.*invalid|invalid model/i.test(text)) {
411
+ return { message: 'Copilot rejected the requested model', code: 'unknown-model', retriable: false };
412
+ }
413
+ if (/budget.*exceed|premium.*limit.*reach|quota.*exceed/i.test(lower)) {
414
+ return { message: 'Copilot premium-request budget exceeded', code: 'budget-exceeded', retriable: false };
415
+ }
416
+ if (/internal error|panic|uncaught|copilot.*crashed|fatal: copilot/i.test(lower)) {
417
+ return { message: 'Copilot CLI crashed', code: 'crash', retriable: true };
418
+ }
419
+ return { message: '', code: null, retriable: true };
420
+ }
421
+
422
+ // ── Model Discovery ─────────────────────────────────────────────────────────
423
+ //
424
+ // GET https://api.githubcopilot.com/models with a Bearer token.
425
+ // Token resolution priority:
426
+ // 1. process.env.GH_TOKEN
427
+ // 2. process.env.COPILOT_GITHUB_TOKEN
428
+ // 3. (best-effort) `gh auth token` — only if env is empty
429
+ //
430
+ // All failure modes (no token, network error, non-200 status, malformed JSON,
431
+ // no chat-type models in response) return null. Returning null tells the
432
+ // dashboard to fall back to free-text input.
433
+
434
+ function _resolveCopilotToken(env) {
435
+ if (env.GH_TOKEN) return env.GH_TOKEN.trim();
436
+ if (env.COPILOT_GITHUB_TOKEN) return env.COPILOT_GITHUB_TOKEN.trim();
437
+ try {
438
+ const out = _execSyncCapture('gh auth token', env, 5000).trim();
439
+ if (out) return out;
440
+ } catch { /* gh not installed or not authed */ }
441
+ return null;
442
+ }
443
+
444
+ function _httpsGetJson(url, headers, timeoutMs = 10000) {
445
+ return new Promise((resolve) => {
446
+ let parsed;
447
+ try { parsed = new URL(url); } catch { return resolve({ status: 0, body: null, error: 'invalid-url' }); }
448
+ const opts = {
449
+ method: 'GET',
450
+ hostname: parsed.hostname,
451
+ port: parsed.port || 443,
452
+ path: parsed.pathname + parsed.search,
453
+ headers,
454
+ timeout: timeoutMs,
455
+ };
456
+ const req = https.request(opts, (res) => {
457
+ let buf = '';
458
+ res.setEncoding('utf8');
459
+ res.on('data', (chunk) => { buf += chunk; });
460
+ res.on('end', () => {
461
+ let body = null;
462
+ try { body = JSON.parse(buf); } catch { /* non-JSON response */ }
463
+ resolve({ status: res.statusCode || 0, body, raw: buf });
464
+ });
465
+ });
466
+ req.on('error', (err) => resolve({ status: 0, body: null, error: err.message }));
467
+ req.on('timeout', () => { try { req.destroy(); } catch {} resolve({ status: 0, body: null, error: 'timeout' }); });
468
+ req.end();
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Fetch the Copilot model catalog. Returns `Promise<{id,name,provider}[] | null>`.
474
+ * `null` means "couldn't reach the API or the response wasn't usable" — the
475
+ * settings UI falls back to free-text input.
476
+ *
477
+ * Filters applied to `data[]`:
478
+ * - drop embedding-only models (capabilities.type !== 'chat')
479
+ * - drop disabled models (policy.state must be 'enabled' OR preview must be true)
480
+ */
481
+ async function listModels({ env = process.env, timeoutMs = 10000 } = {}) {
482
+ const token = _resolveCopilotToken(env);
483
+ if (!token) return null;
484
+ const result = await _httpsGetJson('https://api.githubcopilot.com/models', {
485
+ 'Authorization': `Bearer ${token}`,
486
+ 'Accept': 'application/json',
487
+ // The Copilot models API expects an editor identifier; the values mirror
488
+ // what the CLI itself sends so the API treats us like a normal client.
489
+ 'Editor-Version': 'vscode/1.95.0',
490
+ 'Editor-Plugin-Version': 'copilot/1.0.36',
491
+ 'User-Agent': 'GitHubCopilotChat/0.20.0',
492
+ }, timeoutMs);
493
+ if (result.status !== 200 || !result.body || !Array.isArray(result.body.data)) return null;
494
+
495
+ const models = [];
496
+ for (const m of result.body.data) {
497
+ if (!m || typeof m !== 'object') continue;
498
+ if (m.capabilities?.type !== 'chat') continue;
499
+ const enabled = m.policy?.state === 'enabled' || m.preview === true;
500
+ if (!enabled) continue;
501
+ models.push({ id: String(m.id), name: m.name ? String(m.name) : String(m.id), provider: m.vendor ? String(m.vendor) : '' });
502
+ }
503
+ if (models.length === 0) return null;
504
+ return models;
505
+ }
506
+
507
+ // ── Capability Block ────────────────────────────────────────────────────────
508
+
509
+ const capabilities = {
510
+ // JSONL events on stdout per --output-format json
511
+ streaming: true,
512
+ // --resume=<id> resumes a session
513
+ sessionResume: true,
514
+ // No --system-prompt-file flag — system prompt is merged into stdin
515
+ systemPromptFile: false,
516
+ // --effort low|medium|high|xhigh (no 'max' — adapter maps it)
517
+ effortLevels: true,
518
+ // result.usage carries premiumRequests count, no USD or tokens
519
+ costTracking: false,
520
+ // No 'sonnet'/'opus'/'haiku' shorthand — Copilot expects full model IDs
521
+ modelShorthands: false,
522
+ // GET https://api.githubcopilot.com/models works (verified during spike)
523
+ modelDiscovery: true,
524
+ // Stdin works in non-interactive mode; -p with >32KB hits Windows ARG_MAX
525
+ promptViaArg: false,
526
+ // No --max-budget-usd
527
+ budgetCap: false,
528
+ // No --bare (closest equivalent is --no-custom-instructions, gated separately)
529
+ bareMode: false,
530
+ // No --fallback-model
531
+ fallbackModel: false,
532
+ // Copilot manages session state internally in ~/.copilot/session-state/
533
+ sessionPersistenceControl: false,
534
+ };
535
+
536
+ // Install hint surfaced when `resolveBinary()` returns null. Covers all
537
+ // supported install paths so users on any platform see one actionable line.
538
+ // Standalone Copilot CLI (preferred path) is available via:
539
+ // - WinGet: winget install --id GitHub.cli && gh extension install github/gh-copilot
540
+ // - Homebrew: brew install gh && gh extension install github/gh-copilot
541
+ // - Direct: download from https://github.com/github/copilot-cli/releases
542
+ const INSTALL_HINT = 'install via WinGet (winget install --id GitHub.cli && gh extension install github/gh-copilot), Homebrew (brew install gh && gh extension install github/gh-copilot), or download standalone copilot from https://github.com/github/copilot-cli/releases';
543
+
544
+ module.exports = {
545
+ name: 'copilot',
546
+ capabilities,
547
+ resolveBinary,
548
+ capsFile: CAPS_FILE,
549
+ listModels,
550
+ modelsCache: MODELS_CACHE,
551
+ // Use the same wrapper as Claude — spawn-agent.js is runtime-agnostic per P-9c4f2d6a
552
+ spawnScript: path.join(ENGINE_DIR, 'spawn-agent.js'),
553
+ installHint: INSTALL_HINT,
554
+ buildArgs,
555
+ buildPrompt,
556
+ resolveModel,
557
+ parseOutput,
558
+ parseStreamChunk,
559
+ parseError,
560
+ // Exposed for unit tests — engine code MUST go through resolveRuntime + the
561
+ // adapter contract; never reach into these helpers directly.
562
+ _CLAUDE_SHORTHANDS,
563
+ _resetShorthandWarning,
564
+ _mapEffort,
565
+ KNOWN_EVENT_TYPES,
566
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * engine/runtimes/index.js — Runtime adapter registry.
3
+ *
4
+ * The registry is the single resolution point for everything CLI-runtime-
5
+ * specific in the engine. Engine code MUST go through `resolveRuntime(name)`
6
+ * — never `require('./runtimes/<name>')` directly — so a typo or unknown
7
+ * runtime name fails loudly with a clear list of registered options.
8
+ *
9
+ * Adding a new runtime:
10
+ * 1. Implement the full adapter contract documented in claude.js.
11
+ * 2. `registry.set('<name>', require('./<name>'));` below.
12
+ * 3. Expose its capabilities via the `/api/runtimes` endpoint (free).
13
+ *
14
+ * Engine code MUST gate behavior on `runtime.capabilities.*` flags, not on
15
+ * `runtime.name === 'claude'` comparisons. The whole point of this layer.
16
+ */
17
+
18
+ const registry = new Map();
19
+ registry.set('claude', require('./claude'));
20
+ registry.set('copilot', require('./copilot'));
21
+
22
+ /**
23
+ * Look up a runtime adapter by name. Throws when the name is unknown so
24
+ * misconfigurations surface immediately at dispatch time instead of producing
25
+ * silent fallbacks or undefined-method crashes deep inside spawn logic.
26
+ */
27
+ function resolveRuntime(name) {
28
+ const key = name == null ? 'claude' : String(name);
29
+ const adapter = registry.get(key);
30
+ if (!adapter) {
31
+ const known = Array.from(registry.keys()).sort().join(', ');
32
+ throw new Error(`Unknown runtime "${key}". Registered runtimes: ${known}`);
33
+ }
34
+ return adapter;
35
+ }
36
+
37
+ /**
38
+ * Return the names of every registered runtime, sorted. Used by the dashboard
39
+ * `/api/runtimes` endpoint and the CLI `--cli` validator.
40
+ */
41
+ function listRuntimes() {
42
+ return Array.from(registry.keys()).sort();
43
+ }
44
+
45
+ /**
46
+ * Register a runtime adapter. Exposed for tests and for downstream tooling
47
+ * that wants to register a custom runtime without editing this file.
48
+ */
49
+ function registerRuntime(name, adapter) {
50
+ if (!name || typeof name !== 'string') throw new Error('registerRuntime: name must be a non-empty string');
51
+ if (!adapter || typeof adapter !== 'object') throw new Error('registerRuntime: adapter must be an object');
52
+ registry.set(name, adapter);
53
+ }
54
+
55
+ module.exports = {
56
+ resolveRuntime,
57
+ listRuntimes,
58
+ registerRuntime,
59
+ // Exposed for tests — engine code MUST go through resolveRuntime/listRuntimes
60
+ _registry: registry,
61
+ };