@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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Map a CompiledSpec to opencode's `opencode.json` agent-config shape.
3
+ *
4
+ * Phase 2 slice 2.2 (this file): a pure function that builds the agent
5
+ * config opencode expects. No SDK invocation, no file I/O — the caller
6
+ * (slice 2.3+ will be runtime-opencode/src/index.ts after the SDK is
7
+ * wired in) is responsible for writing the JSON to disk and pointing
8
+ * opencode at it.
9
+ *
10
+ * Source of truth for the opencode config shape:
11
+ * https://opencode.ai/docs/config/
12
+ * https://opencode.ai/docs/agents/
13
+ *
14
+ * Key differences between this adapter's mapping and the Pi adapter:
15
+ *
16
+ * - Pi extension-format tools (anything declared in spec.tools[] with
17
+ * `entrypoint` but not `builtin`) are NOT translatable to opencode.
18
+ * Opencode loads its own native tools and MCP-discovered tools;
19
+ * it does not execute Pi-format extension JS modules. We REJECT
20
+ * specs that declare custom Pi-extension tools when targeting
21
+ * opencode. This matches the harness-matrix ❌ cell — "rejected at
22
+ * compile time with a field-naming error."
23
+ *
24
+ * - opencode's tool registry is `permissions: { <tool>: "allow"|"ask"|"deny" }`.
25
+ * The old top-level `tools: {...}` field is deprecated. We emit the
26
+ * new permission shape.
27
+ *
28
+ * - opencode's model id is `<provider>/<modelId>`. Pi's getModel takes
29
+ * (provider, name) separately. We concatenate.
30
+ *
31
+ * - opencode's `prompt` field accepts either an inline string or a
32
+ * path to a prompt file. Inline is simpler and avoids a transient
33
+ * file-write; we always inline. The honesty preamble + persona
34
+ * instructions + skill bodies (when slice 2.3 lands) are joined
35
+ * with double newlines into the single prompt string.
36
+ *
37
+ * What slice 2.2 does NOT yet do (deferred to 2.3+):
38
+ * - Skill body inlining (the v0.1.7 "active by default" lesson) —
39
+ * skills require fs reads and we keep this slice pure.
40
+ * - MCP server config — opencode has native MCP; mapping is small but
41
+ * belongs in slice 2.3.
42
+ * - Subagents — opencode has its own subagent format; slice 2.3.
43
+ * - Extensions with `source: npm:...` — handled by opencode's package
44
+ * install path or rejected; slice 2.3 design decision.
45
+ */
46
+ import type { CompiledSpec } from "./types.js";
47
+ /**
48
+ * Pre-resolved skill body, ready to be inlined into the system prompt.
49
+ * The caller (index.ts) reads spec.skills[].entrypoint from disk, strips
50
+ * YAML frontmatter, and passes the resulting bodies in.
51
+ *
52
+ * Kept out of buildOpencodeConfig so that function remains testable without
53
+ * fs fixtures — same I/O separation Pi adapter uses.
54
+ */
55
+ export interface SkillBody {
56
+ name: string;
57
+ body: string;
58
+ }
59
+ /**
60
+ * Pre-resolved subagent definition, ready to be added to opencode's
61
+ * `cfg.agent` map. The caller reads spec.subagents[].entrypoint (a .md file
62
+ * with YAML frontmatter), parses it, and passes the structured fields in.
63
+ *
64
+ * Field semantics mirror Pi's AgentConfig (see extensions/subagent/agents.ts):
65
+ * - name: agent identifier
66
+ * - description: when the model should invoke this subagent
67
+ * - tools: optional Pi built-in tool names the subagent may use; mapped to
68
+ * opencode permission keys the same way the primary agent's tools are
69
+ * - model: optional override of provider/name; falls back to primary model
70
+ * - systemPrompt: the subagent's system prompt body (markdown after the
71
+ * frontmatter)
72
+ */
73
+ export interface SubagentDefinition {
74
+ name: string;
75
+ description: string;
76
+ tools?: string[];
77
+ model?: string;
78
+ systemPrompt: string;
79
+ }
80
+ /**
81
+ * The subset of opencode's agent config shape we currently target.
82
+ * Intentionally narrow — opencode supports many more knobs (temperature,
83
+ * mode, model rules, etc.) but we only emit what ADL drives today. Add
84
+ * fields here as the corresponding ADL surface is mapped.
85
+ *
86
+ * The string keys are opencode's; do NOT rename without checking the
87
+ * opencode docs and the SDK's TypeScript types (slice 2.3 will pull
88
+ * those in).
89
+ */
90
+ export interface OpencodeAgentConfig {
91
+ description: string;
92
+ model: string;
93
+ prompt: string;
94
+ temperature?: number;
95
+ /**
96
+ * Subagent mode: opencode treats agents with mode="subagent" as
97
+ * delegate-only — they can be invoked by the primary agent via the
98
+ * task tool but won't appear as a top-level chat interface. The
99
+ * primary agent (the one named in spec.metadata.name) omits this
100
+ * field so opencode treats it as mode="all" by default. Codex
101
+ * pass guidance: opencode supports "subagent" | "primary" | "all".
102
+ */
103
+ mode?: "subagent" | "primary" | "all";
104
+ permission?: Record<string, "allow" | "ask" | "deny">;
105
+ /**
106
+ * Disable this agent entry entirely so opencode skips it. Used to
107
+ * mask out opencode's native agents (general/explore/plan/build)
108
+ * so they cannot be invoked via task tool. Codex pass 4 of slice 2.5.
109
+ */
110
+ disable?: boolean;
111
+ }
112
+ /** opencode MCP server config — local stdio. */
113
+ export interface OpencodeMcpLocal {
114
+ type: "local";
115
+ command: string[];
116
+ environment?: Record<string, string>;
117
+ enabled?: boolean;
118
+ }
119
+ /** opencode MCP server config — remote HTTP/SSE. */
120
+ export interface OpencodeMcpRemote {
121
+ type: "remote";
122
+ url: string;
123
+ headers?: Record<string, string>;
124
+ enabled?: boolean;
125
+ }
126
+ export type OpencodeMcpConfig = OpencodeMcpLocal | OpencodeMcpRemote;
127
+ export interface OpencodeRootConfig {
128
+ $schema: string;
129
+ agent: Record<string, OpencodeAgentConfig>;
130
+ /**
131
+ * MCP (Model Context Protocol) servers. Each key is the server name and
132
+ * the value is a stdio or remote transport config. Tools the MCP server
133
+ * exposes appear in opencode under permission keys of the form
134
+ * `<server>_<tool>`; ADL's allowlist already denies "*" so these are
135
+ * inactive unless the spec explicitly grants `mcp_servers` access.
136
+ * Optional — omitted when spec.mcpServers is absent or empty.
137
+ */
138
+ mcp?: Record<string, OpencodeMcpConfig>;
139
+ }
140
+ /**
141
+ * Build an opencode root config from a CompiledSpec. Pure — does no
142
+ * file I/O and reads no env. Throws on inputs that cannot be safely
143
+ * mapped (e.g. custom Pi-extension tools targeting opencode), with an
144
+ * error message that names the unsupported field.
145
+ *
146
+ * `agentName` defaults to spec.metadata.name; callers can override
147
+ * (e.g. for hermetic test fixtures) when needed.
148
+ */
149
+ export declare function buildOpencodeConfig(spec: CompiledSpec, options?: {
150
+ agentName?: string;
151
+ /**
152
+ * Pre-resolved skill bodies, in declaration order. Each body is
153
+ * appended to the system prompt wrapped via wrapSkillBody() so the
154
+ * model can see what skill it's reading.
155
+ *
156
+ * Caller (index.ts) is responsible for reading spec.skills[].entrypoint
157
+ * (SKILL.md files) and stripping YAML frontmatter before passing here.
158
+ */
159
+ skillBodies?: SkillBody[];
160
+ /**
161
+ * Pre-resolved subagent definitions. Each becomes an entry in
162
+ * opencode's `cfg.agent` map with `mode: "subagent"`.
163
+ */
164
+ subagentDefinitions?: SubagentDefinition[];
165
+ }): OpencodeRootConfig;
@@ -0,0 +1,517 @@
1
+ import { HONESTY_PREAMBLE, wrapSkillBody } from "./honesty.js";
2
+ /**
3
+ * Map Pi built-in tool names to the opencode permission key that
4
+ * controls them. The mapping is NOT identity:
5
+ *
6
+ * bash → bash (1:1)
7
+ * read → read (1:1)
8
+ * edit → edit (1:1, controls opencode's `edit` and `apply_patch`)
9
+ * write → edit (opencode does not expose a separate `write`
10
+ * permission; file creation/overwrite is also
11
+ * gated by `edit`. Codex pass 1 of slice 2.2
12
+ * caught the earlier 1:1 mapping, which would
13
+ * have left write-only specs unable to modify
14
+ * any files.)
15
+ *
16
+ * If a spec declares BOTH `edit` and `write` (a common combination),
17
+ * both map to "edit: allow" — the duplicate is idempotent and
18
+ * harmless. If opencode later adds a distinct `write` permission this
19
+ * table will need to revisit the mapping.
20
+ *
21
+ * Source: https://opencode.ai/docs/config/ — only read, edit, glob,
22
+ * grep, list, bash, task, external_directory, lsp, and skill accept
23
+ * the shorthand-or-pattern form; `write` is not on opencode's
24
+ * permission key list.
25
+ */
26
+ /**
27
+ * Maps Pi built-in tool names to the opencode AgentConfig.permission key
28
+ * that controls them. Only keys in OPENCODE_PERMISSION_KEYS_SUPPORTED are
29
+ * actionable; others compile but are silently ignored.
30
+ *
31
+ * Notable gaps:
32
+ * read → no supported opencode permission key (read is always available
33
+ * in opencode; it cannot be denied via the permission config).
34
+ * Omitted intentionally; declaring it in spec.tools[] adds it to
35
+ * the grant list but has no effect on opencode's actual behavior.
36
+ * write → edit (opencode gates file-write via the `edit` permission).
37
+ */
38
+ /**
39
+ * Maps Pi built-in tool names to the opencode permission key.
40
+ * Per opencode.ai/docs config reference (and confirmed by codex pass 15
41
+ * of slice 2.4), `read` IS a supported permission key. Earlier codex
42
+ * passes had flagged it as unsupported based on the SDK types alone,
43
+ * but the runtime accepts it via the AgentConfig index signature.
44
+ */
45
+ const PI_TO_OPENCODE_PERMISSION_KEY = {
46
+ // Pi built-in names (the four ADL currently recognizes for primary agents).
47
+ bash: "bash",
48
+ read: "read",
49
+ edit: "edit",
50
+ write: "edit",
51
+ // Additional names that appear in Pi subagent frontmatter (e.g.
52
+ // `tools: read, grep, find, ls`). These aren't ADL primary-agent built-
53
+ // ins, but subagent .md files are parsed independently and may list any
54
+ // tool Pi's registry exposes. Map each to its opencode equivalent so
55
+ // the subagent permission map is correct. Codex pass 2 of slice 2.5 caught.
56
+ grep: "grep",
57
+ find: "glob",
58
+ ls: "list",
59
+ // Accept opencode-native names too so frontmatter authors targeting
60
+ // opencode directly don't have to translate.
61
+ glob: "glob",
62
+ list: "list",
63
+ webfetch: "webfetch",
64
+ websearch: "websearch",
65
+ task: "task",
66
+ };
67
+ /**
68
+ * Build an opencode root config from a CompiledSpec. Pure — does no
69
+ * file I/O and reads no env. Throws on inputs that cannot be safely
70
+ * mapped (e.g. custom Pi-extension tools targeting opencode), with an
71
+ * error message that names the unsupported field.
72
+ *
73
+ * `agentName` defaults to spec.metadata.name; callers can override
74
+ * (e.g. for hermetic test fixtures) when needed.
75
+ */
76
+ export function buildOpencodeConfig(spec, options = {}) {
77
+ rejectUnsupportedTools(spec);
78
+ const agentName = options.agentName ?? spec.metadata.name;
79
+ const prompt = buildPromptString(spec, options.skillBodies ?? []);
80
+ const subagentDefs = options.subagentDefinitions ?? [];
81
+ const mcpServers = spec.mcpServers ?? [];
82
+ const permissions = buildPermissions(spec.tools ?? [], {
83
+ hasSubagents: subagentDefs.length > 0,
84
+ mcpServerNames: mcpServers.map((s) => s.name),
85
+ });
86
+ const agent = {
87
+ description: spec.metadata.description ??
88
+ `Agent ${spec.metadata.name} (compiled from ADL by agent-controller).`,
89
+ model: `${spec.model.provider}/${spec.model.name}`,
90
+ prompt,
91
+ ...(spec.model.temperature !== undefined ? { temperature: spec.model.temperature } : {}),
92
+ permission: permissions,
93
+ };
94
+ // Build the agent map: primary agent first, then any subagents.
95
+ // Null-prototype object so user-supplied names like "constructor" or
96
+ // "__proto__" don't collide with Object.prototype. Codex pass 6 of slice 2.5.
97
+ const agentMap = Object.create(null);
98
+ agentMap[agentName] = agent;
99
+ for (const sa of options.subagentDefinitions ?? []) {
100
+ if (sa.name === agentName) {
101
+ throw new Error(`runtime-opencode: subagent name "${sa.name}" collides with the primary ` +
102
+ `agent name. Rename the subagent or the spec metadata.name.`);
103
+ }
104
+ if (NATIVE_OPENCODE_AGENTS.includes(sa.name)) {
105
+ throw new Error(`runtime-opencode: subagent name "${sa.name}" collides with an opencode ` +
106
+ `native agent. The adapter disables these natives to preserve ADL's ` +
107
+ `allowlist contract. Rename the subagent. ` +
108
+ `(Reserved names: ${NATIVE_OPENCODE_AGENTS.join(", ")}.)`);
109
+ }
110
+ if (Object.hasOwn(agentMap, sa.name)) {
111
+ throw new Error(`runtime-opencode: duplicate subagent name "${sa.name}" in spec.subagents[].`);
112
+ }
113
+ agentMap[sa.name] = buildSubagentConfig(sa, spec);
114
+ }
115
+ // Disable opencode's native agents so they cannot be delegated to via
116
+ // task tool. SKIP any native whose name collides with the primary agent
117
+ // (codex pass 5 of slice 2.5: the ADL schema allows metadata.name = "plan"
118
+ // / "build" / "general" / "explore", and the primary agent must remain
119
+ // executable in that case). Subagent names colliding with natives are
120
+ // already rejected in the loop above, so the only overlap to defend
121
+ // against here is the primary agent name.
122
+ for (const nativeName of NATIVE_OPENCODE_AGENTS) {
123
+ if (nativeName === agentName)
124
+ continue;
125
+ agentMap[nativeName] = {
126
+ description: "(disabled by agent-controller)",
127
+ model: `${spec.model.provider}/${spec.model.name}`,
128
+ prompt: "(disabled)",
129
+ disable: true,
130
+ };
131
+ }
132
+ const root = {
133
+ $schema: "https://opencode.ai/config.json",
134
+ agent: agentMap,
135
+ };
136
+ // MCP servers (optional). Omit the field entirely when the spec declares
137
+ // none — keeps the config minimal and matches opencode's "absent ≠ empty"
138
+ // expectation for top-level optional sections.
139
+ const mcp = buildMcpConfig(spec.mcpServers ?? []);
140
+ if (Object.keys(mcp).length > 0)
141
+ root.mcp = mcp;
142
+ return root;
143
+ }
144
+ /**
145
+ * Map a pre-resolved SubagentDefinition into opencode's AgentConfig shape.
146
+ * The subagent inherits the primary spec's model when its own model is
147
+ * not specified — keeping behavior predictable when the spec author
148
+ * doesn't pin a model per subagent.
149
+ */
150
+ function buildSubagentConfig(sa, spec) {
151
+ // Subagent tools are a subset of Pi built-ins — map them through the same
152
+ // PI_TO_OPENCODE_PERMISSION_KEY table the primary agent uses. Anything not
153
+ // in the table is an unknown built-in and we throw (caller's responsibility
154
+ // to validate the .md file before passing in).
155
+ const grants = { "*": "deny" };
156
+ for (const key of OPENCODE_PERMISSION_KEYS_DENY_BASELINE) {
157
+ grants[key] = "deny";
158
+ }
159
+ for (const toolName of sa.tools ?? []) {
160
+ if (!(toolName in PI_TO_OPENCODE_PERMISSION_KEY)) {
161
+ throw new Error(`runtime-opencode: subagent "${sa.name}" declares tool ${JSON.stringify(toolName)} ` +
162
+ `which is not a known Pi built-in. Allowed: ${Object.keys(PI_TO_OPENCODE_PERMISSION_KEY).join(", ")}.`);
163
+ }
164
+ grants[PI_TO_OPENCODE_PERMISSION_KEY[toolName]] = "allow";
165
+ }
166
+ // Normalize the subagent's model to opencode's `provider/model` format.
167
+ // Pi convention often writes just the bare model id (`claude-sonnet-4-...`),
168
+ // which opencode cannot resolve. If the subagent's model contains a `/`
169
+ // it's already fully qualified; otherwise prepend the primary spec's
170
+ // provider. Codex pass 1 of slice 2.5 caught the missing normalization.
171
+ const subagentModel = sa.model
172
+ ? (sa.model.includes("/") ? sa.model : `${spec.model.provider}/${sa.model}`)
173
+ : `${spec.model.provider}/${spec.model.name}`;
174
+ return {
175
+ description: sa.description,
176
+ model: subagentModel,
177
+ prompt: sa.systemPrompt,
178
+ mode: "subagent",
179
+ permission: grants,
180
+ };
181
+ }
182
+ /**
183
+ * Reject MCP server names whose `<name>_*` allow pattern would override the
184
+ * deny baseline for an opencode built-in. opencode matches permission keys
185
+ * as wildcard patterns, so a server named "repo" would have `repo_*: "allow"`
186
+ * which also matches the built-in `repo_clone` / `repo_overview` keys —
187
+ * effectively granting them despite the baseline deny.
188
+ *
189
+ * Built into the deny baseline today: doom_loop, external_directory,
190
+ * repo_clone, repo_overview. The forbidden MCP-name prefixes are therefore:
191
+ * doom, external, repo. The check is derived from the baseline (not
192
+ * hardcoded) so it stays accurate if the baseline grows.
193
+ *
194
+ * Codex pass 3 of slice 2.5 caught this allowlist-broadening bug.
195
+ */
196
+ function rejectCollidingMcpName(name) {
197
+ // We emit both raw `<name>_*` and sanitized `<sanitized>_*` allow patterns
198
+ // (because opencode's actual tool-id format isn't fully determined). Check
199
+ // both prefixes against the built-in deny baseline so neither pattern can
200
+ // accidentally re-grant a built-in.
201
+ const sanitized = sanitizeMcpName(name);
202
+ const candidatePrefixes = sanitized === name ? [`${name}_`] : [`${name}_`, `${sanitized}_`];
203
+ for (const builtinKey of OPENCODE_PERMISSION_KEYS_DENY_BASELINE) {
204
+ for (const candidatePrefix of candidatePrefixes) {
205
+ if (builtinKey.startsWith(candidatePrefix)) {
206
+ throw new Error(`runtime-opencode: MCP server name "${name}" collides with the opencode ` +
207
+ `built-in permission key "${builtinKey}". The implicit "${candidatePrefix}*" allow ` +
208
+ `grant for this server's tools would also match the built-in, breaking the ` +
209
+ `ADL deny-by-default contract. Rename the MCP server in spec.mcpServers[].`);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ /**
215
+ * Mirror opencode's MCP-tool-id sanitization rule: replace every character
216
+ * outside [A-Za-z0-9] with `_`. opencode generates tool ids of the form
217
+ * `<sanitized-server-name>_<tool-name>`; our permission keys must use the
218
+ * same sanitization to match. Codex pass 4 of slice 2.5 caught the
219
+ * mismatch for server names like "github.com".
220
+ */
221
+ function sanitizeMcpName(name) {
222
+ return name.replace(/[^A-Za-z0-9]/g, "_");
223
+ }
224
+ /**
225
+ * Reject MCP server names that contain characters which would create
226
+ * overmatching wildcard patterns in opencode's permission grants. We emit
227
+ * the raw name as `<name>_*` in the permission map; if the name contains
228
+ * glob metacharacters (`*`, `?`, `[`, `]`, `{`, `}`), the resulting pattern
229
+ * could match arbitrary opencode built-ins (e.g. `repo*_*` matches `repo_clone`)
230
+ * and re-enable tools outside the ADL allowlist. Codex pass 8 of slice 2.5.
231
+ *
232
+ * Allowed character set is the same one MCP servers conventionally use for
233
+ * identifiers: alphanumerics plus `.`, `-`, `_`. Anything else throws.
234
+ */
235
+ function rejectUnsafeMcpNameChars(name) {
236
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
237
+ throw new Error(`runtime-opencode: MCP server name "${name}" contains characters outside ` +
238
+ `the allowed set [A-Za-z0-9._-]. Glob metacharacters (* ? [ ]) and other ` +
239
+ `non-identifier characters could create overmatching opencode permission ` +
240
+ `wildcards that re-enable built-in tools outside the ADL allowlist. ` +
241
+ `Rename the MCP server in spec.mcpServers[].`);
242
+ }
243
+ }
244
+ /**
245
+ * Map ADL spec.mcpServers[] to opencode's cfg.mcp shape.
246
+ *
247
+ * Transport mapping:
248
+ * - "stdio" → { type: "local", command: [command, ...args], environment: env }
249
+ * - "streamable-http" → { type: "remote", url, headers }
250
+ * - "sse" → { type: "remote", url, headers }
251
+ *
252
+ * opencode does not distinguish streamable-http from sse — both become
253
+ * type="remote". The transport-specific behavior is negotiated by the
254
+ * remote endpoint at connect time.
255
+ *
256
+ * lifecycle (eager/lazy) has no direct opencode equivalent; we set
257
+ * `enabled: true` for both ("eager" matches; "lazy" is approximated since
258
+ * opencode lazily fetches tools anyway).
259
+ */
260
+ function buildMcpConfig(servers) {
261
+ // Null-prototype object: ADL only constrains MCP names to non-empty strings,
262
+ // so names like "constructor", "toString", or "__proto__" must work as plain
263
+ // map keys without colliding with Object.prototype or mutating the prototype
264
+ // chain. Codex pass 6 of slice 2.5.
265
+ const out = Object.create(null);
266
+ const sanitizedNames = new Map(); // sanitized → raw
267
+ for (const s of servers) {
268
+ rejectUnsafeMcpNameChars(s.name);
269
+ if (Object.hasOwn(out, s.name)) {
270
+ throw new Error(`runtime-opencode: duplicate MCP server name "${s.name}" in spec.mcpServers[].`);
271
+ }
272
+ const sanitized = sanitizeMcpName(s.name);
273
+ const existing = sanitizedNames.get(sanitized);
274
+ if (existing !== undefined && existing !== s.name) {
275
+ throw new Error(`runtime-opencode: MCP server names "${existing}" and "${s.name}" both ` +
276
+ `sanitize to "${sanitized}". opencode generates tool ids under the sanitized ` +
277
+ `name, so the two servers' tools would collide in the same namespace. ` +
278
+ `Rename one in spec.mcpServers[].`);
279
+ }
280
+ sanitizedNames.set(sanitized, s.name);
281
+ rejectCollidingMcpName(s.name);
282
+ if (s.transport === "stdio") {
283
+ if (!s.command) {
284
+ throw new Error(`runtime-opencode: MCP server "${s.name}" uses transport "stdio" but has no command field.`);
285
+ }
286
+ const local = {
287
+ type: "local",
288
+ command: [s.command, ...(s.args ?? [])],
289
+ };
290
+ if (s.env && Object.keys(s.env).length > 0)
291
+ local.environment = s.env;
292
+ out[s.name] = local;
293
+ }
294
+ else if (s.transport === "streamable-http" || s.transport === "sse") {
295
+ if (!s.url) {
296
+ throw new Error(`runtime-opencode: MCP server "${s.name}" uses transport "${s.transport}" but has no url field.`);
297
+ }
298
+ const remote = {
299
+ type: "remote",
300
+ url: s.url,
301
+ };
302
+ if (s.headers && Object.keys(s.headers).length > 0)
303
+ remote.headers = s.headers;
304
+ out[s.name] = remote;
305
+ }
306
+ else {
307
+ throw new Error(`runtime-opencode: MCP server "${s.name}" has unknown transport "${s.transport}". ` +
308
+ `Expected one of: stdio, streamable-http, sse.`);
309
+ }
310
+ }
311
+ return out;
312
+ }
313
+ /**
314
+ * Reject specs that declare custom Pi-extension tools when targeting
315
+ * opencode. The check is: any spec.tools[] entry that has `entrypoint`
316
+ * set and `builtin` unset is a Pi-extension tool. opencode cannot load
317
+ * those, so we fail with a clear error that names every offending tool.
318
+ *
319
+ * spec.extensions[] is intentionally not validated here — slice 2.3
320
+ * will design extension handling (likely also a reject for opencode,
321
+ * since extension JS modules are Pi-specific). For now we silently
322
+ * pass extensions through; the prompt construction ignores them.
323
+ */
324
+ function rejectUnsupportedTools(spec) {
325
+ const offenders = [];
326
+ for (const t of spec.tools ?? []) {
327
+ if (isCustomPiExtensionTool(t)) {
328
+ offenders.push(t.name);
329
+ }
330
+ }
331
+ if (offenders.length > 0) {
332
+ throw new Error(`runtime-opencode: spec.tools[] contains custom Pi-extension tools ` +
333
+ `that cannot run on opencode: ${offenders.join(", ")}. ` +
334
+ `Only Pi built-in tools (bash, read, edit, write) are supported on ` +
335
+ `the opencode adapter today. See docs/architecture/harness-matrix.md ` +
336
+ `for the supported-feature table; custom Pi extensions are documented ` +
337
+ `as ❌ for opencode.`);
338
+ }
339
+ }
340
+ function isCustomPiExtensionTool(t) {
341
+ // A Pi built-in declared in spec.tools[] has builtin=true and no
342
+ // entrypoint. A custom Pi-extension tool has entrypoint set and
343
+ // builtin not set. Anything else (e.g. entrypoint set + builtin
344
+ // true) shouldn't happen given the compiler, but we treat as not-
345
+ // custom to err on the permissive side rather than reject a
346
+ // valid-looking spec.
347
+ return Boolean(t.entrypoint) && !t.builtin;
348
+ }
349
+ /**
350
+ * Build the prompt string opencode uses as the agent's system prompt.
351
+ *
352
+ * Composition (in order):
353
+ * 1. HONESTY_PREAMBLE — same anti-hallucination preamble as Pi
354
+ * adapter. Even though opencode is also susceptible to
355
+ * <invoke>/<function_calls> XML in message bodies, having the
356
+ * preamble at session start lowers the rate. Slice 2.4 will add
357
+ * the runtime XML detector to catch what slips through.
358
+ * 2. Persona role and instructions (when present), formatted to
359
+ * match Pi adapter's buildSystemPrompt() so spec authors don't
360
+ * have to mentally branch on which adapter they're targeting.
361
+ *
362
+ * Skill bodies will be appended here in slice 2.3 once we add fs
363
+ * reads to the mapping pipeline. For now this slice is pure.
364
+ */
365
+ function buildPromptString(spec, skillBodies) {
366
+ const parts = [HONESTY_PREAMBLE];
367
+ if (spec.persona?.role)
368
+ parts.push(`# Role\n${spec.persona.role}`);
369
+ if (spec.persona?.instructions)
370
+ parts.push(`# Instructions\n${spec.persona.instructions}`);
371
+ // Skill bodies (when present). Wrapped with the same "skill may describe
372
+ // tools you lack" preamble Pi adapter uses via wrapSkillBody(). This is
373
+ // the v0.1.7 "active by default" pattern: opencode doesn't have a skills
374
+ // concept, so we inline the bodies so the model unconditionally sees them.
375
+ for (const s of skillBodies) {
376
+ if (!s.body.trim())
377
+ continue;
378
+ parts.push(wrapSkillBody(s.name, s.body.trim()));
379
+ }
380
+ return parts.join("\n\n");
381
+ }
382
+ /**
383
+ * All opencode permission keys to set in the deny baseline.
384
+ *
385
+ * This list combines:
386
+ * (a) Keys typed in AgentConfig.permission in @opencode-ai/sdk:
387
+ * edit, bash, webfetch, doom_loop, external_directory
388
+ * (b) Keys documented in opencode.ai/docs config reference that the SDK
389
+ * types do not yet expose but the runtime accepts via AgentConfig's
390
+ * index signature `[key: string]: unknown`:
391
+ * read, glob, grep, list, task, skill, lsp, question, websearch
392
+ * (c) A wildcard "*": "deny" baseline as a belt-and-suspenders catch-all.
393
+ * Codex pass 11 raised that this was a no-op; codex pass 15 said the
394
+ * opencode runtime does support it per the docs config reference.
395
+ * We include it because if it becomes effective in a future opencode
396
+ * release, having it present is exactly what we want; if it's still
397
+ * a no-op today, the explicit key denials above cover the known tools.
398
+ *
399
+ * When the opencode SDK types are updated to expose more keys, remove those
400
+ * keys from the undocumented-but-runtime-supported list and keep them here.
401
+ */
402
+ /**
403
+ * opencode ships these named agents as natives. They are present in
404
+ * cfg.agent by default (see AgentConfig types in the SDK). If we don't
405
+ * explicitly disable them, a spec that grants `task: allow` (to invoke its
406
+ * own declared subagents) can also delegate to these natives — which carry
407
+ * their own tool defaults, bypassing the ADL allowlist contract.
408
+ * Codex pass 4 of slice 2.5 caught this bypass.
409
+ *
410
+ * We disable all four unconditionally. Specs that want to use them can
411
+ * re-export them via spec.subagents[] with explicit grants.
412
+ */
413
+ const NATIVE_OPENCODE_AGENTS = [
414
+ "plan",
415
+ "build",
416
+ "general",
417
+ "explore",
418
+ // Experimental native agent enabled by OPENCODE_EXPERIMENTAL_SCOUT. Listed
419
+ // here defensively so the allowlist contract holds even when the operator
420
+ // has scout enabled at the opencode level. Codex pass 6 of slice 2.5.
421
+ "scout",
422
+ ];
423
+ const OPENCODE_PERMISSION_KEYS_DENY_BASELINE = [
424
+ // Typed in AgentConfig.permission (definite SDK support)
425
+ "edit",
426
+ "bash",
427
+ "webfetch",
428
+ "doom_loop",
429
+ "external_directory",
430
+ // Documented in opencode.ai/docs (runtime support, not yet in SDK types)
431
+ "read",
432
+ "glob",
433
+ "grep",
434
+ "list",
435
+ "task",
436
+ "skill",
437
+ "lsp",
438
+ "question",
439
+ "websearch",
440
+ // Additional keys present in the bundled SDK v2 types (codex pass 26).
441
+ "todowrite",
442
+ "repo_clone",
443
+ "repo_overview",
444
+ // Wildcard catch-all
445
+ "*",
446
+ ];
447
+ /**
448
+ * Build the permissions map for opencode from spec.tools[].
449
+ *
450
+ * Implements ADL's allowlist semantic: start with explicit "deny" for
451
+ * every known opencode tool, then overlay "allow" for the ones the spec
452
+ * declares. This ensures that a `tools: []` spec actually runs with no
453
+ * tools, matching Pi adapter behavior.
454
+ *
455
+ * Always returns a non-empty object (at minimum all-deny). We omit the
456
+ * `undefined` return to make the caller simpler and the intent explicit.
457
+ */
458
+ function buildPermissions(tools, context = { hasSubagents: false, mcpServerNames: [] }) {
459
+ // Start with the wildcard catch-all FIRST, then enumerate specific denies.
460
+ // opencode uses last-match-wins semantics for permission rules, so if "*":
461
+ // "deny" is serialized AFTER specific allows, it overrides them and renders
462
+ // all explicitly granted tools unusable.
463
+ const grants = { "*": "deny" };
464
+ for (const key of OPENCODE_PERMISSION_KEYS_DENY_BASELINE) {
465
+ grants[key] = "deny";
466
+ }
467
+ // Overlay "allow" for Pi built-ins the spec explicitly declared.
468
+ for (const t of tools) {
469
+ if (!t.builtin)
470
+ continue;
471
+ if (!(t.name in PI_TO_OPENCODE_PERMISSION_KEY)) {
472
+ throw new Error(`runtime-opencode: Pi built-in tool ${JSON.stringify(t.name)} is not in ` +
473
+ `PI_TO_OPENCODE_PERMISSION_KEY. Add an entry.`);
474
+ }
475
+ if (t.config && Object.keys(t.config).length > 0) {
476
+ // As of v0.1.11 the canonical rejection for built-in-with-config lives
477
+ // in the Go compiler (cli/internal/adl/compiler.go) so `agentctl
478
+ // validate`/`compile` catches the bug before any adapter starts. We
479
+ // keep this runtime check as defense-in-depth: if a future schema
480
+ // change or a path that bypasses the compiler injects a built-in
481
+ // with config, the opencode adapter still refuses to start with a
482
+ // clear error. The Pi adapter relies entirely on the compile-time
483
+ // rejection (no equivalent runtime check today).
484
+ throw new Error(`runtime-opencode: spec.tools declares built-in "${t.name}" with config ` +
485
+ `(${JSON.stringify(Object.keys(t.config))}). This should have been rejected at ` +
486
+ `compile time by the Go CLI; reaching this code path means either an outdated ` +
487
+ `CLI binary or a hand-crafted CompiledSpec. For bash command allowlisting, use ` +
488
+ `the @gotgenes/pi-permission-system extension via spec.extensions[].source: ` +
489
+ `npm:@gotgenes/pi-permission-system.`);
490
+ }
491
+ const opencodeKey = PI_TO_OPENCODE_PERMISSION_KEY[t.name];
492
+ grants[opencodeKey] = "allow";
493
+ }
494
+ // Slice 2.5 codex pass 1: when the spec declares subagents, the primary
495
+ // agent needs the `task` permission to invoke them via opencode's task
496
+ // tool. Without this, opencode connects all child agents but the parent
497
+ // is denied access to delegate. Declaring subagents IS the implicit grant.
498
+ if (context.hasSubagents) {
499
+ grants["task"] = "allow";
500
+ }
501
+ // Slice 2.5: each MCP server's tools appear in opencode under permission
502
+ // keys of the form `<server-prefix>_<tool>`. Codex passes 4 and 7 of this
503
+ // slice gave contradictory accounts of whether opencode uses the raw name
504
+ // (e.g. `my-mcp_*`) or a sanitized form (e.g. `my_mcp_*`). Rather than guess
505
+ // and risk leaving the allow grant ineffective for one interpretation,
506
+ // emit BOTH patterns. For alphanumeric-only names the two collapse to the
507
+ // same key; for names with punctuation, whichever form opencode actually
508
+ // generates is covered. A non-matching pattern is a harmless no-op.
509
+ for (const serverName of context.mcpServerNames) {
510
+ grants[`${serverName}_*`] = "allow";
511
+ const sanitized = sanitizeMcpName(serverName);
512
+ if (sanitized !== serverName) {
513
+ grants[`${sanitized}_*`] = "allow";
514
+ }
515
+ }
516
+ return grants;
517
+ }