@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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/event-translator.d.ts +77 -0
- package/dist/event-translator.js +322 -0
- package/dist/honesty.d.ts +59 -0
- package/dist/honesty.js +226 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +704 -0
- package/dist/opencode-config.d.ts +165 -0
- package/dist/opencode-config.js +517 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +2 -0
- package/dist/wire.d.ts +5 -0
- package/dist/wire.js +8 -0
- package/package.json +50 -0
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
|
+
});
|