@openagents-org/agent-launcher 0.2.142 → 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:
@@ -45,11 +87,15 @@ class OpenCodeAdapter extends BaseAdapter {
45
87
  this._migrateSessionsFile();
46
88
  this._loadSessions();
47
89
 
90
+ // Process tracking for stop control
91
+ this._channelProcesses = {}; // channel → child process
92
+ this._stoppingChannels = new Set();
93
+
48
94
  this._opencodeBinary = this._findOpencodeBinary();
49
95
  if (this._opencodeBinary) {
50
96
  this._log(`Using OpenCode subprocess mode: ${this._opencodeBinary}`);
51
97
  } else {
52
- 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}`);
53
99
  }
54
100
  }
55
101
 
@@ -91,6 +137,32 @@ class OpenCodeAdapter extends BaseAdapter {
91
137
  } catch {}
92
138
  }
93
139
 
140
+ async _onControlAction(action, payload) {
141
+ if (action === 'stop') {
142
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
143
+ if (channel) {
144
+ const proc = this._channelProcesses[channel];
145
+ const hadQueuedWork = !!this._channelQueues[channel]?.length;
146
+ if (proc) {
147
+ this._log(`Stopping process for channel=${channel}`);
148
+ this._stoppingChannels.add(channel);
149
+ await this._stopProcess(proc);
150
+ delete this._channelProcesses[channel];
151
+ }
152
+ delete this._channelQueues[channel];
153
+ if (proc || hadQueuedWork) {
154
+ try {
155
+ await this.sendResponse(channel, 'Execution stopped by user.');
156
+ } catch {}
157
+ }
158
+ } else {
159
+ await this._stopAllProcesses('Execution stopped by user.');
160
+ }
161
+ return;
162
+ }
163
+ await super._onControlAction(action, payload);
164
+ }
165
+
94
166
  /**
95
167
  * Write workspace skill to OpenCode's skill directory for auto-discovery.
96
168
  */
@@ -158,19 +230,12 @@ class OpenCodeAdapter extends BaseAdapter {
158
230
  // Tier 1: PATH. Use the ENRICHED env (node-version-manager/homebrew/npm
159
231
  // dirs the launcher adds) so the lookup matches a packaged daemon's real
160
232
  // reach; windowsHide stops a console window from flashing on Windows.
161
- try {
162
- const env = getEnhancedEnv();
163
- if (IS_WINDOWS) {
164
- const r = execSync('where opencode.exe 2>nul || where opencode.cmd 2>nul || where opencode 2>nul', {
165
- encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
166
- });
167
- const hit = r.split(/\r?\n/)[0].trim();
168
- if (hit) return hit;
169
- } else {
170
- const hit = execSync('which opencode', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
171
- if (hit) return hit;
172
- }
173
- } 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;
174
239
 
175
240
  // Tier 2: Next to Node.js
176
241
  const nearNode = path.join(path.dirname(process.execPath), `opencode${ext}`);
@@ -223,19 +288,27 @@ class OpenCodeAdapter extends BaseAdapter {
223
288
  try {
224
289
  const responseText = await this._runOpencode(content, msgChannel);
225
290
 
291
+ if (this._stoppingChannels.has(msgChannel)) {
292
+ this._stoppingChannels.delete(msgChannel);
293
+ return;
294
+ }
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.
226
300
  if (responseText) {
227
301
  await this.sendResponse(msgChannel, responseText);
228
- } else {
229
- // Exit 0 with no assistant text almost always means OpenCode has no
230
- // model/provider configured (it emits only step_start then stops).
231
- await this.sendResponse(
232
- msgChannel,
233
- '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.'
234
- );
235
302
  }
236
303
  } catch (e) {
237
- this._log(`Error handling message: ${e.message}`);
238
- await this.sendError(msgChannel, `Error processing message: ${e.message}`);
304
+ if (this._stoppingChannels.has(msgChannel)) {
305
+ this._stoppingChannels.delete(msgChannel);
306
+ return;
307
+ }
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);
239
312
  }
240
313
  }
241
314
 
@@ -247,8 +320,17 @@ class OpenCodeAdapter extends BaseAdapter {
247
320
  * Split a string containing concatenated JSON objects.
248
321
  */
249
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) {
250
332
  const objects = [];
251
- raw = raw.trim();
333
+ raw = String(raw || '');
252
334
  let pos = 0;
253
335
  while (pos < raw.length) {
254
336
  if (' \t\r\n'.includes(raw[pos])) { pos++; continue; }
@@ -256,12 +338,12 @@ class OpenCodeAdapter extends BaseAdapter {
256
338
  // Find matching brace
257
339
  let depth = 0;
258
340
  let inStr = false;
259
- let escape = false;
260
- let start = pos;
341
+ let escaped = false;
342
+ const start = pos;
261
343
  for (let i = pos; i < raw.length; i++) {
262
344
  const ch = raw[i];
263
- if (escape) { escape = false; continue; }
264
- if (ch === '\\' && inStr) { escape = true; continue; }
345
+ if (escaped) { escaped = false; continue; }
346
+ if (ch === '\\' && inStr) { escaped = true; continue; }
265
347
  if (ch === '"') { inStr = !inStr; continue; }
266
348
  if (inStr) continue;
267
349
  if (ch === '{') depth++;
@@ -278,17 +360,24 @@ class OpenCodeAdapter extends BaseAdapter {
278
360
  }
279
361
  if (i === raw.length - 1) pos = raw.length; // no match, skip
280
362
  }
281
- if (depth !== 0) break; // unbalanced, stop
363
+ if (depth !== 0) return { objects, rest: raw.slice(start) };
282
364
  }
283
- return objects;
365
+ return { objects, rest: '' };
284
366
  }
285
367
 
286
368
  /**
287
369
  * Extract user-visible text from a single opencode JSON event.
288
370
  */
289
371
  static _extractTextFromEvent(event) {
290
- const eventType = event.type || '';
291
- 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;
292
381
 
293
382
  const part = event.part;
294
383
  if (part && typeof part === 'object') {
@@ -301,6 +390,79 @@ class OpenCodeAdapter extends BaseAdapter {
301
390
  return text || null;
302
391
  }
303
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
+
304
466
  /**
305
467
  * Extract human-readable text from opencode --format json output.
306
468
  */
@@ -316,6 +478,19 @@ class OpenCodeAdapter extends BaseAdapter {
316
478
  return texts.length ? texts.join('\n').trim() : raw.trim();
317
479
  }
318
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
+
319
494
  /**
320
495
  * True when `raw` is opencode JSON that carries ONLY control events
321
496
  * (step_start / step_finish / tool_use) and no assistant text — which is what
@@ -360,6 +535,72 @@ class OpenCodeAdapter extends BaseAdapter {
360
535
  }
361
536
  }
362
537
 
538
+ /**
539
+ * Override BaseAdapter.stop so daemon shutdown also tears down in-flight
540
+ * opencode subprocesses cleanly.
541
+ */
542
+ stop() {
543
+ this._stopAllProcesses(
544
+ 'Task interrupted — daemon restarting. Send another message to continue.'
545
+ ).catch(() => {});
546
+ super.stop();
547
+ }
548
+
549
+ async _stopProcess(proc) {
550
+ if (!proc || proc.exitCode !== null) return;
551
+ try {
552
+ if (IS_WINDOWS) {
553
+ try { proc.kill('SIGINT'); } catch {}
554
+ const exited = await new Promise((resolve) => {
555
+ if (proc.exitCode !== null) {
556
+ resolve(true);
557
+ return;
558
+ }
559
+ const timeout = setTimeout(() => resolve(false), 1500);
560
+ proc.once('exit', () => { clearTimeout(timeout); resolve(true); });
561
+ });
562
+ if (!exited) {
563
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
564
+ }
565
+ } else {
566
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch {
567
+ proc.kill('SIGTERM');
568
+ }
569
+ await new Promise((resolve) => {
570
+ let done = false;
571
+ const finish = () => {
572
+ if (done) return;
573
+ done = true;
574
+ resolve();
575
+ };
576
+ const timeout = setTimeout(() => {
577
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch {
578
+ proc.kill('SIGKILL');
579
+ }
580
+ const reapTimeout = setTimeout(finish, 1000);
581
+ proc.once('exit', () => { clearTimeout(reapTimeout); finish(); });
582
+ }, 1500);
583
+ proc.once('exit', () => { clearTimeout(timeout); finish(); });
584
+ });
585
+ }
586
+ } catch {}
587
+ }
588
+
589
+ async _stopAllProcesses(completionMessage = 'Execution stopped.') {
590
+ const entries = Object.entries(this._channelProcesses);
591
+ if (!entries.length) return;
592
+ this._log(`Stopping ${entries.length} running process(es)...`);
593
+ for (const [channel, proc] of entries) {
594
+ this._stoppingChannels.add(channel);
595
+ await this._stopProcess(proc);
596
+ delete this._channelProcesses[channel];
597
+ delete this._channelQueues[channel];
598
+ try {
599
+ await this.sendResponse(channel, completionMessage);
600
+ } catch {}
601
+ }
602
+ }
603
+
363
604
  // ------------------------------------------------------------------
364
605
  // Subprocess execution
365
606
  // ------------------------------------------------------------------
@@ -391,28 +632,22 @@ class OpenCodeAdapter extends BaseAdapter {
391
632
  }
392
633
 
393
634
  _runOpencode(content, msgChannel) {
394
- const binary = this._opencodeBinary || this._findOpencodeBinary();
395
- if (binary) this._opencodeBinary = binary;
396
- if (!binary) {
397
- return Promise.reject(new Error(
398
- 'opencode CLI not found. Install with: npm install -g opencode-ai@latest'
399
- ));
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));
400
642
  }
401
643
 
644
+ const binary = this._opencodeBinary;
402
645
  const cmd = [binary, 'run', '--format', 'json', '--dir', this.agentHome];
403
646
 
404
- // Pin the model explicitly — without it opencode hangs waiting for
405
- // interactive selection (see _resolveModel). Harmless when resuming a
406
- // 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.
407
649
  const model = this._resolveModel();
408
- if (model) {
409
- cmd.push('--model', model);
410
- } else {
411
- this._log(
412
- 'WARNING: no model configured (set LLM_MODEL / OPENCODE_MODEL). ' +
413
- 'opencode may hang or produce no response.'
414
- );
415
- }
650
+ cmd.push('--model', model);
416
651
 
417
652
  const sessionId = this._channelSessions[msgChannel];
418
653
  let fullPrompt;
@@ -444,18 +679,44 @@ class OpenCodeAdapter extends BaseAdapter {
444
679
  env: spawnEnv,
445
680
  cwd: this.agentHome,
446
681
  timeout: TIMEOUT_MS,
682
+ detached: !IS_WINDOWS,
447
683
  // Never let a console window appear. Besides being ugly, an attached
448
684
  // console makes opencode start its interactive TUI and hang instead of
449
685
  // running headless — the root cause of "stuck on thinking…" on Windows.
450
686
  windowsHide: true,
451
687
  });
452
688
 
689
+ this._channelProcesses[msgChannel] = proc;
690
+
453
691
  let stdout = '';
454
692
  let stderr = '';
693
+ let streamBuffer = '';
694
+ let pendingEvents = Promise.resolve();
695
+ const responseState = { finalText: '', allText: '', seenText: false };
455
696
  let settled = false;
456
- const finish = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };
697
+ const finish = (fn, arg) => {
698
+ if (settled) return;
699
+ settled = true;
700
+ if (this._channelProcesses[msgChannel] === proc) {
701
+ delete this._channelProcesses[msgChannel];
702
+ }
703
+ fn(arg);
704
+ };
457
705
 
458
- 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
+ }
459
720
  if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
460
721
 
461
722
  // Send the prompt via stdin, then close it so opencode knows the message
@@ -474,46 +735,371 @@ class OpenCodeAdapter extends BaseAdapter {
474
735
  // never parse a truncated response. The handler receives (code, signal):
475
736
  // when the spawn timeout kills the child, code is null and signal is the
476
737
  // signal name — report that as a timeout rather than "exited with code null".
477
- proc.on('close', (code, signal) => {
738
+ proc.on('close', async (code, signal) => {
739
+ try { await pendingEvents; } catch {}
740
+
741
+ if (this._stoppingChannels.has(msgChannel)) {
742
+ this._stoppingChannels.delete(msgChannel);
743
+ return finish(resolve, '');
744
+ }
745
+
478
746
  stdout = stdout.trim();
479
747
  stderr = stderr.trim();
480
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
+
481
755
  if (signal) {
482
- this._log(`opencode killed by signal ${signal} after ${TIMEOUT_MS / 1000}s (no output: ${!stdout})`);
483
- return finish(reject, new Error(
484
- stderr ? stderr.slice(0, 500)
485
- : `opencode timed out after ${TIMEOUT_MS / 1000}s with no response. ` +
486
- 'This usually means no model/provider is configured — check LLM_MODEL / LLM_API_KEY.'
487
- ));
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));
488
763
  }
489
764
 
490
765
  if (code !== 0) {
491
- this._log(`opencode exited with code ${code}: ${stderr.slice(0, 300)}`);
492
- // Surface the real failure instead of a generic "No response" so the
493
- // user can see (e.g.) a missing model/provider or auth error.
494
- return finish(reject, new Error(
495
- stderr ? stderr.slice(0, 500) : `opencode exited with code ${code}`
496
- ));
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));
497
769
  }
498
770
 
499
- if (stdout) {
500
- this._persistSessionId(msgChannel, stdout);
501
- const text = OpenCodeAdapter._extractTextFromJson(stdout);
502
- // Exit 0 but no assistant text usually means no model/provider is
503
- // configured (opencode emits only step_start then stops). Tell the
504
- // user how to fix it rather than echoing raw event JSON.
505
- if (!text || OpenCodeAdapter._isOnlyControlJson(stdout)) {
506
- this._log(`opencode produced no assistant text. stdout head: ${stdout.slice(0, 300)}`);
507
- return finish(resolve, '');
508
- }
509
- return finish(resolve, text);
510
- }
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);
511
775
 
512
- if (stderr) this._log(`opencode stderr: ${stderr.slice(0, 300)}`);
513
- finish(resolve, '');
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));
783
+ }
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)}`));
514
787
  });
515
788
  });
516
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
+ }
517
1103
  }
518
1104
 
519
1105
  module.exports = OpenCodeAdapter;