@openagents-org/agent-launcher 0.2.143 → 0.2.146

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,796 @@
1
+ /**
2
+ * GitHub Copilot CLI adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges the OFFICIAL GitHub Copilot CLI — executable `copilot`, npm package
5
+ * `@github/copilot` — to an OpenAgents workspace. This is NOT the retired
6
+ * `gh copilot` GitHub CLI extension; this adapter never invokes `gh`.
7
+ *
8
+ * Per incoming workspace message it spawns one `copilot -p <prompt>
9
+ * --output-format=json` subprocess (cwd = the agent's working directory),
10
+ * parses the JSONL event stream via the standalone pure parser
11
+ * (./copilot-stream-parser), maps events onto workspace messages, persists the
12
+ * real Copilot session id per channel for resume, and can interrupt + clean up
13
+ * the whole process tree on demand.
14
+ *
15
+ * Design notes specific to Copilot CLI (do not copy Cline/Codex semantics
16
+ * blindly):
17
+ * • Auth is GitHub-based (env tokens / gh / keychain OAuth) — never read or
18
+ * log tokens; the launcher/installer reports auth state, the CLI's own run
19
+ * result is the final authority.
20
+ * • Permissions are explicit and least-privilege: scoped to the working dir
21
+ * via --add-dir, interactive prompts disabled (--no-ask-user) since the
22
+ * workspace cannot answer them. We never default to --allow-all / --yolo /
23
+ * --allow-all-paths / --allow-url.
24
+ * • plan mode → analysis only (--plan, no write tools); act mode → controlled
25
+ * read/write/shell within the working dir.
26
+ *
27
+ * ── Verification status ────────────────────────────────────────────────────
28
+ * The concrete CLI flags and JSONL `type` strings used here follow the
29
+ * documented `@github/copilot` interface and are CENTRALIZED (flags in
30
+ * `_buildArgs`, the JSONL schema in copilot-stream-parser). They must be
31
+ * confirmed against the locally installed build; see
32
+ * docs/agents/github-copilot-cli.md. Unknown events degrade gracefully rather
33
+ * than crashing a task.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ const fs = require('fs');
39
+ const os = require('os');
40
+ const path = require('path');
41
+ const { execSync, execFileSync, spawn } = require('child_process');
42
+
43
+ const BaseAdapter = require('./base');
44
+ const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
45
+ const { whichBinary, whereBinary, getEnhancedEnv, defaultAgentWorkdir } = require('../paths');
46
+ const { compareVersions } = require('../installer');
47
+ const { CopilotStreamParser, redactSensitive } = require('./copilot-stream-parser');
48
+
49
+ const IS_WINDOWS = process.platform === 'win32';
50
+
51
+ // Minimum Copilot CLI version the adapter can drive. Kept in sync with
52
+ // registry.json `install.min_version`. Basis: the GA 1.0 line is the first to
53
+ // stably provide everything the adapter relies on — `--output-format=json`
54
+ // (JSONL; introduced ~0.0.422), non-interactive `-p` with `--no-ask-user`,
55
+ // granular `--allow-tool`/`--add-dir`, `--resume`/`--name` session control, and
56
+ // `--secret-env-vars`/`--no-remote`. Verified end-to-end against 1.0.63.
57
+ const MIN_VERSION = '1.0.0';
58
+
59
+ // Token-bearing env var NAMES Copilot reads (verified precedence via
60
+ // `copilot help environment`): COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN.
61
+ // Plus OpenAgents' own workspace token. We pass the NAMES (never values) to
62
+ // `--secret-env-vars` so the CLI strips/redacts them, and use the list to scrub
63
+ // our own diagnostics. We never log their VALUES.
64
+ const SECRET_ENV_VARS = [
65
+ 'COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'OA_WORKSPACE_TOKEN',
66
+ ];
67
+
68
+ // Tool identifiers granted in act mode (least-privilege). VERIFIED against
69
+ // `copilot help permissions` (v1.0.63): `shell` (all shell commands) and `write`
70
+ // (file create/modify). Paths are scoped via --add-dir; we deliberately do NOT
71
+ // enable network/URL or all-paths access by default.
72
+ const ACT_ALLOW_TOOLS = ['shell', 'write'];
73
+
74
+ // How long to wait for a turn before giving up (no output / hung CLI).
75
+ const TURN_TIMEOUT_MS = 10 * 60 * 1000;
76
+
77
+ class CopilotAdapter extends BaseAdapter {
78
+ /**
79
+ * @param {object} opts - BaseAdapter opts plus:
80
+ * @param {Set} [opts.disabledModules]
81
+ */
82
+ constructor(opts) {
83
+ super(opts);
84
+ this.disabledModules = opts.disabledModules || new Set();
85
+
86
+ const env = this.agentEnv || process.env;
87
+ // Official model env var (verified via `copilot help environment`).
88
+ this._model = env.COPILOT_MODEL || '';
89
+ this._versionGate = null; // cached { version, compatible } from --version
90
+
91
+ // Per-channel real Copilot session reference (id or stable name) + live procs
92
+ this._channelSessions = {};
93
+ this._channelProcesses = {};
94
+ this._stoppingChannels = new Set();
95
+ this._sessionsFile = path.join(
96
+ os.homedir(), '.openagents', 'sessions',
97
+ `${this.workspaceId}_${this.agentName}_copilot.json`,
98
+ );
99
+ this._loadSessions();
100
+
101
+ this._copilotBin = this._findCopilotBinary();
102
+ if (this._copilotBin) {
103
+ this._log(`Copilot CLI: ${this._copilotBin}`);
104
+ } else {
105
+ this._log('Warning: copilot CLI not found (install: npm install -g @github/copilot)');
106
+ }
107
+ }
108
+
109
+ // ------------------------------------------------------------------
110
+ // Session persistence (per-channel Copilot session reference)
111
+ // ------------------------------------------------------------------
112
+
113
+ _loadSessions() {
114
+ try {
115
+ if (fs.existsSync(this._sessionsFile)) {
116
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
117
+ if (data && typeof data === 'object') {
118
+ Object.assign(this._channelSessions, data);
119
+ this._log(`Loaded ${Object.keys(data).length} session(s)`);
120
+ }
121
+ }
122
+ } catch {
123
+ this._log('Could not load sessions file, starting fresh');
124
+ }
125
+ }
126
+
127
+ _saveSessions() {
128
+ try {
129
+ fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
130
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
131
+ } catch {}
132
+ }
133
+
134
+ /**
135
+ * A stable, working-dir-bound session name for a channel. Binding the working
136
+ * directory into the name prevents a session created for one project from
137
+ * being resumed against another (the spec's "Session 与 working directory
138
+ * 绑定" / no cross-project resume requirement).
139
+ */
140
+ _stableSessionName(channel) {
141
+ const wd = this.workingDir || defaultAgentWorkdir(this.agentName);
142
+ const hash = require('crypto').createHash('sha1')
143
+ .update(`${wd} ${this.workspaceId} ${channel}`).digest('hex').slice(0, 12);
144
+ return `openagents-${hash}`;
145
+ }
146
+
147
+ // ------------------------------------------------------------------
148
+ // Binary discovery (multi-tier; never resolves the retired `gh` extension)
149
+ // ------------------------------------------------------------------
150
+
151
+ _findCopilotBinary() {
152
+ const home = os.homedir();
153
+ // Windows: npm shim is copilot.cmd; a winget/standalone install is copilot.exe.
154
+ const exts = IS_WINDOWS ? ['.cmd', '.exe', ''] : [''];
155
+ const named = (dir) => {
156
+ for (const e of exts) {
157
+ const c = path.join(dir, `copilot${e}`);
158
+ try { if (fs.existsSync(c)) return c; } catch {}
159
+ }
160
+ return null;
161
+ };
162
+
163
+ // Tier 0: isolated runtime prefix (~/.openagents/runtimes/copilot/.bin)
164
+ let hit = named(path.join(home, '.openagents', 'runtimes', 'copilot', 'node_modules', '.bin'));
165
+ if (hit) return hit;
166
+ // Tier 0b: legacy portable npm prefix
167
+ hit = named(path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin'));
168
+ if (hit) return hit;
169
+
170
+ // Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
171
+ // output + verifies existence so a non-ASCII/Chinese username isn't mangled
172
+ // into an ENOENT). Uses the enriched env (matches launcher/daemon PATH).
173
+ const viaWhere = whereBinary('copilot');
174
+ if (viaWhere) return viaWhere;
175
+
176
+ // Tier 2: next to the current Node interpreter (npm global)
177
+ hit = named(path.dirname(process.execPath));
178
+ if (hit) return hit;
179
+
180
+ // Tier 3: npm global prefix
181
+ try {
182
+ const npmPrefix = execSync('npm config get prefix', {
183
+ encoding: 'utf-8', timeout: 5000, windowsHide: true,
184
+ }).trim();
185
+ if (npmPrefix) {
186
+ hit = named(npmPrefix) || named(path.join(npmPrefix, 'bin'));
187
+ if (hit) return hit;
188
+ }
189
+ } catch {}
190
+
191
+ // Tier 4: common locations (Homebrew, user bins, winget links)
192
+ const candidates = IS_WINDOWS
193
+ ? [path.join(process.env.APPDATA || '', 'npm'),
194
+ path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links')]
195
+ : ['/opt/homebrew/bin', '/usr/local/bin',
196
+ path.join(home, '.local', 'bin'), path.join(home, '.npm-global', 'bin')];
197
+ for (const dir of candidates) {
198
+ hit = named(dir);
199
+ if (hit) return hit;
200
+ }
201
+
202
+ // Tier 5: deep cross-platform PATH scan (nvm/fnm/volta/etc.)
203
+ return whichBinary('copilot');
204
+ }
205
+
206
+ /**
207
+ * Resolve [file, prefixArgs] for spawning. On Windows an npm `.cmd` shim is
208
+ * resolved to `[node, entry.js]` so we can spawn shell:false and keep every
209
+ * argv element (notably the prompt) literal — no cmd.exe re-parsing, so no
210
+ * quoting/injection hazard from special characters in the prompt. A native
211
+ * `.exe` (winget/standalone) is spawned directly. On Unix the binary is spawned
212
+ * directly. Returns null if resolution is impossible.
213
+ */
214
+ _resolveExec(bin, isWindows = IS_WINDOWS) {
215
+ const lower = bin.toLowerCase();
216
+
217
+ // A direct JS entry must run via `node` on EVERY platform. Windows cannot
218
+ // spawn a `.js` (no shebang support, not a PE → `spawn UNKNOWN`); on Unix a
219
+ // shebang would work, but routing through node is equivalent and keeps
220
+ // behaviour identical cross-platform. This also covers test mocks and any
221
+ // install whose bin resolves straight to a JS file.
222
+ if (lower.endsWith('.js') || lower.endsWith('.cjs') || lower.endsWith('.mjs')) {
223
+ return [this._findNodeBin(), bin];
224
+ }
225
+
226
+ if (!isWindows) return [bin];
227
+
228
+ // Native executable — spawn directly (shell:false keeps argv literal).
229
+ if (lower.endsWith('.exe')) return [bin];
230
+
231
+ // npm/batch shim: resolve to its real target so we spawn shell:false and
232
+ // every argv element (notably the prompt) stays literal — no cmd.exe
233
+ // re-parsing, hence no quoting/injection hazard.
234
+ if (lower.endsWith('.cmd') || lower.endsWith('.bat')) {
235
+ try {
236
+ const content = fs.readFileSync(bin, 'utf-8');
237
+ const m = content.match(/%dp0%\\([^\s"*?]+\.[cm]?js)/i);
238
+ if (m) {
239
+ return [this._findNodeBin(), path.resolve(path.dirname(bin), m[1])];
240
+ }
241
+ const exe = content.match(/%dp0%\\([^\s"*?]+\.exe)/i);
242
+ if (exe) return [path.resolve(path.dirname(bin), exe[1])];
243
+ } catch {}
244
+ // Last resort: let cmd.exe run the shim. Args still travel as a literal
245
+ // argv array via spawn (shell:false), not a concatenated string.
246
+ return ['cmd.exe', '/c', bin];
247
+ }
248
+
249
+ // No extension (e.g. a Unix-style shim copied to Windows) — spawn directly.
250
+ return [bin];
251
+ }
252
+
253
+ _findNodeBin() {
254
+ const home = os.homedir();
255
+ const candidates = IS_WINDOWS
256
+ ? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
257
+ : [path.join(home, '.openagents', 'nodejs', 'bin', 'node'),
258
+ path.join(home, '.openagents', 'nodejs', 'node')];
259
+ for (const c of candidates) { try { if (fs.existsSync(c)) return c; } catch {} }
260
+ return process.execPath || 'node';
261
+ }
262
+
263
+ // ------------------------------------------------------------------
264
+ // Command construction (argv array — never a shell string)
265
+ // ------------------------------------------------------------------
266
+
267
+ /**
268
+ * Build the argument vector (excluding the executable). The prompt is always a
269
+ * single discrete argv element. Permissions are least-privilege and scoped to
270
+ * the working directory.
271
+ */
272
+ _buildArgs(prompt, channel, { skipResume = false } = {}) {
273
+ const wd = this.workingDir || defaultAgentWorkdir(this.agentName);
274
+ // Required-value flags take the space form; optional-value/variadic flags
275
+ // (--resume, --secret-env-vars, --allow-tool — declared `[=value]` /
276
+ // `[=value...]` in `copilot --help` v1.0.63) MUST use the `=` form so the
277
+ // next token isn't mistaken for a positional argument.
278
+ const args = ['-p', prompt, '--output-format', 'json', '--stream', 'on'];
279
+
280
+ if (this._model) args.push('--model', this._model);
281
+
282
+ // Scope filesystem access to the working dir only (no --allow-all-paths).
283
+ args.push('--add-dir', wd);
284
+
285
+ // Privacy: do not let the session be remote-controlled from GitHub web/mobile
286
+ // (verified flag `--no-remote`). We never pass --share / --share-gist.
287
+ args.push('--no-remote');
288
+
289
+ // The workspace cannot service interactive prompts, so disable them and
290
+ // pre-authorize a least-privilege tool set instead of --allow-all/--yolo.
291
+ // Tool IDs `shell` and `write` are verified against `copilot help permissions`.
292
+ args.push('--no-ask-user');
293
+ if (this._mode === 'plan') {
294
+ // Analysis/planning only — do not grant write capability.
295
+ args.push('--plan');
296
+ } else {
297
+ for (const t of ACT_ALLOW_TOOLS) args.push(`--allow-tool=${t}`);
298
+ }
299
+
300
+ // Defense-in-depth: tell Copilot which env-var NAMES to redact from its own
301
+ // output (NEVER the values). Only list vars actually present in the child
302
+ // env so we don't emit a meaningless argument.
303
+ const env = this.agentEnv || process.env;
304
+ const presentSecrets = SECRET_ENV_VARS.filter((k) => env[k]);
305
+ if (presentSecrets.length) args.push(`--secret-env-vars=${presentSecrets.join(',')}`);
306
+
307
+ // Resume the channel's prior session when we have a reference, else seed a
308
+ // stable, working-dir-bound name we can resume by (verified: --resume matches
309
+ // session id / task id / id-prefix / name, exact case-insensitive).
310
+ const ref = this._channelSessions[channel];
311
+ if (ref && !skipResume) {
312
+ args.push(`--resume=${ref}`);
313
+ } else if (!ref) {
314
+ args.push('--name', this._stableSessionName(channel));
315
+ }
316
+ return args;
317
+ }
318
+
319
+ _buildSystemContext(channelName) {
320
+ return buildOpenclawSystemPrompt({
321
+ agentName: this.agentName,
322
+ workspaceId: this.workspaceId,
323
+ channelName,
324
+ endpoint: this.endpoint,
325
+ token: this.token,
326
+ mode: this._mode,
327
+ disabledModules: this.disabledModules,
328
+ });
329
+ }
330
+
331
+ // ------------------------------------------------------------------
332
+ // Control actions (stop / interrupt)
333
+ // ------------------------------------------------------------------
334
+
335
+ async _onControlAction(action, payload) {
336
+ if (action === 'stop') {
337
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
338
+ if (channel && this._channelProcesses[channel]) {
339
+ this._stoppingChannels.add(channel);
340
+ await this._stopProcess(this._channelProcesses[channel]);
341
+ delete this._channelProcesses[channel];
342
+ delete this._channelQueues[channel];
343
+ try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
344
+ } else {
345
+ for (const [ch, proc] of Object.entries(this._channelProcesses)) {
346
+ this._stoppingChannels.add(ch);
347
+ await this._stopProcess(proc);
348
+ delete this._channelProcesses[ch];
349
+ try { await this.sendStatus(ch, 'Execution stopped by user'); } catch {}
350
+ }
351
+ }
352
+ return;
353
+ }
354
+ await super._onControlAction(action, payload);
355
+ }
356
+
357
+ /**
358
+ * Daemon shutdown: tear down any in-flight Copilot subprocess so the thread
359
+ * doesn't get stuck showing "running".
360
+ */
361
+ stop() {
362
+ for (const proc of Object.values(this._channelProcesses)) {
363
+ this._stopProcess(proc).catch(() => {});
364
+ }
365
+ super.stop();
366
+ }
367
+
368
+ /**
369
+ * Graceful → forceful interrupt of a Copilot subprocess and its whole process
370
+ * tree (Copilot may spawn shell/MCP children). SIGINT first so the CLI can
371
+ * cancel managed work, then SIGTERM/SIGKILL on the process group (Unix) or
372
+ * taskkill /T (Windows).
373
+ */
374
+ async _stopProcess(proc) {
375
+ if (!proc || proc.exitCode !== null) return;
376
+ try {
377
+ if (IS_WINDOWS) {
378
+ try { proc.kill('SIGINT'); } catch {}
379
+ const exited = await this._waitExit(proc, 1500);
380
+ if (!exited) {
381
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000, windowsHide: true }); } catch {}
382
+ }
383
+ } else {
384
+ try { process.kill(-proc.pid, 'SIGINT'); } catch { try { proc.kill('SIGINT'); } catch {} }
385
+ let exited = await this._waitExit(proc, 1500);
386
+ if (!exited) {
387
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
388
+ exited = await this._waitExit(proc, 2000);
389
+ }
390
+ if (!exited) {
391
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
392
+ await this._waitExit(proc, 1000);
393
+ }
394
+ }
395
+ } catch {}
396
+ }
397
+
398
+ _waitExit(proc, ms) {
399
+ return new Promise((resolve) => {
400
+ if (proc.exitCode !== null) return resolve(true);
401
+ const t = setTimeout(() => resolve(false), ms);
402
+ proc.once('exit', () => { clearTimeout(t); resolve(true); });
403
+ });
404
+ }
405
+
406
+ // ------------------------------------------------------------------
407
+ // Message handler
408
+ // ------------------------------------------------------------------
409
+
410
+ async _handleMessage(msg) {
411
+ const content = (msg.content || '').trim();
412
+ if (!content) return;
413
+
414
+ const channel = msg.sessionId || this.channelName;
415
+ this._stoppingChannels.delete(channel);
416
+ const sender = msg.senderName || msg.senderType || 'user';
417
+ this._log(`Processing message from ${sender} in ${channel}: ${redactSensitive(content).slice(0, 80)}...`);
418
+
419
+ if (!this._copilotBin) {
420
+ await this.sendError(channel,
421
+ 'GitHub Copilot CLI not found. Install it with: npm install -g @github/copilot');
422
+ return;
423
+ }
424
+
425
+ // Hard version gate BEFORE spawning a turn: an older CLI lacks the flags /
426
+ // JSONL output this adapter requires, so refuse rather than emit a confusing
427
+ // "unknown option" failure on every run.
428
+ const gate = this._checkVersionGate();
429
+ if (gate.compatible === false) {
430
+ await this.sendError(channel,
431
+ `GitHub Copilot CLI ${gate.version} is too old — this integration requires ${MIN_VERSION} or newer. Upgrade with: copilot update (or npm install -g @github/copilot).`);
432
+ return;
433
+ }
434
+
435
+ // Working directory must exist — never silently fall back to the repo cwd.
436
+ const wd = this.workingDir;
437
+ if (wd && !this._dirExists(wd)) {
438
+ await this.sendError(channel, `Working directory does not exist: ${wd}`);
439
+ return;
440
+ }
441
+
442
+ await this._autoTitleChannel(channel, content);
443
+ await this.sendStatus(channel, 'thinking...');
444
+
445
+ const fullPrompt = `${this._buildSystemContext(channel)}\n\n---\n\nUser message:\n${content}`;
446
+
447
+ // Up to 2 attempts: resume first, then a fresh session if resume was stale.
448
+ for (let attempt = 0; attempt < 2; attempt++) {
449
+ const skipResume = attempt > 0;
450
+ const args = this._buildArgs(fullPrompt, channel, { skipResume });
451
+ this._logSpawn(channel, args, skipResume);
452
+
453
+ let result;
454
+ try {
455
+ result = await this._runTurn(channel, args);
456
+ } catch (e) {
457
+ await this.sendError(channel, `Error: ${redactSensitive(e.message)}`);
458
+ return;
459
+ }
460
+
461
+ if (this._stoppingChannels.has(channel)) return; // user interrupted
462
+
463
+ // Stale resume → retry once without it.
464
+ if (result.staleSession && attempt === 0 && this._channelSessions[channel]) {
465
+ this._log(`Stale session for ${channel}; retrying with a fresh session`);
466
+ delete this._channelSessions[channel];
467
+ this._saveSessions();
468
+ continue;
469
+ }
470
+
471
+ if (result.errorMessage) {
472
+ await this.sendError(channel, result.errorMessage);
473
+ return;
474
+ }
475
+ const text = (result.finalText || '').trim();
476
+ if (text) {
477
+ await this.sendResponse(channel, text);
478
+ } else if (result.timedOut) {
479
+ await this.sendError(channel, 'Copilot CLI timed out before producing a response.');
480
+ } else {
481
+ await this.sendResponse(channel, 'No response generated. Please try again.');
482
+ }
483
+ return;
484
+ }
485
+ }
486
+
487
+ _dirExists(p) {
488
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
489
+ }
490
+
491
+ /**
492
+ * Pre-launch version gate. Runs `copilot --version` once (cached), parses the
493
+ * version, and compares against MIN_VERSION. Returns
494
+ * { version, compatible: true|false|null }:
495
+ * • compatible:false → below MIN_VERSION, callers must not spawn.
496
+ * • compatible:null → version unparseable/undetectable ("unknown"); we do
497
+ * NOT block (let the run proceed; runtime errors will surface real issues).
498
+ * Mirrors the installer's generic gate semantics but lives in the adapter so a
499
+ * too-old CLI never gets a turn spawned against it.
500
+ */
501
+ _checkVersionGate() {
502
+ if (this._versionGate) return this._versionGate;
503
+ let version = null;
504
+ try {
505
+ // Args as an array (no shell) so paths with spaces/backslashes are safe on
506
+ // every platform. _resolveExec already normalizes a Windows .cmd shim.
507
+ const [file, ...prefix] = this._resolveExec(this._copilotBin);
508
+ const raw = execFileSync(file, [...prefix, '--version'], {
509
+ encoding: 'utf-8', timeout: 8000, windowsHide: true, env: getEnhancedEnv(),
510
+ }).trim();
511
+ const m = raw.match(/(\d+\.\d+\.\d+)/);
512
+ version = m ? m[1] : null;
513
+ } catch {}
514
+ let compatible;
515
+ if (!version) compatible = null;
516
+ else compatible = compareVersions(version, MIN_VERSION) >= 0;
517
+ this._versionGate = { version, compatible };
518
+ return this._versionGate;
519
+ }
520
+
521
+ _logSpawn(channel, args, skipResume) {
522
+ // Log a redacted, prompt-free shape of the command. The prompt is replaced
523
+ // with a placeholder; tokens never appear in argv but redact defensively.
524
+ const shown = [];
525
+ for (let i = 0; i < args.length; i++) {
526
+ if (args[i] === '-p') { shown.push('-p', '<prompt>'); i++; continue; }
527
+ shown.push(args[i]);
528
+ }
529
+ this._log(`Spawning copilot ${redactSensitive(shown.join(' '))}${skipResume ? ' (no-resume retry)' : ''}`);
530
+ }
531
+
532
+ /**
533
+ * Run one Copilot turn: spawn the subprocess, stream-parse stdout, map events
534
+ * onto workspace messages, capture the session id, and resolve a summary of
535
+ * the turn. Never rejects on CLI failure — failures come back as
536
+ * `errorMessage` / `staleSession` / `timedOut` so the caller can react.
537
+ */
538
+ _runTurn(channel, args) {
539
+ const [file, ...prefix] = this._resolveExec(this._copilotBin);
540
+ const env = getEnhancedEnv({ ...(this.agentEnv || process.env) });
541
+ const cwd = this.workingDir || defaultAgentWorkdir(this.agentName);
542
+
543
+ return new Promise((resolve) => {
544
+ let proc;
545
+ try {
546
+ proc = spawn(file, [...prefix, ...args], {
547
+ stdio: ['ignore', 'pipe', 'pipe'],
548
+ env,
549
+ cwd,
550
+ detached: !IS_WINDOWS,
551
+ windowsHide: true,
552
+ });
553
+ } catch (e) {
554
+ return resolve({ errorMessage: `Failed to launch Copilot CLI: ${redactSensitive(e.message)}` });
555
+ }
556
+ this._channelProcesses[channel] = proc;
557
+
558
+ // Guard the child's stdio streams against 'error'. When the process is
559
+ // SIGKILL'd on stop, macOS Node can emit a benign EPIPE/ECONNRESET 'error'
560
+ // on stdout/stderr a tick later; with a 'data' listener but no 'error'
561
+ // listener that becomes an uncaughtException which crashes the
562
+ // (single-process, e.g. Node 18) test run with exit 1 and no `not ok`.
563
+ // No-op — data handling below is unchanged.
564
+ if (proc.stdout) proc.stdout.on('error', () => {});
565
+ if (proc.stderr) proc.stderr.on('error', () => {});
566
+
567
+ const parser = new CopilotStreamParser();
568
+ // Final-answer accumulation. `finalParts` holds authoritative `text`
569
+ // events; `deltaBuf` holds streamed `text_delta` text for the current
570
+ // contiguous block. A final `text` event SUPERSEDES the deltas of its
571
+ // block (deltas are the streamed pieces of the same message) so the answer
572
+ // is never duplicated. A tool/file/shell event between text starts a new
573
+ // block (only the last contiguous block becomes the answer; earlier text
574
+ // was shown live as thinking).
575
+ const finalParts = [];
576
+ let deltaBuf = '';
577
+ const accumulatedText = () => (finalParts.length ? finalParts.join('\n') : deltaBuf).trim();
578
+ let sawToolSinceText = false;
579
+ let errorEvent = null;
580
+ let stderrBuf = '';
581
+ let pending = Promise.resolve();
582
+ let settled = false;
583
+
584
+ const timeout = setTimeout(() => {
585
+ this._stopProcess(proc).catch(() => {});
586
+ finish({ timedOut: true, finalText: accumulatedText() });
587
+ }, TURN_TIMEOUT_MS);
588
+
589
+ const finish = (summary) => {
590
+ if (settled) return;
591
+ settled = true;
592
+ clearTimeout(timeout);
593
+ delete this._channelProcesses[channel];
594
+ resolve(summary);
595
+ };
596
+
597
+ const handle = async (ev) => {
598
+ switch (ev.kind) {
599
+ case 'session':
600
+ if (ev.sessionId) {
601
+ this._channelSessions[channel] = ev.sessionId;
602
+ this._saveSessions();
603
+ }
604
+ break;
605
+ case 'text_delta':
606
+ // Streamed interim text is shown live as `thinking`; the final
607
+ // `text` event (if any) is the authoritative answer.
608
+ if (sawToolSinceText) { finalParts.length = 0; deltaBuf = ''; sawToolSinceText = false; }
609
+ if (ev.text) { deltaBuf += ev.text; try { await this.sendThinking(channel, ev.text); } catch {} }
610
+ break;
611
+ case 'text':
612
+ // A complete message supersedes the deltas streamed for this block.
613
+ if (sawToolSinceText) { finalParts.length = 0; sawToolSinceText = false; }
614
+ deltaBuf = '';
615
+ if (ev.text) finalParts.push(ev.text);
616
+ break;
617
+ case 'reasoning':
618
+ if (ev.text) { try { await this.sendThinking(channel, ev.text); } catch {} }
619
+ break;
620
+ case 'tool_start':
621
+ sawToolSinceText = true;
622
+ try { await this.sendStatus(channel, this._describeTool(ev)); } catch {}
623
+ break;
624
+ case 'shell':
625
+ sawToolSinceText = true;
626
+ try {
627
+ const ec = (ev.exitCode != null) ? ` (exit ${ev.exitCode})` : '';
628
+ await this.sendStatus(channel, `**Running:** \`${redactSensitive(String(ev.command)).slice(0, 200)}\`${ec}`);
629
+ } catch {}
630
+ break;
631
+ case 'file_change':
632
+ sawToolSinceText = true;
633
+ try { await this.sendStatus(channel, `**${ev.action === 'read' ? 'Reading' : 'Editing'}:** \`${ev.path}\``); } catch {}
634
+ break;
635
+ case 'tool_result':
636
+ // Tool output is operational detail, not an assistant reply — keep it
637
+ // out of the chat body; just log a redacted note.
638
+ this._log(`tool_result ${ev.tool}${ev.isError ? ' (error)' : ''}`);
639
+ break;
640
+ case 'permission':
641
+ try { await this.sendStatus(channel, `Permission ${ev.granted ? 'granted' : 'denied'}: ${redactSensitive(ev.detail).slice(0, 160)}`); } catch {}
642
+ break;
643
+ case 'ask_user':
644
+ // We launched with --no-ask-user; if a prompt still surfaces, the
645
+ // workspace can't answer it. Record it so the turn fails clearly.
646
+ errorEvent = errorEvent || { message: 'Copilot requested interactive input, which the workspace cannot provide. Re-run with a more specific task.' };
647
+ break;
648
+ case 'usage':
649
+ this._log(`usage model=${ev.model || '?'}`);
650
+ break;
651
+ case 'error':
652
+ errorEvent = errorEvent || { message: ev.message, code: ev.code };
653
+ break;
654
+ case 'done':
655
+ break;
656
+ case 'unknown':
657
+ // Preserve a redacted diagnostic; never crash the task.
658
+ this._log(`unknown event: ${ev.raw}`);
659
+ break;
660
+ default:
661
+ break;
662
+ }
663
+ };
664
+
665
+ proc.stdout.on('data', (chunk) => {
666
+ for (const ev of parser.push(chunk)) {
667
+ pending = pending.then(() => handle(ev)).catch(() => {});
668
+ }
669
+ });
670
+ if (proc.stderr) {
671
+ proc.stderr.on('data', (c) => { stderrBuf += c.toString('utf-8'); });
672
+ }
673
+
674
+ proc.on('error', (err) => {
675
+ finish({ errorMessage: `Copilot CLI failed to start: ${redactSensitive(err.message)}` });
676
+ });
677
+
678
+ proc.on('exit', async (code) => {
679
+ try { for (const ev of parser.flush()) await handle(ev); } catch {}
680
+ try { await pending; } catch {}
681
+
682
+ if (this._stoppingChannels.has(channel)) {
683
+ return finish({ interrupted: true, finalText: accumulatedText() });
684
+ }
685
+
686
+ const finalText = accumulatedText();
687
+ const classified = this._classifyOutcome(code, errorEvent, stderrBuf);
688
+ if (classified) {
689
+ // A resume against a vanished session: surface as stale so the caller
690
+ // retries fresh instead of erroring the user.
691
+ if (classified.staleSession) return finish({ staleSession: true });
692
+ // If the CLI still produced a usable answer, prefer it over a soft error.
693
+ if (finalText && !classified.hard) return finish({ finalText });
694
+ return finish({ errorMessage: classified.message });
695
+ }
696
+ finish({ finalText });
697
+ });
698
+ });
699
+ }
700
+
701
+ _describeTool(ev) {
702
+ let preview = '';
703
+ const inp = ev.input;
704
+ if (inp && typeof inp === 'object') {
705
+ preview = inp.command || inp.path || inp.file || inp.file_path || inp.pattern
706
+ || inp.query || inp.url || (typeof inp.content === 'string' ? inp.content.slice(0, 80) : '')
707
+ || JSON.stringify(inp).slice(0, 120);
708
+ } else if (inp != null) {
709
+ preview = String(inp).slice(0, 120);
710
+ }
711
+ return `${ev.tool} › ${redactSensitive(String(preview))}`;
712
+ }
713
+
714
+ // ------------------------------------------------------------------
715
+ // Error classification (based on event, exit code, and stderr)
716
+ // ------------------------------------------------------------------
717
+
718
+ /**
719
+ * Turn a turn's terminal signals into a concise, actionable user message and
720
+ * a stale-session hint. Returns null when the turn succeeded. Detailed,
721
+ * redacted text only goes to the dev log.
722
+ *
723
+ * String patterns are anchored on REAL Copilot CLI v1.0.63 stderr (see
724
+ * test/fixtures/copilot-cli-real-samples.md) — note auth/session/network
725
+ * failures in non-interactive mode print to stderr with EMPTY stdout (no JSONL
726
+ * error event), so classification is driven by stderr + exit code. Broader
727
+ * synonyms are kept as forward-compatible fallbacks.
728
+ */
729
+ _classifyOutcome(exitCode, errorEvent, stderr) {
730
+ const blob = redactSensitive(`${errorEvent ? (errorEvent.message || '') + ' ' + (errorEvent.code || '') : ''} ${stderr || ''}`).trim();
731
+ const ok = (exitCode === 0 || exitCode == null) && !errorEvent;
732
+ if (ok) return null;
733
+
734
+ if (blob) this._log(`Copilot failure (exit=${exitCode}): ${blob.slice(0, 400)}`);
735
+
736
+ const has = (re) => re.test(blob);
737
+
738
+ // Session resume miss → retry fresh (not a user-facing error).
739
+ // Real: "Error: No session, task, or name matched 'NAME'."
740
+ if (has(/no session,? .*matched|session\s+(not\s+found|expired|unknown|invalid)|no such session/i)) {
741
+ return { staleSession: true };
742
+ }
743
+ // Token present but REJECTED — must be checked BEFORE the no-credentials rule
744
+ // because the real CLI appends the SAME "To authenticate … gh auth login"
745
+ // help block to both messages; only the leading line distinguishes them.
746
+ // Real: "Authentication token found but could not be validated. Failed to
747
+ // fetch PAT user login (401): ... Bad credentials".
748
+ if (has(/could not be validated|bad credentials|authentication token found but|token.*(expired|revoked|invalid)|invalid.*token|\b401\b|unauthorized/i)) {
749
+ return { hard: true, message: 'GitHub Copilot token is invalid, expired, or revoked (401). Re-authenticate with a fine-grained token that has the "Copilot Requests" permission, or run `copilot` and use /login. (Classic ghp_ tokens are not supported.)' };
750
+ }
751
+ // No credentials at all. Real distinguishing line: "No authentication
752
+ // information found." (Do NOT key on the shared "gh auth login" tail.)
753
+ if (has(/no authentication information|not (logged|signed) in|no (credentials|token)\b|login required/i)) {
754
+ return { hard: true, message: 'Not signed in to GitHub Copilot. Set COPILOT_GITHUB_TOKEN (or GH_TOKEN/GITHUB_TOKEN), run `gh auth login`, or run `copilot` and use /login.' };
755
+ }
756
+ if (has(/\b403\b|forbidden|sso|saml|single sign-on|not authorized for/i)) {
757
+ return { hard: true, message: 'Access denied (403). This is usually SAML/SSO not authorized for your token, or insufficient token scope for Copilot CLI.' };
758
+ }
759
+ if (has(/organization|enterprise|policy|disabled by|not allowed by|blocked by/i)) {
760
+ return { hard: true, message: 'GitHub Copilot CLI is blocked by an organization/enterprise policy for this account.' };
761
+ }
762
+ if (has(/subscription|not entitled|no copilot|copilot.*(unavailable|inactive)|no seat|seat/i)) {
763
+ return { hard: true, message: 'No active GitHub Copilot subscription/seat for this account.' };
764
+ }
765
+ // GitHub Enterprise host misconfiguration (GH_HOST / COPILOT_GH_HOST / --host).
766
+ if (has(/gh_host|copilot_gh_host|enterprise.*host|host.*not.*found|unknown host|could not resolve host|invalid host/i)) {
767
+ return { hard: true, message: 'GitHub host configuration error. Check GH_HOST / COPILOT_GH_HOST (GitHub Enterprise data-residency hostname).' };
768
+ }
769
+ // BYOK custom provider misconfiguration (COPILOT_PROVIDER_*).
770
+ if (has(/copilot_provider|provider.*(base.?url|api.?key|not configured|invalid)|byok/i)) {
771
+ return { hard: true, message: 'Custom model provider (BYOK) is misconfigured. Check COPILOT_PROVIDER_BASE_URL / COPILOT_PROVIDER_API_KEY / COPILOT_PROVIDER_TYPE.' };
772
+ }
773
+ if (has(/model.*(not found|unavailable|unsupported)|unknown model|invalid model/i)) {
774
+ return { hard: true, message: 'The requested model is unavailable for this account. Clear COPILOT_MODEL or pick a supported model.' };
775
+ }
776
+ if (has(/rate limit|429|too many requests|quota|credit/i)) {
777
+ return { hard: true, message: 'Rate limited or out of Copilot credits. Please wait and try again.' };
778
+ }
779
+ if (has(/check your network|network|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|getaddrinfo|socket hang up|503|502|service unavailable/i)) {
780
+ return { hard: true, message: 'Network or GitHub service error reaching Copilot. Check connectivity and try again.' };
781
+ }
782
+ if (has(/permission|not authorized|denied|not allowed to (write|read|run)/i)) {
783
+ return { hard: true, message: 'Copilot was denied a required permission (path/tool). The task may need access outside the working directory.' };
784
+ }
785
+ if (errorEvent && errorEvent.message) {
786
+ return { hard: true, message: `Copilot error: ${redactSensitive(errorEvent.message).slice(0, 300)}` };
787
+ }
788
+ if (exitCode && exitCode !== 0) {
789
+ return { hard: false, message: `Copilot CLI exited with code ${exitCode}.` };
790
+ }
791
+ return null;
792
+ }
793
+ }
794
+
795
+ module.exports = CopilotAdapter;
796
+ module.exports.MIN_VERSION = MIN_VERSION;