@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.
- package/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +108 -7
- package/src/adapters/claude.js +50 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +10 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +665 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +29 -7
- package/src/config.js +8 -2
- package/src/daemon.js +108 -6
- package/src/installer.js +509 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
package/src/adapters/opencode.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
238
|
-
|
|
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
|
|
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
|
|
260
|
-
|
|
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 (
|
|
264
|
-
if (ch === '\\' && inStr) {
|
|
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)
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
//
|
|
405
|
-
//
|
|
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
|
-
|
|
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) => {
|
|
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)
|
|
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 (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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;
|