@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.
- package/CHANGELOG.md +5 -0
- package/bin/minions.js +5 -3
- package/dashboard/js/settings.js +216 -22
- package/dashboard.js +135 -8
- package/docs/copilot-cli-schema.md +637 -0
- package/docs/copilot-output-sample-claude.jsonl +72 -0
- package/docs/copilot-output-sample-default.jsonl +26 -0
- package/docs/copilot-output-sample-gpt4o.jsonl +23 -0
- package/engine/cli.js +250 -18
- package/engine/lifecycle.js +14 -9
- package/engine/llm.js +346 -94
- package/engine/model-discovery.js +167 -0
- package/engine/preflight.js +247 -19
- package/engine/runtimes/claude.js +413 -0
- package/engine/runtimes/copilot.js +566 -0
- package/engine/runtimes/index.js +61 -0
- package/engine/shared.js +299 -63
- package/engine/spawn-agent.js +265 -181
- package/engine.js +118 -31
- package/package.json +1 -1
|
@@ -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
|
+
};
|