@openagents-org/agent-launcher 0.2.143 → 0.2.145

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.
@@ -18,7 +18,7 @@ const { execSync, spawn } = require('child_process');
18
18
  const BaseAdapter = require('./base');
19
19
  const { formatAttachmentsForPrompt } = require('./utils');
20
20
  const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt } = require('./workspace-prompt');
21
- const { whichBinary, getEnhancedEnv } = require('../paths');
21
+ const { whichBinary, whereBinary } = require('../paths');
22
22
 
23
23
  const IS_WINDOWS = process.platform === 'win32';
24
24
 
@@ -26,6 +26,48 @@ const IS_WINDOWS = process.platform === 'win32';
26
26
  // child with SIGTERM, the 'close' handler sees signal='SIGTERM' / code=null.
27
27
  const TIMEOUT_MS = 300000; // 5 minutes
28
28
 
29
+ // Pinned OpenCode CLI version. Do NOT use @latest anywhere (registry.json /
30
+ // opencode.yaml install commands, install hints below). This adapter parses
31
+ // opencode's `--format json` stream, so an unbounded version floats the event
32
+ // schema (text / tool / error / step events) out from under us and makes the
33
+ // same workspace behave differently per machine.
34
+ const OPENCODE_PINNED_VERSION = '1.17.11';
35
+ // Supported floor — below this we block the run with `unsupported_version`.
36
+ // The 1.17.x line is the stream-json shape this adapter targets.
37
+ const OPENCODE_MIN_VERSION = '1.17.0';
38
+ // Highest version whose JSON behavior is treated as verified. Newer is allowed
39
+ // but flagged 'degraded' (the run still proceeds; any failure is classified).
40
+ const OPENCODE_TESTED_MAX_VERSION = '1.17.11';
41
+
42
+ // How long a detected CLI version / executability probe is cached on the
43
+ // adapter instance, so preflight never spawns `--version` more than once per
44
+ // window across channels.
45
+ const VERSION_PROBE_TTL_MS = 60000;
46
+
47
+ // Short, de-identified, actionable user messages per failure category. These
48
+ // must NEVER default to "run auth login": only credential_missing / auth_failed
49
+ // mention authentication. Diagnostics (exit code, signal, redacted stdout/
50
+ // stderr) go to the daemon log, not to these.
51
+ const FAILURE_MESSAGES = {
52
+ cli_not_found: `OpenCode CLI not found. Install it (\`npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}\`) and try again.`,
53
+ cli_not_executable: 'OpenCode CLI was found but could not be started. Reinstall it, then retry.',
54
+ unsupported_version: `This OpenCode CLI version is not supported (requires >= ${OPENCODE_MIN_VERSION}). Reinstall \`opencode-ai@${OPENCODE_PINNED_VERSION}\` and retry.`,
55
+ model_missing: "No model is configured for OpenCode. Set a model (LLM_MODEL, e.g. `gpt-4o`) in this agent's configuration, then retry.",
56
+ credential_missing: 'OpenCode has no API key or sign-in configured. Add an API key (LLM_API_KEY) or run `opencode auth login`, then retry.',
57
+ auth_failed: "OpenCode's provider rejected the credentials (authentication failed). Check the API key or sign-in and retry.",
58
+ provider_not_configured: 'OpenCode has no usable provider configured. Configure a provider and model, then retry.',
59
+ model_not_found: 'The configured model was not found or is not accessible. Check the model name and your access, then retry.',
60
+ rate_limited: 'The provider is rate-limiting requests. Wait a moment and retry.',
61
+ network_error: 'OpenCode could not reach the provider (network or service error). Retry shortly.',
62
+ provider_server_error: 'The provider returned a server error. Retry shortly.',
63
+ timeout: 'OpenCode timed out before producing a reply. Retry; if it persists, check the model and provider configuration.',
64
+ stream_parse_error: 'OpenCode produced output this version could not parse. Update to a supported OpenCode version, or open diagnostics.',
65
+ empty_response: 'OpenCode finished without producing a final reply. Retry; if it persists, open diagnostics.',
66
+ process_crashed: 'OpenCode exited unexpectedly. Open diagnostics or retry.',
67
+ cwd_unavailable: "OpenCode's working directory is not accessible. Check the agent home directory's permissions.",
68
+ unknown_error: 'OpenCode failed for an undetermined reason. Open diagnostics or retry.',
69
+ };
70
+
29
71
  class OpenCodeAdapter extends BaseAdapter {
30
72
  /**
31
73
  * @param {object} opts - BaseAdapter opts plus:
@@ -53,7 +95,7 @@ class OpenCodeAdapter extends BaseAdapter {
53
95
  if (this._opencodeBinary) {
54
96
  this._log(`Using OpenCode subprocess mode: ${this._opencodeBinary}`);
55
97
  } else {
56
- this._log('OpenCode binary not found. Install with: npm install -g opencode-ai@latest');
98
+ this._log(`OpenCode binary not found. Install with: npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}`);
57
99
  }
58
100
  }
59
101
 
@@ -188,19 +230,12 @@ class OpenCodeAdapter extends BaseAdapter {
188
230
  // Tier 1: PATH. Use the ENRICHED env (node-version-manager/homebrew/npm
189
231
  // dirs the launcher adds) so the lookup matches a packaged daemon's real
190
232
  // reach; windowsHide stops a console window from flashing on Windows.
191
- try {
192
- const env = getEnhancedEnv();
193
- if (IS_WINDOWS) {
194
- const r = execSync('where opencode.exe 2>nul || where opencode.cmd 2>nul || where opencode 2>nul', {
195
- encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
196
- });
197
- const hit = r.split(/\r?\n/)[0].trim();
198
- if (hit) return hit;
199
- } else {
200
- const hit = execSync('which opencode', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
201
- if (hit) return hit;
202
- }
203
- } catch {}
233
+ // Codepage-safe lookup (whereBinary forces UTF-8 output + verifies existence
234
+ // so a non-ASCII/Chinese username isn't mangled into an ENOENT). Uses the
235
+ // ENRICHED env so a packaged daemon's minimal PATH still reaches nvm/fnm/
236
+ // volta/homebrew/npm dirs.
237
+ const viaWhere = whereBinary('opencode');
238
+ if (viaWhere) return viaWhere;
204
239
 
205
240
  // Tier 2: Next to Node.js
206
241
  const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`);
@@ -258,23 +293,22 @@ class OpenCodeAdapter extends BaseAdapter {
258
293
  return;
259
294
  }
260
295
 
296
+ // The only way _runOpencode resolves empty now is the stop path (handled
297
+ // above). Every real failure — including exit 0 with no assistant text —
298
+ // is thrown with a category and handled in catch, so we no longer post a
299
+ // generic "produced no response" that misattributes everything to auth.
261
300
  if (responseText) {
262
301
  await this.sendResponse(msgChannel, responseText);
263
- } else {
264
- // Exit 0 with no assistant text almost always means OpenCode has no
265
- // model/provider configured (it emits only step_start then stops).
266
- await this.sendResponse(
267
- msgChannel,
268
- 'OpenCode ran but produced no response. This usually means no model/provider is configured — set one up (e.g. `opencode auth login` or configure a provider/API key) and try again.'
269
- );
270
302
  }
271
303
  } catch (e) {
272
304
  if (this._stoppingChannels.has(msgChannel)) {
273
305
  this._stoppingChannels.delete(msgChannel);
274
306
  return;
275
307
  }
276
- this._log(`Error handling message: ${e.message}`);
277
- await this.sendError(msgChannel, `Error processing message: ${e.message}`);
308
+ const category = (e && e.category) ? e.category : this._classifyErrno(e);
309
+ const diagnostic = OpenCodeAdapter._redact((e && (e.diagnostic || e.message)) || '');
310
+ this._log(`OpenCode failure [${category}] in ${msgChannel}: ${diagnostic.slice(0, 300)}`);
311
+ await this._sendClassifiedError(msgChannel, category, e && e.detail);
278
312
  }
279
313
  }
280
314
 
@@ -286,8 +320,17 @@ class OpenCodeAdapter extends BaseAdapter {
286
320
  * Split a string containing concatenated JSON objects.
287
321
  */
288
322
  static _splitJsonObjects(raw) {
323
+ return OpenCodeAdapter._drainJsonObjects(raw).objects;
324
+ }
325
+
326
+ /**
327
+ * Extract complete JSON objects from a possibly partial stream buffer.
328
+ * Returns unparsed trailing text as `rest` so callers can prepend it to the
329
+ * next stdout chunk. OpenCode may concatenate JSON objects without newlines.
330
+ */
331
+ static _drainJsonObjects(raw) {
289
332
  const objects = [];
290
- raw = raw.trim();
333
+ raw = String(raw || '');
291
334
  let pos = 0;
292
335
  while (pos < raw.length) {
293
336
  if (' \t\r\n'.includes(raw[pos])) { pos++; continue; }
@@ -296,7 +339,7 @@ class OpenCodeAdapter extends BaseAdapter {
296
339
  let depth = 0;
297
340
  let inStr = false;
298
341
  let escaped = false;
299
- let start = pos;
342
+ const start = pos;
300
343
  for (let i = pos; i < raw.length; i++) {
301
344
  const ch = raw[i];
302
345
  if (escaped) { escaped = false; continue; }
@@ -317,17 +360,24 @@ class OpenCodeAdapter extends BaseAdapter {
317
360
  }
318
361
  if (i === raw.length - 1) pos = raw.length; // no match, skip
319
362
  }
320
- if (depth !== 0) break; // unbalanced, stop
363
+ if (depth !== 0) return { objects, rest: raw.slice(start) };
321
364
  }
322
- return objects;
365
+ return { objects, rest: '' };
323
366
  }
324
367
 
325
368
  /**
326
369
  * Extract user-visible text from a single opencode JSON event.
327
370
  */
328
371
  static _extractTextFromEvent(event) {
329
- const eventType = event.type || '';
330
- if (['step_start', 'step_finish', 'tool_use'].includes(eventType)) return null;
372
+ if (!event || typeof event !== 'object') return null;
373
+ // Control / tool / error events carry no user-visible assistant text. Match
374
+ // both underscore and hyphen spellings (`step_finish` vs `step-finish`) and
375
+ // detect tool events by shape, since opencode's exact top-level `type`
376
+ // string varies across versions.
377
+ const eventType = String(event.type || '').toLowerCase();
378
+ if (['step_start', 'step-start', 'step_finish', 'step-finish', 'tool_use', 'tool'].includes(eventType)) return null;
379
+ if (OpenCodeAdapter._isToolEvent(event)) return null;
380
+ if (eventType.includes('error')) return null;
331
381
 
332
382
  const part = event.part;
333
383
  if (part && typeof part === 'object') {
@@ -340,6 +390,79 @@ class OpenCodeAdapter extends BaseAdapter {
340
390
  return text || null;
341
391
  }
342
392
 
393
+ static _toolStatusFromEvent(event) {
394
+ if (!OpenCodeAdapter._isToolEvent(event)) return null;
395
+
396
+ const item = event.item || event.part || event.tool || event;
397
+ const toolName = OpenCodeAdapter._safeToolName(
398
+ item.name || item.tool || item.toolName || item.id || 'tool'
399
+ );
400
+ const input = OpenCodeAdapter._toolInputFromItem(item);
401
+ const preview = OpenCodeAdapter._toolInputPreview(input);
402
+
403
+ return OpenCodeAdapter._formatToolStatus(toolName, preview);
404
+ }
405
+
406
+ static _safeToolName(name) {
407
+ return String(name || 'tool').replace(/[`\\]/g, '').slice(0, 80) || 'tool';
408
+ }
409
+
410
+ static _toolInputFromItem(item) {
411
+ if (!item || typeof item !== 'object') return {};
412
+ if (item.state && typeof item.state === 'object' && item.state.input) {
413
+ return item.state.input;
414
+ }
415
+ return item.input || item.args || item.arguments || item.parameters || item.params || {};
416
+ }
417
+
418
+ static _toolInputPreview(input) {
419
+ if (input == null) return '';
420
+ if (typeof input === 'string') return input.slice(0, 1000);
421
+ try {
422
+ return JSON.stringify(input, null, 2).slice(0, 1000);
423
+ } catch {
424
+ return String(input).slice(0, 1000);
425
+ }
426
+ }
427
+
428
+ static _markdownFence(body) {
429
+ const runs = String(body || '').match(/`+/g) || [];
430
+ const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 2);
431
+ return '`'.repeat(Math.max(3, maxRun + 1));
432
+ }
433
+
434
+ static _formatToolStatus(toolName, preview) {
435
+ const header = `**Using tool:** \`${toolName}\``;
436
+ const body = String(preview || '').trim();
437
+ if (!body) return header;
438
+ const fence = OpenCodeAdapter._markdownFence(body);
439
+ return `${header}\n${fence}\n${body}\n${fence}`;
440
+ }
441
+
442
+ async _handleStreamEvent(event, msgChannel, responseState = null) {
443
+ const status = OpenCodeAdapter._toolStatusFromEvent(event);
444
+ if (status) {
445
+ // A tool call resets `finalText` (the "text since the last tool"), so the
446
+ // final answer is the text emitted AFTER the last tool. But we keep
447
+ // `allText` intact: if the run ends on a tool with no closing text, the
448
+ // earlier assistant text is still recoverable instead of being reported
449
+ // as an empty response (see _finalTextFromStdout).
450
+ if (responseState) responseState.finalText = '';
451
+ await this.sendStatus(msgChannel, status);
452
+ return;
453
+ }
454
+
455
+ const text = OpenCodeAdapter._extractTextFromEvent(event);
456
+ if (text) {
457
+ if (responseState) {
458
+ responseState.seenText = true;
459
+ responseState.finalText += text;
460
+ responseState.allText = (responseState.allText || '') + text;
461
+ }
462
+ if (text.trim()) await this.sendThinking(msgChannel, text.trim());
463
+ }
464
+ }
465
+
343
466
  /**
344
467
  * Extract human-readable text from opencode --format json output.
345
468
  */
@@ -355,6 +478,19 @@ class OpenCodeAdapter extends BaseAdapter {
355
478
  return texts.length ? texts.join('\n').trim() : raw.trim();
356
479
  }
357
480
 
481
+ static _finalTextFromStdout(raw, responseState = null) {
482
+ raw = String(raw || '').trim();
483
+ if (!raw) return '';
484
+ if (OpenCodeAdapter._isOnlyControlJson(raw)) return '';
485
+ if (responseState && responseState.seenText) {
486
+ // Prefer text after the last tool call (the natural final answer); fall
487
+ // back to everything streamed so a `text -> tool -> exit` sequence is not
488
+ // misreported as empty.
489
+ return (responseState.finalText || '').trim() || (responseState.allText || '').trim();
490
+ }
491
+ return OpenCodeAdapter._extractTextFromJson(raw);
492
+ }
493
+
358
494
  /**
359
495
  * True when `raw` is opencode JSON that carries ONLY control events
360
496
  * (step_start / step_finish / tool_use) and no assistant text — which is what
@@ -496,28 +632,22 @@ class OpenCodeAdapter extends BaseAdapter {
496
632
  }
497
633
 
498
634
  _runOpencode(content, msgChannel) {
499
- const binary = this._opencodeBinary || this._findOpencodeBinary();
500
- if (binary) this._opencodeBinary = binary;
501
- if (!binary) {
502
- return Promise.reject(new Error(
503
- 'opencode CLI not found. Install with: npm install -g opencode-ai@latest'
504
- ));
635
+ // Preflight BEFORE spawning: bail out with a structured, actionable error
636
+ // instead of launching a child that will hang for 5 minutes or exit 1 with
637
+ // an opaque message. Stubbing _runOpencode in tests bypasses this, exactly
638
+ // like the real send path bypasses spawn when preflight fails.
639
+ const pf = this._preflight(msgChannel);
640
+ if (!pf.ok) {
641
+ return Promise.reject(this._failure(pf.category, pf.diagnostic, pf.detail));
505
642
  }
506
643
 
644
+ const binary = this._opencodeBinary;
507
645
  const cmd = [binary, 'run', '--format', 'json', '--dir', this.agentHome];
508
646
 
509
- // Pin the model explicitly — without it opencode hangs waiting for
510
- // interactive selection (see _resolveModel). Harmless when resuming a
511
- // session (opencode keeps the session's model); essential for new ones.
647
+ // Preflight guarantees a resolvable model; pin it explicitly — without it
648
+ // opencode hangs waiting for interactive provider/model selection.
512
649
  const model = this._resolveModel();
513
- if (model) {
514
- cmd.push('--model', model);
515
- } else {
516
- this._log(
517
- 'WARNING: no model configured (set LLM_MODEL / OPENCODE_MODEL). ' +
518
- 'opencode may hang or produce no response.'
519
- );
520
- }
650
+ cmd.push('--model', model);
521
651
 
522
652
  const sessionId = this._channelSessions[msgChannel];
523
653
  let fullPrompt;
@@ -560,6 +690,9 @@ class OpenCodeAdapter extends BaseAdapter {
560
690
 
561
691
  let stdout = '';
562
692
  let stderr = '';
693
+ let streamBuffer = '';
694
+ let pendingEvents = Promise.resolve();
695
+ const responseState = { finalText: '', allText: '', seenText: false };
563
696
  let settled = false;
564
697
  const finish = (fn, arg) => {
565
698
  if (settled) return;
@@ -570,7 +703,20 @@ class OpenCodeAdapter extends BaseAdapter {
570
703
  fn(arg);
571
704
  };
572
705
 
573
- if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; });
706
+ if (proc.stdout) {
707
+ proc.stdout.on('data', (d) => {
708
+ const chunk = d.toString('utf-8');
709
+ stdout += chunk;
710
+ streamBuffer += chunk;
711
+ const drained = OpenCodeAdapter._drainJsonObjects(streamBuffer);
712
+ streamBuffer = drained.rest;
713
+ for (const event of drained.objects) {
714
+ pendingEvents = pendingEvents
715
+ .then(() => this._handleStreamEvent(event, msgChannel, responseState))
716
+ .catch((e) => this._log(`opencode stream event handling failed: ${e.message}`));
717
+ }
718
+ });
719
+ }
574
720
  if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
575
721
 
576
722
  // Send the prompt via stdin, then close it so opencode knows the message
@@ -589,7 +735,9 @@ class OpenCodeAdapter extends BaseAdapter {
589
735
  // never parse a truncated response. The handler receives (code, signal):
590
736
  // when the spawn timeout kills the child, code is null and signal is the
591
737
  // signal name — report that as a timeout rather than "exited with code null".
592
- proc.on('close', (code, signal) => {
738
+ proc.on('close', async (code, signal) => {
739
+ try { await pendingEvents; } catch {}
740
+
593
741
  if (this._stoppingChannels.has(msgChannel)) {
594
742
  this._stoppingChannels.delete(msgChannel);
595
743
  return finish(resolve, '');
@@ -598,42 +746,360 @@ class OpenCodeAdapter extends BaseAdapter {
598
746
  stdout = stdout.trim();
599
747
  stderr = stderr.trim();
600
748
 
749
+ // A structured error event may ride on stdout regardless of exit code
750
+ // (opencode emits {"type":"error", ...} as a JSONL event). Look there
751
+ // first so the real auth/provider/model failure is never discarded just
752
+ // because stderr happened to be empty.
753
+ const stdoutErr = OpenCodeAdapter._extractErrorFromStdout(stdout);
754
+
601
755
  if (signal) {
602
- this._log(`opencode killed by signal ${signal} after ${TIMEOUT_MS / 1000}s (no output: ${!stdout})`);
603
- return finish(reject, new Error(
604
- stderr ? stderr.slice(0, 500)
605
- : `opencode timed out after ${TIMEOUT_MS / 1000}s with no response. ` +
606
- 'This usually means no model/provider is configured — check LLM_MODEL / LLM_API_KEY.'
607
- ));
756
+ this._log(`opencode killed by signal ${signal} after ${TIMEOUT_MS / 1000}s (output: ${!!stdout})`);
757
+ const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
758
+ // A signal almost always means our own timeout fired; only override to
759
+ // a more specific provider error if stdout/stderr clearly show one.
760
+ const category = (cls.category === 'unknown_error' || cls.category === 'process_crashed')
761
+ ? 'timeout' : cls.category;
762
+ return finish(reject, this._failure(category, cls.diagnostic || `signal=${signal}`, cls.detail));
608
763
  }
609
764
 
610
765
  if (code !== 0) {
611
- this._log(`opencode exited with code ${code}: ${stderr.slice(0, 300)}`);
612
- // Surface the real failure instead of a generic "No response" so the
613
- // user can see (e.g.) a missing model/provider or auth error.
614
- return finish(reject, new Error(
615
- stderr ? stderr.slice(0, 500) : `opencode exited with code ${code}`
616
- ));
766
+ const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
767
+ this._log(`opencode exited code ${code} → [${cls.category}]: ${OpenCodeAdapter._redact(cls.diagnostic).slice(0, 300)}`);
768
+ return finish(reject, this._failure(cls.category, cls.diagnostic, cls.detail));
617
769
  }
618
770
 
619
- if (stdout) {
620
- this._persistSessionId(msgChannel, stdout);
621
- const text = OpenCodeAdapter._extractTextFromJson(stdout);
622
- // Exit 0 but no assistant text usually means no model/provider is
623
- // configured (opencode emits only step_start then stops). Tell the
624
- // user how to fix it rather than echoing raw event JSON.
625
- if (!text || OpenCodeAdapter._isOnlyControlJson(stdout)) {
626
- this._log(`opencode produced no assistant text. stdout head: ${stdout.slice(0, 300)}`);
627
- return finish(resolve, '');
628
- }
629
- return finish(resolve, text);
771
+ // Exit 0.
772
+ if (stdout) this._persistSessionId(msgChannel, stdout);
773
+ const text = stdout ? OpenCodeAdapter._finalTextFromStdout(stdout, responseState) : '';
774
+ if (text) return finish(resolve, text);
775
+
776
+ // Exit 0 with no final assistant text. This is NOT proof of a missing
777
+ // provider — it can be a structured error on stdout, an incomplete
778
+ // stream, or a genuinely empty completion. Classify rather than guess.
779
+ if (stdoutErr) {
780
+ const cls = OpenCodeAdapter._classifyFailure({ code, signal, stdout, stderr, stdoutErr });
781
+ this._log(`opencode exit 0 with error event → [${cls.category}]: ${OpenCodeAdapter._redact(cls.diagnostic).slice(0, 300)}`);
782
+ return finish(reject, this._failure(cls.category, cls.diagnostic, cls.detail));
630
783
  }
631
-
632
- if (stderr) this._log(`opencode stderr: ${stderr.slice(0, 300)}`);
633
- finish(resolve, '');
784
+ const emptyCat = OpenCodeAdapter._emptyExitCategory(stdout);
785
+ this._log(`opencode exit 0, no final text → [${emptyCat}]. stdout head: ${OpenCodeAdapter._redact(stdout).slice(0, 200)}`);
786
+ return finish(reject, this._failure(emptyCat, `exit 0, no final assistant text; stdout head: ${stdout.slice(0, 200)}`));
634
787
  });
635
788
  });
636
789
  }
790
+
791
+ // ------------------------------------------------------------------
792
+ // Preflight (deterministic, no model call) + failure classification
793
+ // ------------------------------------------------------------------
794
+
795
+ /**
796
+ * Cheap, side-effect-free readiness check run immediately before spawning.
797
+ * Returns { ok: true, ... } or { ok: false, category, detail?, diagnostic? }.
798
+ * Conservative by design: it blocks only on deterministic, locally-knowable
799
+ * problems (no binary, unsupported version, no model, no credential at all,
800
+ * unusable cwd) and lets anything ambiguous through to a real run.
801
+ */
802
+ _preflight() {
803
+ const binary = this._opencodeBinary || this._findOpencodeBinary();
804
+ if (binary) this._opencodeBinary = binary;
805
+ if (!binary) return { ok: false, category: 'cli_not_found' };
806
+
807
+ const probe = this._detectCliVersion(binary);
808
+ if (!probe.executable) {
809
+ return { ok: false, category: 'cli_not_executable', diagnostic: 'opencode --version did not run' };
810
+ }
811
+ const vclass = OpenCodeAdapter._classifyVersion(probe.version);
812
+ if (vclass === 'unsupported') {
813
+ return {
814
+ ok: false,
815
+ category: 'unsupported_version',
816
+ detail: `detected ${probe.version || 'unknown'}`,
817
+ diagnostic: `detected ${probe.version}, requires >= ${OPENCODE_MIN_VERSION}`,
818
+ };
819
+ }
820
+ // 'unknown' (unparseable) and 'degraded' (newer than tested) still run.
821
+
822
+ const model = this._resolveModel();
823
+ if (!model) return { ok: false, category: 'model_missing' };
824
+
825
+ const cred = this._credentialState();
826
+ if (cred === 'missing') return { ok: false, category: 'credential_missing' };
827
+
828
+ try {
829
+ fs.accessSync(this.agentHome, fs.constants.R_OK | fs.constants.W_OK);
830
+ } catch {
831
+ return { ok: false, category: 'cwd_unavailable', diagnostic: `cannot access ${this.agentHome}` };
832
+ }
833
+
834
+ return { ok: true, version: probe.version, versionClass: vclass, model, credential: cred };
835
+ }
836
+
837
+ /**
838
+ * Probe the CLI's version and executability, cached briefly so preflight never
839
+ * spawns `--version` more than once per VERSION_PROBE_TTL_MS across channels.
840
+ * Returns { version: string|null, executable: boolean }.
841
+ */
842
+ _detectCliVersion(binary) {
843
+ const now = Date.now();
844
+ if (this._versionProbe && (now - this._versionProbe.ts) < VERSION_PROBE_TTL_MS) {
845
+ return this._versionProbe;
846
+ }
847
+ let version = null;
848
+ let executable = false;
849
+ try {
850
+ const raw = execSync(`"${binary}" --version`, {
851
+ encoding: 'utf-8',
852
+ timeout: 5000,
853
+ windowsHide: true,
854
+ env: getEnhancedEnv(),
855
+ stdio: ['ignore', 'pipe', 'pipe'],
856
+ }).trim();
857
+ executable = true;
858
+ const m = raw.match(/(\d+\.\d+\.\d+)/);
859
+ version = m ? m[1] : (raw.split('\n')[0] || null);
860
+ } catch (e) {
861
+ const code = e && e.code;
862
+ if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') {
863
+ executable = false; // never spawned
864
+ } else {
865
+ executable = true; // spawned but --version misbehaved
866
+ const out = String((e && (e.stdout || e.stderr)) || '');
867
+ const m = out.match(/(\d+\.\d+\.\d+)/);
868
+ version = m ? m[1] : null;
869
+ }
870
+ }
871
+ this._versionProbe = { version, executable, ts: now };
872
+ return this._versionProbe;
873
+ }
874
+
875
+ /**
876
+ * Best-effort credential/provider presence. 'present' | 'unknown' | 'missing'.
877
+ * 'unknown' (custom provider key, custom endpoint, or a config file we can't
878
+ * fully validate) is deliberately NOT blocked — only a total absence of any
879
+ * recognizable signal is 'missing'.
880
+ */
881
+ _credentialState() {
882
+ const env = this.agentEnv || process.env;
883
+ const val = (k) => (env[k] == null ? '' : String(env[k])).trim();
884
+ const KNOWN = [
885
+ 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
886
+ 'GROQ_API_KEY', 'MISTRAL_API_KEY', 'DEEPSEEK_API_KEY', 'XAI_API_KEY',
887
+ 'GEMINI_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_API_KEY',
888
+ 'AZURE_API_KEY', 'AZURE_OPENAI_API_KEY',
889
+ ];
890
+ if (KNOWN.some((k) => val(k))) return 'present';
891
+
892
+ const home = os.homedir();
893
+ const fileNonEmpty = (p) => {
894
+ try { return fs.existsSync(p) && fs.statSync(p).size > 0; } catch { return false; }
895
+ };
896
+ const stores = [
897
+ path.join(home, '.local', 'share', 'opencode', 'auth.json'),
898
+ path.join(home, '.config', 'opencode', 'opencode.json'),
899
+ path.join(home, '.config', 'opencode', 'config.json'),
900
+ path.join(this.agentHome, 'opencode.json'),
901
+ path.join(this.agentHome, '.opencode', 'opencode.json'),
902
+ ];
903
+ if (stores.some(fileNonEmpty)) return 'present';
904
+
905
+ if (val('OPENAI_BASE_URL') || val('LLM_BASE_URL')) return 'unknown';
906
+ if (Object.keys(env).some((k) => /(_API_KEY|_API_TOKEN|_TOKEN)$/.test(k) && val(k))) return 'unknown';
907
+ return 'missing';
908
+ }
909
+
910
+ /** Build a categorized Error carrying diagnostic (for logs) + detail (for the user). */
911
+ _failure(category, diagnostic, detail) {
912
+ const err = new Error(`opencode ${category}${detail ? `: ${detail}` : ''}`);
913
+ err.category = category;
914
+ err.diagnostic = diagnostic || '';
915
+ err.detail = detail || '';
916
+ return err;
917
+ }
918
+
919
+ /** Map a raw spawn errno (ENOENT/EACCES) to a category; default unknown_error. */
920
+ _classifyErrno(e) {
921
+ const code = e && e.code;
922
+ if (code === 'ENOENT') return 'cli_not_found';
923
+ if (code === 'EACCES' || code === 'EPERM') return 'cli_not_executable';
924
+ return 'unknown_error';
925
+ }
926
+
927
+ /**
928
+ * Post a user-visible, de-identified, actionable error that is clearly NOT a
929
+ * normal reply. Carries `error_category` in metadata so the UI can route it.
930
+ */
931
+ async _sendClassifiedError(channel, category, detail) {
932
+ const base = FAILURE_MESSAGES[category] || FAILURE_MESSAGES.unknown_error;
933
+ const safe = detail ? OpenCodeAdapter._redact(detail).trim() : '';
934
+ const body = safe ? `${base}\n\n> ${safe}` : base;
935
+ const content = `⚠️ **OpenCode couldn't run** — ${body}`;
936
+ try {
937
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
938
+ senderType: 'agent',
939
+ senderName: this.agentName,
940
+ messageType: 'error',
941
+ metadata: { agent_mode: this._mode, error: true, error_category: category },
942
+ sessionId: this._sessionId,
943
+ });
944
+ } catch {
945
+ // Older backends may reject an unknown messageType/metadata — fall back to
946
+ // the plain error path so the user still gets an actionable message.
947
+ try { await this.sendError(channel, content); } catch {}
948
+ }
949
+ }
950
+
951
+ // ------------------------------------------------------------------
952
+ // Static classifiers (pure — unit tested)
953
+ // ------------------------------------------------------------------
954
+
955
+ /** True when an event is a tool call, across opencode's version-varying shapes. */
956
+ static _isToolEvent(event) {
957
+ if (!event || typeof event !== 'object') return false;
958
+ const t = String(event.type || '').toLowerCase();
959
+ if (t === 'tool_use' || t === 'tool') return true;
960
+ const part = event.part;
961
+ if (part && typeof part === 'object' && String(part.type || '').toLowerCase() === 'tool') return true;
962
+ return false;
963
+ }
964
+
965
+ /** Scan stdout JSONL for a structured error event; last one wins. */
966
+ static _extractErrorFromStdout(raw) {
967
+ const events = OpenCodeAdapter._splitJsonObjects(raw);
968
+ for (let i = events.length - 1; i >= 0; i--) {
969
+ const err = OpenCodeAdapter._errorFromEvent(events[i]);
970
+ if (err) return err;
971
+ }
972
+ return null;
973
+ }
974
+
975
+ /** Pull { message, name, status } from one event, tolerant of nesting. */
976
+ static _errorFromEvent(ev) {
977
+ if (!ev || typeof ev !== 'object') return null;
978
+ const type = String(ev.type || '').toLowerCase();
979
+ const part = (ev.part && typeof ev.part === 'object') ? ev.part : null;
980
+ // Nested error objects carry the richest detail — try them before the
981
+ // wrapping event, whose only signal may be type === 'error'.
982
+ const candidates = [];
983
+ if (ev.error && typeof ev.error === 'object') candidates.push(ev.error);
984
+ if (part && part.error && typeof part.error === 'object') candidates.push(part.error);
985
+ if (part && String(part.type || '').toLowerCase().includes('error')) candidates.push(part);
986
+ if (type.includes('error')) candidates.push(ev);
987
+ if (typeof ev.error === 'string') candidates.push({ message: ev.error });
988
+ if (part && typeof part.error === 'string') candidates.push({ message: part.error });
989
+
990
+ const pick = (...vals) => {
991
+ for (const v of vals) if (typeof v === 'string' && v.trim()) return v;
992
+ return '';
993
+ };
994
+ for (const c of candidates) {
995
+ if (!c || typeof c !== 'object') continue;
996
+ const data = (c.data && typeof c.data === 'object') ? c.data : {};
997
+ const message = pick(c.message, c.error, c.detail, c.reason, data.message);
998
+ const name = pick(c.name, c.type);
999
+ const statusRaw = c.status != null ? c.status
1000
+ : (c.statusCode != null ? c.statusCode
1001
+ : (data.status != null ? data.status
1002
+ : (typeof c.code === 'number' ? c.code : null)));
1003
+ if (message || name || statusRaw != null) {
1004
+ return {
1005
+ message: String(message || ''),
1006
+ name: String(name || ''),
1007
+ status: statusRaw != null ? String(statusRaw) : '',
1008
+ };
1009
+ }
1010
+ }
1011
+ return null;
1012
+ }
1013
+
1014
+ /**
1015
+ * Classify a failed run into one taxonomy category. Conservative: an exit
1016
+ * code alone is never "auth"; only explicit 401/403/auth wording is. Returns
1017
+ * { category, diagnostic, detail }.
1018
+ */
1019
+ static _classifyFailure({ code, signal, stdout, stderr, stdoutErr } = {}) {
1020
+ const err = stdoutErr || OpenCodeAdapter._extractErrorFromStdout(stdout || '');
1021
+ const status = err && err.status ? String(err.status) : '';
1022
+ const msg = (err && err.message) || '';
1023
+ const name = (err && err.name) || '';
1024
+ const diagnostic = [name, status, msg].filter(Boolean).join(' ')
1025
+ || (stderr || '')
1026
+ || `exit code ${code}${signal ? `, signal ${signal}` : ''}`;
1027
+ const hay = `${name} ${status} ${msg} ${stderr || ''}`.toLowerCase();
1028
+ const has = (re) => re.test(hay);
1029
+
1030
+ let category;
1031
+ if (has(/\b(401|403)\b/) || has(/unauthor|invalid api key|invalid_api_key|authentication|auth(entication)? failed|forbidden|invalid token|no api key|missing api key|permission denied/)) {
1032
+ category = 'auth_failed';
1033
+ } else if (has(/\b429\b/) || has(/rate.?limit|too many requests|quota|overloaded/)) {
1034
+ category = 'rate_limited';
1035
+ } else if (has(/model.*(not found|does not exist|unknown|invalid|unsupported)|no such model|unknown model|invalid model|model_not_found|\b404\b/)) {
1036
+ category = 'model_not_found';
1037
+ } else if (has(/no provider|provider not configured|not configured|no model configured|no providers|missing provider/)) {
1038
+ category = 'provider_not_configured';
1039
+ } else if (has(/econnrefused|enotfound|etimedout|eai_again|network|fetch failed|socket hang|connection (refused|reset|error)|getaddrinfo|\bdns\b/)) {
1040
+ category = 'network_error';
1041
+ } else if (has(/\b5\d\d\b|server error|internal server|bad gateway|service unavailable|gateway timeout/)) {
1042
+ category = 'provider_server_error';
1043
+ } else if (has(/timeout|timed out/)) {
1044
+ category = 'timeout';
1045
+ } else if (err) {
1046
+ category = 'unknown_error'; // recognized an error but not its kind
1047
+ } else if (signal) {
1048
+ category = 'timeout';
1049
+ } else if (!stdout && !stderr) {
1050
+ category = 'process_crashed';
1051
+ } else if (stdout) {
1052
+ category = 'stream_parse_error';
1053
+ } else {
1054
+ category = 'unknown_error';
1055
+ }
1056
+
1057
+ const detail = OpenCodeAdapter._redact(msg || stderr || '').slice(0, 200);
1058
+ return { category, diagnostic, detail };
1059
+ }
1060
+
1061
+ /** Category for an exit-0 run that produced no final assistant text. */
1062
+ static _emptyExitCategory(stdout) {
1063
+ const raw = String(stdout || '').trim();
1064
+ if (!raw) return 'empty_response';
1065
+ return 'empty_response';
1066
+ }
1067
+
1068
+ /** 'ok' | 'degraded' (newer than tested) | 'unsupported' (too old) | 'unknown'. */
1069
+ static _classifyVersion(version) {
1070
+ // Non-semver (or unreadable) → 'unknown': allow the run, never block on it.
1071
+ if (!version || !/^\d+\.\d+/.test(String(version))) return 'unknown';
1072
+ if (OpenCodeAdapter._cmpVer(version, OPENCODE_MIN_VERSION) < 0) return 'unsupported';
1073
+ if (OPENCODE_TESTED_MAX_VERSION && OpenCodeAdapter._cmpVer(version, OPENCODE_TESTED_MAX_VERSION) > 0) return 'degraded';
1074
+ return 'ok';
1075
+ }
1076
+
1077
+ /** Numeric-segment semver compare. Returns -1 / 0 / 1. */
1078
+ static _cmpVer(a, b) {
1079
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
1080
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
1081
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1082
+ const d = (pa[i] || 0) - (pb[i] || 0);
1083
+ if (d) return d < 0 ? -1 : 1;
1084
+ }
1085
+ return 0;
1086
+ }
1087
+
1088
+ /** Redact secrets (keys, tokens, bearer/authorization, query secrets) from diagnostics. */
1089
+ static _redact(s) {
1090
+ let out = String(s == null ? '' : s);
1091
+ out = out
1092
+ .replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-[REDACTED]')
1093
+ .replace(/\b(?:github_pat|gh[pousr])_[A-Za-z0-9_]{10,}/g, '[REDACTED_TOKEN]')
1094
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}/g, '[REDACTED_TOKEN]')
1095
+ .replace(/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]')
1096
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, '[REDACTED_JWT]')
1097
+ .replace(/(authorization|api[_-]?key|x-api-key|token|bearer|secret|password|passwd)(["'\s:=]+)([^\s"',}]+)/gi,
1098
+ (m, k, sep) => `${k}${sep}[REDACTED]`)
1099
+ .replace(/([?&](?:api[_-]?key|key|token|access_token)=)[^&\s"']+/gi, '$1[REDACTED]')
1100
+ .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED]');
1101
+ return out;
1102
+ }
637
1103
  }
638
1104
 
639
1105
  module.exports = OpenCodeAdapter;