@agent-controller/runtime-opencode 0.3.1

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/dist/index.js ADDED
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Opencode adapter entrypoint — Phase 2 of the v0.2 execution plan.
3
+ *
4
+ * This adapter accepts a CompiledSpec on stdin (one JSON document, then
5
+ * EOF), spawns an opencode server via @opencode-ai/sdk, drives a session
6
+ * with spec.task as the prompt, translates opencode's SSE events into our
7
+ * wire-protocol NDJSON on stdout, and exits with a non-zero code when the
8
+ * session ends in error.
9
+ *
10
+ * Wire protocol contract: cli/internal/wire/events.go
11
+ * Event translation: src/event-translator.ts (pure, testable separately)
12
+ * Config mapping: src/opencode-config.ts (pure, no SDK dependency)
13
+ * Honesty guardrails: src/honesty.ts (mirrored from runtime/)
14
+ *
15
+ * Operational notes:
16
+ * - opencode is spawned as a child process by createOpencode(). On
17
+ * normal exit, server.close() is called in the finally block.
18
+ * - The SSE event stream is global (all sessions on the opencode
19
+ * instance); translateEvent filters to our session ID.
20
+ * - The hallucination guardrail mode from spec.guardrails is forwarded
21
+ * to translateEvent. The "correct" mode re-prompts once when the
22
+ * model fabricates XML (mirrors Pi adapter behavior).
23
+ * - This adapter does NOT yet handle: skill body inlining, MCP servers,
24
+ * subagents. Those land in slice 2.5. When a spec declares those
25
+ * features the adapter currently ignores them (the effective behavior
26
+ * is that the model runs without those tools/skills). Slice 2.5 will
27
+ * either wire them or emit a compile-time rejection.
28
+ */
29
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { spawnSync } from "node:child_process";
32
+ import { tmpdir } from "node:os";
33
+ import { createOpencode } from "@opencode-ai/sdk";
34
+ import { stamp } from "./wire.js";
35
+ import { buildOpencodeConfig } from "./opencode-config.js";
36
+ import { translateEvent, createTranslatorState } from "./event-translator.js";
37
+ import { CORRECTION_PROMPT } from "./honesty.js";
38
+ // ── stdin reading ──────────────────────────────────────────────────────────
39
+ async function readSpecFromStdin() {
40
+ const chunks = [];
41
+ for await (const chunk of process.stdin) {
42
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
43
+ }
44
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
45
+ if (raw.length === 0) {
46
+ throw new Error("runtime-opencode: stdin was empty; expected a JSON-encoded CompiledSpec");
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch (err) {
53
+ const detail = err instanceof Error ? err.message : String(err);
54
+ throw new Error(`runtime-opencode: failed to parse stdin as JSON: ${detail}`);
55
+ }
56
+ if (typeof parsed !== "object" || parsed === null) {
57
+ throw new Error("runtime-opencode: stdin parsed to a non-object value");
58
+ }
59
+ const spec = parsed;
60
+ if (typeof spec.v !== "number" || spec.v !== 1) {
61
+ throw new Error(`runtime-opencode: unsupported CompiledSpec version ${spec.v}; expected 1`);
62
+ }
63
+ if (!spec.metadata?.name) {
64
+ throw new Error("runtime-opencode: CompiledSpec.metadata.name is required");
65
+ }
66
+ if (!spec.model?.provider || !spec.model?.name) {
67
+ throw new Error("runtime-opencode: CompiledSpec.model.provider and .name are required");
68
+ }
69
+ return spec;
70
+ }
71
+ // ── wire event emitter ────────────────────────────────────────────────────
72
+ function emit(ev) {
73
+ process.stdout.write(JSON.stringify(ev) + "\n");
74
+ }
75
+ // ── hallucination mode resolver ───────────────────────────────────────────
76
+ function resolveHallucinationMode(spec) {
77
+ const raw = spec.guardrails?.hallucinationDetector;
78
+ if (!raw)
79
+ return "block";
80
+ if (raw === "warn" || raw === "block" || raw === "correct")
81
+ return raw;
82
+ process.stderr.write(`[runtime-opencode] WARNING: unknown spec.guardrails.hallucinationDetector value ` +
83
+ `"${raw}"; falling back to "block".\n`);
84
+ return "block";
85
+ }
86
+ // ── temp config dir management ────────────────────────────────────────────
87
+ /**
88
+ * Create a temp working directory for the opencode session. The SDK passes
89
+ * the config to opencode via the OPENCODE_CONFIG_CONTENT environment
90
+ * variable, not a config file, so we don't need to write opencode.json —
91
+ * we just need an isolated working directory. Returns the temp dir path;
92
+ * the caller is responsible for cleanup.
93
+ */
94
+ function makeTempWorkdir() {
95
+ const dir = join(tmpdir(), `agent-controller-opencode-${process.pid}-${Date.now()}`);
96
+ mkdirSync(dir, { recursive: true });
97
+ return dir;
98
+ }
99
+ // ── skill + subagent resolution (slice 2.5) ───────────────────────────────
100
+ /**
101
+ * Strip YAML frontmatter from the head of a Markdown file. Returns the body
102
+ * portion only. If no frontmatter delimiter is present, returns the input
103
+ * unchanged. Matches the same regex Pi adapter uses for skill body extraction.
104
+ */
105
+ function stripFrontmatter(raw) {
106
+ return raw.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
107
+ }
108
+ /**
109
+ * Parse YAML frontmatter from the head of a Markdown file into a flat
110
+ * Record<string, unknown>. Supports the small subset Pi's subagent format
111
+ * uses: string keys with string values OR YAML list values
112
+ * (e.g. `tools:\n - bash\n - read`). Quoted values are de-quoted.
113
+ *
114
+ * Intentionally tiny: we avoid pulling a full YAML dependency into runtime-
115
+ * opencode for one config-file parser. Frontmatter that uses richer YAML
116
+ * features (anchors, multiline strings, nested maps) will fall through
117
+ * with the lines preserved verbatim — callers that need richer data should
118
+ * extend this parser.
119
+ */
120
+ function parseFrontmatter(raw) {
121
+ const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
122
+ if (!match)
123
+ return {};
124
+ const body = match[1];
125
+ const out = {};
126
+ const lines = body.split("\n");
127
+ let i = 0;
128
+ while (i < lines.length) {
129
+ const line = lines[i];
130
+ // Skip blank lines and comment lines.
131
+ if (!line.trim() || line.trim().startsWith("#")) {
132
+ i++;
133
+ continue;
134
+ }
135
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
136
+ if (!kv) {
137
+ i++;
138
+ continue;
139
+ }
140
+ const key = kv[1];
141
+ const rest = kv[2];
142
+ // Reject YAML block-scalar indicators. Parsing `description: >-\n multi\n line`
143
+ // would require a full YAML implementation. Rather than silently capture the
144
+ // indicator as the value, throw so the spec author knows to inline.
145
+ // Codex pass 5 of slice 2.5 caught block scalars being silently accepted.
146
+ if (/^[|>][-+]?\s*$/.test(rest.trim())) {
147
+ throw new Error(`runtime-opencode: frontmatter field "${key}" uses a YAML block-scalar ` +
148
+ `indicator ("${rest.trim()}"). Block scalars are not supported by the ` +
149
+ `adapter's inline frontmatter parser — please use a single-line value or ` +
150
+ `quote the string.`);
151
+ }
152
+ if (rest === "" || rest === undefined) {
153
+ // Possible list:
154
+ // key:
155
+ // - foo
156
+ // - bar
157
+ const list = [];
158
+ let j = i + 1;
159
+ while (j < lines.length) {
160
+ const item = lines[j].match(/^\s*-\s+(.*)$/);
161
+ if (!item)
162
+ break;
163
+ list.push(dequote(item[1].trim()));
164
+ j++;
165
+ }
166
+ if (list.length > 0) {
167
+ out[key] = list;
168
+ i = j;
169
+ continue;
170
+ }
171
+ out[key] = "";
172
+ i++;
173
+ continue;
174
+ }
175
+ out[key] = dequote(rest.trim());
176
+ i++;
177
+ }
178
+ return out;
179
+ }
180
+ function dequote(s) {
181
+ if ((s.startsWith("\"") && s.endsWith("\"")) || (s.startsWith("'") && s.endsWith("'"))) {
182
+ return s.slice(1, -1);
183
+ }
184
+ return s;
185
+ }
186
+ /**
187
+ * Read each spec.skills[].entrypoint and return pre-resolved SkillBody
188
+ * objects ready to be inlined into the system prompt by buildOpencodeConfig.
189
+ * Missing/unreadable files emit a stderr warning and are skipped, matching
190
+ * Pi adapter's tolerant behavior for malformed skill refs.
191
+ */
192
+ function readSkillBodies(skills) {
193
+ const out = [];
194
+ for (const s of skills) {
195
+ if (!s.entrypoint)
196
+ continue;
197
+ try {
198
+ const raw = readFileSync(s.entrypoint, "utf8");
199
+ const body = stripFrontmatter(raw);
200
+ if (body.trim().length > 0)
201
+ out.push({ name: s.name, body });
202
+ }
203
+ catch (err) {
204
+ const msg = err instanceof Error ? err.message : String(err);
205
+ process.stderr.write(`[runtime-opencode] WARNING: could not read skill ${s.name} at ${s.entrypoint}: ${msg}\n`);
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ /**
211
+ * Read each spec.subagents[].entrypoint (.md file with YAML frontmatter),
212
+ * parse the frontmatter, and return SubagentDefinition objects. Errors
213
+ * (unreadable file, missing required frontmatter fields) throw — the spec
214
+ * declared this subagent and the run cannot honor it without the data,
215
+ * so failing fast is the right call.
216
+ */
217
+ function readSubagentDefinitions(subagents) {
218
+ const out = [];
219
+ for (const s of subagents) {
220
+ if (!s.entrypoint) {
221
+ throw new Error(`runtime-opencode: subagent "${s.name}" has no entrypoint. The compiler should ` +
222
+ `resolve every spec.subagents[] ref to an absolute .md path.`);
223
+ }
224
+ let raw;
225
+ try {
226
+ raw = readFileSync(s.entrypoint, "utf8");
227
+ }
228
+ catch (err) {
229
+ const msg = err instanceof Error ? err.message : String(err);
230
+ throw new Error(`runtime-opencode: could not read subagent ${s.name} at ${s.entrypoint}: ${msg}`);
231
+ }
232
+ const fm = parseFrontmatter(raw);
233
+ const fmName = typeof fm.name === "string" ? fm.name : s.name;
234
+ const fmDescription = typeof fm.description === "string" ? fm.description : "";
235
+ if (!fmDescription) {
236
+ throw new Error(`runtime-opencode: subagent ${s.name} at ${s.entrypoint} is missing a ` +
237
+ `"description" field in its YAML frontmatter. opencode requires a description ` +
238
+ `to know when to invoke the subagent.`);
239
+ }
240
+ const fmModel = typeof fm.model === "string" && fm.model.length > 0 ? fm.model : undefined;
241
+ // Subagent frontmatter `tools` accepts two formats per Pi's loader:
242
+ // tools:
243
+ // - bash
244
+ // - read
245
+ // OR the comma-separated scalar form:
246
+ // tools: bash,read
247
+ // We accept both so existing subagent .md files continue to work
248
+ // unchanged. Codex pass 1 of slice 2.5 caught that we only accepted
249
+ // the array form, silently dropping the scalar form.
250
+ let fmTools;
251
+ if (Array.isArray(fm.tools)) {
252
+ fmTools = fm.tools.filter((t) => typeof t === "string");
253
+ }
254
+ else if (typeof fm.tools === "string" && fm.tools.length > 0) {
255
+ fmTools = fm.tools.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
256
+ }
257
+ const systemPrompt = stripFrontmatter(raw).trim();
258
+ out.push({
259
+ name: fmName,
260
+ description: fmDescription,
261
+ ...(fmTools ? { tools: fmTools } : {}),
262
+ ...(fmModel ? { model: fmModel } : {}),
263
+ systemPrompt,
264
+ });
265
+ }
266
+ return out;
267
+ }
268
+ // ── main session loop ─────────────────────────────────────────────────────
269
+ async function runOpencode(spec, sessionId) {
270
+ const hallucinationMode = resolveHallucinationMode(spec);
271
+ let configDir;
272
+ let server;
273
+ // SIGINT/SIGTERM handler: when agentctl stops this process via
274
+ // LocalBackend.Stop, Node exits without running the finally block.
275
+ // We emit a `session.ended { reason: "cancelled" }` wire event so the CLI's
276
+ // event loop observes a clean terminal event (not a synthetic runtime error),
277
+ // then close the opencode server child so port 4096 isn't occupied by a
278
+ // zombie for subsequent runs. Codex pass 4 caught the resource leak;
279
+ // codex pass 5 caught the missing cancellation wire event.
280
+ // AbortController for createOpencode startup. If SIGINT/SIGTERM arrives
281
+ // during the `await createOpencode()` call, the SDK will abort the server
282
+ // spawn attempt so we don't orphan the opencode child process. Without
283
+ // this, `server` is still `undefined` when shutdownOnSignal fires and the
284
+ // spawned process is left running. Codex pass 16 of slice 2.4 caught.
285
+ const abortController = new AbortController();
286
+ const shutdownOnSignal = () => {
287
+ abortController.abort(); // cancel any in-flight createOpencode() call
288
+ // Clean up temp workdir so cancelled runs don't leak agent-controller-
289
+ // opencode-* dirs in /tmp. Codex pass 35 caught that process.exit(130)
290
+ // bypasses the finally block that does this cleanup normally.
291
+ if (configDir) {
292
+ try {
293
+ rmSync(configDir, { recursive: true, force: true });
294
+ }
295
+ catch { /* ignore */ }
296
+ }
297
+ try {
298
+ if (sessionId) {
299
+ const line = JSON.stringify(stamp(sessionId, "session.ended", { reason: "cancelled" })) + "\n";
300
+ process.stdout.write(line, () => {
301
+ server?.close();
302
+ process.exit(130);
303
+ });
304
+ return; // exit called in write callback
305
+ }
306
+ }
307
+ catch { /* ignore */ }
308
+ server?.close();
309
+ process.exit(130);
310
+ };
311
+ process.once("SIGINT", shutdownOnSignal);
312
+ process.once("SIGTERM", shutdownOnSignal);
313
+ try {
314
+ // Fail fast for unsupported operations BEFORE starting any child processes.
315
+ // As of v0.3.4 (slice 3.4) the canonical --resume rejection lives in the
316
+ // CLI (cli/cmd/agentctl/main.go), which fails before parseValidateCompile
317
+ // hands off to the adapter. This check stays as defense-in-depth for
318
+ // hand-crafted CompiledSpecs piped directly into the adapter binary —
319
+ // those bypass the CLI gate but still get caught here.
320
+ if (spec.sessionId) {
321
+ throw new Error(`runtime-opencode: --resume <id> is not yet supported for the opencode adapter (spec.sessionId = "${spec.sessionId}"). ` +
322
+ "Session resumption for opencode is planned for a future slice. Use the Pi adapter (runtime.type: local or local-pi) if you need --resume.");
323
+ }
324
+ configDir = makeTempWorkdir();
325
+ // Build the opencode config from the ADL spec. The SDK passes this to
326
+ // opencode via the OPENCODE_CONFIG_CONTENT env var (not a config file).
327
+ //
328
+ // Slice 2.5 wires three previously-rejected fields:
329
+ // - spec.skills[] → inline SKILL.md bodies into the system prompt
330
+ // (no opencode-native concept; same pattern as Pi)
331
+ // - spec.subagents[] → opencode cfg.agent[name] with mode="subagent"
332
+ // (opencode-native subagent support)
333
+ // - spec.mcpServers[] → opencode cfg.mcp[name] (opencode-native MCP)
334
+ //
335
+ // Fields that remain rejected (Pi-specific, no opencode equivalent):
336
+ // - spec.extensions[] — Pi extension JS modules don't run in opencode
337
+ // - spec.installs[] — deprecated; use spec.extensions[].source
338
+ //
339
+ // As of v0.3.4 the canonical rejection lives in
340
+ // cli/internal/adl/compiler.go::checkOpencodeIncompatibilities, so
341
+ // `agentctl compile` catches these before any adapter starts. The
342
+ // runtime checks below stay as defense-in-depth: a hand-crafted
343
+ // CompiledSpec that bypasses the compiler (e.g. piped straight
344
+ // into the adapter binary) still gets rejected here.
345
+ const unsupportedFields = [];
346
+ const allExtensions = spec.extensions ?? [];
347
+ if (allExtensions.length > 0) {
348
+ unsupportedFields.push(`spec.extensions (${allExtensions.length} declared) — Pi extension modules cannot run in opencode; the opencode adapter does not support custom Pi-format extensions`);
349
+ }
350
+ if ((spec.installs ?? []).length > 0) {
351
+ unsupportedFields.push(`spec.installs (${spec.installs.length} entries) — deprecated; use spec.extensions[].source on the Pi adapter (runtime.type: local)`);
352
+ }
353
+ if (unsupportedFields.length > 0) {
354
+ throw new Error(`runtime-opencode: spec declares capabilities not supported by the opencode adapter:\n` +
355
+ unsupportedFields.map((f) => ` - ${f}`).join("\n") + "\n" +
356
+ "Either remove these from the spec or use runtime.type: local (Pi adapter) which supports them.");
357
+ }
358
+ // Resolve skill bodies + subagent definitions BEFORE calling buildOpencodeConfig.
359
+ // Both involve fs reads of the per-ref entrypoint paths the compiler resolved.
360
+ const skillBodies = readSkillBodies(spec.skills ?? []);
361
+ const subagentDefinitions = readSubagentDefinitions(spec.subagents ?? []);
362
+ const cfg = buildOpencodeConfig(spec, { skillBodies, subagentDefinitions });
363
+ // Isolate opencode from ALL ambient user config: skills, MCP servers,
364
+ // plugins, CLAUDE.md files, ~/.opencode, XDG data dirs, etc. Redirect
365
+ // HOME, XDG_CONFIG_HOME, and XDG_DATA_HOME to our temp workspace so the
366
+ // spawned opencode process sees only our ADL-derived config.
367
+ //
368
+ // Auth implication: opencode must get credentials from environment variables
369
+ // (ANTHROPIC_API_KEY, etc.) rather than from auth.json files in the real
370
+ // HOME. This is the correct behavior for an ADL-governed agent — auth
371
+ // comes from the operator's environment, not user-specific dotfiles. If
372
+ // auth is stored only in ~/.opencode/auth.json, the local-opencode adapter
373
+ // will fail to authenticate; set ANTHROPIC_API_KEY instead.
374
+ //
375
+ // Codex passes 32-34 escalated the isolation requirement: XDG_CONFIG_HOME
376
+ // alone doesn't prevent opencode from loading ~/.opencode or CLAUDE.md.
377
+ const opencodeXdgConfigDir = join(configDir, ".config", "opencode");
378
+ mkdirSync(opencodeXdgConfigDir, { recursive: true });
379
+ writeFileSync(join(opencodeXdgConfigDir, "opencode.json"), JSON.stringify(cfg, null, 2), "utf8");
380
+ const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
381
+ const originalXdgDataHome = process.env.XDG_DATA_HOME;
382
+ const originalHome = process.env.HOME;
383
+ process.env.XDG_CONFIG_HOME = configDir;
384
+ process.env.XDG_DATA_HOME = configDir;
385
+ process.env.HOME = configDir;
386
+ // Spawn an opencode server + connected client. Use port 0 to let the OS
387
+ // allocate a free ephemeral port instead of always binding to 4096.
388
+ // Without dynamic ports, two concurrent local-opencode runs (or a dev's
389
+ // existing opencode instance) would collide and fail at server start.
390
+ // Codex pass 5 of slice 2.4 caught the fixed-port issue.
391
+ // Preflight: ensure the `opencode` binary is available before spawning
392
+ // the server. `which` is POSIX-only and fails on Windows or minimal
393
+ // images. Instead we probe by running `opencode --version` with
394
+ // `shell: true` so the OS (including cmd.exe) can find `.cmd` / `.bat`
395
+ // variants via PATH. Codex pass 24 added the check; pass 25 caught the
396
+ // cross-platform gap.
397
+ const probeResult = spawnSync("opencode", ["--version"], {
398
+ encoding: "utf8",
399
+ shell: true, // lets cmd.exe resolve opencode.cmd on Windows
400
+ timeout: 5000, // don't wait forever if the binary hangs
401
+ });
402
+ if (probeResult.error || (probeResult.status !== 0 && probeResult.status !== null)) {
403
+ throw new Error("runtime-opencode: the `opencode` CLI is not installed or not on PATH. " +
404
+ "Install it with `npm install -g opencode-ai` or follow the setup guide at " +
405
+ "https://opencode.ai/docs/. The local-opencode adapter requires the CLI to be " +
406
+ "available as a separate process.");
407
+ }
408
+ // Also chdir to configDir before spawning so the opencode server process
409
+ // starts with configDir as its cwd. opencode scans cwd (and ancestors)
410
+ // for project-level `.opencode/` or `opencode.json`; by starting in an
411
+ // isolated temp dir that has no such files, we prevent project config
412
+ // from leaking into the session. Sessions still reference projectCwd via
413
+ // the `directory` query param per-request. Codex pass 33 caught that
414
+ // XDG_CONFIG_HOME alone didn't isolate project-level configs.
415
+ const originalCwd = process.cwd();
416
+ process.chdir(configDir);
417
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
418
+ const oc = await createOpencode({ config: cfg, port: 0, signal: abortController.signal });
419
+ // Restore cwd, HOME, and XDG env vars after the server spawns.
420
+ process.chdir(originalCwd);
421
+ if (originalHome === undefined)
422
+ delete process.env.HOME;
423
+ else
424
+ process.env.HOME = originalHome;
425
+ if (originalXdgConfigHome === undefined)
426
+ delete process.env.XDG_CONFIG_HOME;
427
+ else
428
+ process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
429
+ if (originalXdgDataHome === undefined)
430
+ delete process.env.XDG_DATA_HOME;
431
+ else
432
+ process.env.XDG_DATA_HOME = originalXdgDataHome;
433
+ server = oc.server;
434
+ const client = oc.client;
435
+ // The session's working directory must be the caller's project root, not
436
+ // the temp config dir. Specs that grant read/edit/bash tools need to
437
+ // see the project files; editing in an empty temp dir then deleting it
438
+ // would lose all work. Codex pass 2 of slice 2.4 caught the wrong cwd.
439
+ const projectCwd = process.cwd();
440
+ // (resume check was moved earlier, to the top of the try block, before
441
+ // createOpencode() — codex pass 20 of slice 2.4 caught the ordering.)
442
+ // Create a new session scoped to the caller's working directory.
443
+ const sessionResp = await client.session.create({
444
+ query: { directory: projectCwd },
445
+ });
446
+ if (!sessionResp.data) {
447
+ throw new Error("opencode: session.create returned no data");
448
+ }
449
+ const opencodeSessionId = sessionResp.data.id;
450
+ // SSE producer-consumer: subscribe to the global event stream and
451
+ // immediately start a concurrent consumer (fire-and-forget IIFE) that
452
+ // pushes events to an internal queue. This ensures the HTTP subscription
453
+ // is active BEFORE we call promptAsync, so we never miss events.
454
+ //
455
+ // The SDK's client.global.event() returns a lazy generator; the HTTP
456
+ // connection only starts on the first .next() call. By starting the IIFE
457
+ // before promptAsync, the IIFE's for-await queues the first .next() in
458
+ // the same microtask batch. By the time the promptAsync network round-trip
459
+ // completes, the SSE connection is already live.
460
+ //
461
+ // P2 fix (codex pass 23): pass sseMaxRetryAttempts to cap retries so a
462
+ // permanently-broken SSE connection terminates instead of retrying forever.
463
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
464
+ const sseStream = await client.global.event({ ...({ sseMaxRetryAttempts: 3 }) });
465
+ const sseQueue = [];
466
+ let sseEnded = false;
467
+ let sseWaiter;
468
+ // Track when the SSE connection is live so we can wait before prompting.
469
+ let sseConnected = false;
470
+ let sseConnectedResolve;
471
+ const sseConnectedPromise = new Promise((resolve) => {
472
+ sseConnectedResolve = resolve;
473
+ });
474
+ (async () => {
475
+ try {
476
+ for await (const ev of sseStream.stream) {
477
+ // The `server.connected` event signals the SSE subscription is live.
478
+ // GlobalEvent has shape { directory, payload: { type, properties } }
479
+ // — the type lives in ev.payload.type, NOT ev.type. Codex pass 29
480
+ // caught the wrong path; pass 28 identified the race condition.
481
+ const evPayloadType = ev.payload?.type;
482
+ if (evPayloadType === "server.connected" && !sseConnected) {
483
+ sseConnected = true;
484
+ sseConnectedResolve?.();
485
+ sseConnectedResolve = undefined;
486
+ }
487
+ sseQueue.push(ev);
488
+ sseWaiter?.();
489
+ sseWaiter = undefined;
490
+ }
491
+ }
492
+ catch (err) {
493
+ sseConnectedResolve?.();
494
+ sseConnectedResolve = undefined;
495
+ sseQueue.push({ __sseConnectionError: err instanceof Error ? err.message : String(err) });
496
+ sseWaiter?.();
497
+ sseWaiter = undefined;
498
+ }
499
+ finally {
500
+ sseConnectedResolve?.();
501
+ sseConnectedResolve = undefined;
502
+ sseEnded = true;
503
+ sseWaiter?.();
504
+ sseWaiter = undefined;
505
+ }
506
+ })();
507
+ // Wait until the SSE subscription is live before prompting. If the
508
+ // server.connected event doesn't arrive within 3s, we throw rather
509
+ // than proceeding — a fast model turn could complete before the
510
+ // adapter is subscribed, causing us to miss all events and report a
511
+ // false success. Codex pass 31 caught the silent proceed-on-timeout.
512
+ // Clear the timer when server.connected arrives before the timeout so
513
+ // Node's event loop is not kept alive for the remainder of the 3 seconds.
514
+ // Codex pass 35 of slice 2.4 caught the missing clearTimeout/unref.
515
+ let sseConnectTimedOut = false;
516
+ let sseConnectTimer;
517
+ await Promise.race([
518
+ sseConnectedPromise,
519
+ new Promise((resolve) => {
520
+ sseConnectTimer = setTimeout(() => { sseConnectTimedOut = true; resolve(); }, 3000);
521
+ // unref so the timer alone doesn't keep Node alive in normal exit paths
522
+ sseConnectTimer.unref?.();
523
+ }),
524
+ ]);
525
+ if (sseConnectTimer) {
526
+ clearTimeout(sseConnectTimer);
527
+ sseConnectTimer = undefined;
528
+ }
529
+ if (!sseConnected) {
530
+ throw new Error("runtime-opencode: timed out waiting for SSE server.connected event " +
531
+ "(3s). opencode may be starting slowly or the global event stream is " +
532
+ "unavailable. Try again or check the opencode server logs.");
533
+ }
534
+ void sseConnectTimedOut; // suppress unused-variable warning
535
+ // Wait until at least one event is in the queue or the stream ends/errors.
536
+ async function waitForSseEvent() {
537
+ if (sseQueue.length > 0 || sseEnded)
538
+ return;
539
+ return new Promise((resolve) => { sseWaiter = resolve; });
540
+ }
541
+ // Emit session.started now that opencode is up and the session exists.
542
+ emit(stamp(sessionId, "session.started", {
543
+ agentName: spec.metadata.name,
544
+ model: spec.model,
545
+ }));
546
+ // promptAsync: submit the prompt after the SSE subscription is confirmed live.
547
+ const promptResp = await client.session.promptAsync({
548
+ path: { id: opencodeSessionId },
549
+ query: { directory: projectCwd },
550
+ body: {
551
+ agent: spec.metadata.name,
552
+ parts: [{ type: "text", text: spec.task }],
553
+ },
554
+ });
555
+ // Check for HTTP-level errors on the prompt submission.
556
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
557
+ if (promptResp.error) {
558
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
559
+ const detail = promptResp.error?.message ?? JSON.stringify(promptResp.error);
560
+ throw new Error(`opencode: session.promptAsync failed: ${detail}`);
561
+ }
562
+ // Drive the event loop until session.idle (turn complete) or
563
+ // session.error (terminal failure).
564
+ let errorMessage;
565
+ let correctionRequested = false;
566
+ let correctionSent = false;
567
+ const translatorState = createTranslatorState();
568
+ // Helper to process one raw event from the SSE stream.
569
+ function processRawEvent(rawEvent) {
570
+ const gev = rawEvent.payload
571
+ ? rawEvent
572
+ : undefined;
573
+ if (!gev)
574
+ return { idle: false, error: undefined };
575
+ const result = translateEvent(gev, sessionId, opencodeSessionId, translatorState, hallucinationMode);
576
+ for (const wev of result.wireEvents) {
577
+ emit(wev);
578
+ if (wev.type === "warning" && wev.data.kind === "hallucinated_tool_call") {
579
+ if (hallucinationMode === "correct" && !correctionSent)
580
+ correctionRequested = true;
581
+ }
582
+ if (wev.type === "error") {
583
+ errorMessage ??= wev.data.message ?? "opencode error";
584
+ }
585
+ }
586
+ return { idle: result.sessionIdle, error: result.sessionError };
587
+ }
588
+ let mainLoopSawIdle = false;
589
+ mainLoop: while (!errorMessage) {
590
+ await waitForSseEvent();
591
+ while (sseQueue.length > 0) {
592
+ const rawEvent = sseQueue.shift();
593
+ // Handle synthetic SSE connection error.
594
+ if (rawEvent && typeof rawEvent === "object" && "__sseConnectionError" in rawEvent) {
595
+ errorMessage ??= `SSE connection error: ${rawEvent.__sseConnectionError}`;
596
+ break mainLoop;
597
+ }
598
+ const { idle, error } = processRawEvent(rawEvent);
599
+ if (error) {
600
+ errorMessage ??= error;
601
+ break mainLoop;
602
+ }
603
+ if (idle) {
604
+ if (correctionRequested && !correctionSent && !errorMessage) {
605
+ correctionSent = true;
606
+ const corrResp = await client.session.promptAsync({
607
+ path: { id: opencodeSessionId },
608
+ query: { directory: projectCwd },
609
+ body: {
610
+ agent: spec.metadata.name,
611
+ parts: [{ type: "text", text: CORRECTION_PROMPT }],
612
+ },
613
+ });
614
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
615
+ if (corrResp.error) {
616
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
617
+ errorMessage = `correction re-prompt failed: ${corrResp.error?.message ?? "unknown"}`;
618
+ break mainLoop;
619
+ }
620
+ // Keep looping for the second turn's idle.
621
+ break; // break from inner while, outer while continues
622
+ }
623
+ mainLoopSawIdle = true;
624
+ break mainLoop;
625
+ }
626
+ }
627
+ // If the queue is now empty and the SSE stream ended, we're done waiting.
628
+ if (sseEnded && sseQueue.length === 0)
629
+ break;
630
+ }
631
+ // If the main loop exited without observing session.idle (or a
632
+ // session.error which breaks with errorMessage set), the SSE stream closed
633
+ // unexpectedly (server exit, EOF, network drop) before the turn completed.
634
+ // Treat as an error — the turn is unfinished, not successfully completed.
635
+ // Codex pass 13 of slice 2.4 caught this false-positive-success path.
636
+ if (!errorMessage && !mainLoopSawIdle) {
637
+ errorMessage = "opencode: SSE stream closed without session.idle — server may have exited before the turn completed";
638
+ }
639
+ if (errorMessage) {
640
+ emit(stamp(sessionId, "error", { message: errorMessage }));
641
+ emit(stamp(sessionId, "session.ended", { reason: "error", message: errorMessage }));
642
+ return false;
643
+ }
644
+ emit(stamp(sessionId, "session.ended", { reason: "completed" }));
645
+ return true;
646
+ }
647
+ finally {
648
+ // Remove signal listeners so they don't fire again on normal exit.
649
+ process.removeListener("SIGINT", shutdownOnSignal);
650
+ process.removeListener("SIGTERM", shutdownOnSignal);
651
+ server?.close();
652
+ if (configDir) {
653
+ try {
654
+ rmSync(configDir, { recursive: true, force: true });
655
+ }
656
+ catch { /* best-effort */ }
657
+ }
658
+ }
659
+ }
660
+ // ── entry point ───────────────────────────────────────────────────────────
661
+ async function main() {
662
+ let spec;
663
+ try {
664
+ spec = await readSpecFromStdin();
665
+ }
666
+ catch (err) {
667
+ const message = err instanceof Error ? err.message : String(err);
668
+ process.stderr.write(`[runtime-opencode] ${message}\n`);
669
+ return 2;
670
+ }
671
+ const sessionId = `s_${Date.now().toString(36)}`;
672
+ try {
673
+ const ok = await runOpencode(spec, sessionId);
674
+ return ok ? 0 : 1;
675
+ }
676
+ catch (err) {
677
+ // AbortError: startup was cancelled by SIGINT/SIGTERM. The signal handler
678
+ // already emitted session.ended(reason=cancelled) and will call process.exit.
679
+ // Don't double-emit an error here. Codex pass 27 of slice 2.4.
680
+ if (err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted"))) {
681
+ // Signal handler is responsible for cleanup; just return an error code.
682
+ return 130;
683
+ }
684
+ const message = err instanceof Error ? err.message : String(err);
685
+ // Emit a terminal error on the wire so the CLI event loop sees it.
686
+ emit(stamp(sessionId, "error", { message }));
687
+ emit(stamp(sessionId, "session.ended", { reason: "error", message }));
688
+ process.stderr.write(`[runtime-opencode] ${message}\n`);
689
+ return 1;
690
+ }
691
+ }
692
+ main()
693
+ .then((code) => {
694
+ // Use process.exitCode + allow Node to drain stdout naturally rather than
695
+ // calling process.exit() directly. process.exit() does not wait for
696
+ // buffered writes to flush, which can cause the CLI to miss session.ended.
697
+ // Codex pass 27 of slice 2.4 caught this drain issue.
698
+ process.exitCode = code;
699
+ })
700
+ .catch((err) => {
701
+ const message = err instanceof Error ? err.message : String(err);
702
+ process.stderr.write(`[runtime-opencode] uncaught: ${message}\n`);
703
+ process.exitCode = 1;
704
+ });