@gr8ful/spf 0.15.0 → 0.17.0

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.
Files changed (64) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/references/config.md +9 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +109 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +27 -27
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +86 -4
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +126 -12
  34. package/dist/core/data_types.js +101 -4
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/paths.d.ts +41 -4
  39. package/dist/core/paths.js +32 -3
  40. package/dist/core/quality.d.ts +7 -7
  41. package/dist/core/quality.js +16 -10
  42. package/dist/core/runner.d.ts +9 -3
  43. package/dist/core/runner.js +39 -27
  44. package/dist/core/session.d.ts +2 -2
  45. package/dist/core/session.js +39 -18
  46. package/dist/core/sqlite.d.ts +14 -7
  47. package/dist/core/sqlite.js +14 -7
  48. package/dist/core/trace_db.d.ts +118 -0
  49. package/dist/core/trace_db.js +278 -0
  50. package/dist/core/tracer.d.ts +64 -34
  51. package/dist/core/tracer.js +141 -69
  52. package/dist/core/watch.d.ts +4 -4
  53. package/dist/core/watch.js +2 -2
  54. package/dist/ui/server/app.js +10 -10
  55. package/dist/ui/server/db.d.ts +89 -21
  56. package/dist/ui/server/db.js +235 -99
  57. package/dist/ui/server/serve.d.ts +5 -1
  58. package/dist/ui/server/serve.js +4 -5
  59. package/package.json +1 -1
  60. package/web/assets/index-CQ3k1Y1-.css +1 -0
  61. package/web/assets/index-CU8tom6S.js +21 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CRujNW-1.js +0 -11
  64. package/web/assets/index-Cto6nuQL.css +0 -1
@@ -0,0 +1,590 @@
1
+ /**
2
+ * OpenCode coding agent interface — a third backend alongside `agent_flue.ts`
3
+ * (`coding_agent: flue`) and `agent_cc.ts` (`coding_agent: claude_code`), for
4
+ * running agents on the `opencode` CLI. Subprocess-based, for the same
5
+ * reason `agent_cc.ts` is: shelling out costs SPF zero new dependencies, and
6
+ * anyone using this backend needs the `opencode` CLI installed anyway.
7
+ *
8
+ * The `opencode` command is resolved from `PATH` by default. To route it
9
+ * through a wrapper, proxy, or launcher, set `SPF_OPENCODE_CMD` — same
10
+ * mechanism, same rationale, and same `{model}` token substitution as
11
+ * `agent_cc.ts`'s `SPF_CLAUDE_CMD` (see that module's doc comment for the
12
+ * `ollama launch`-style motivating example). Unlike `SPF_CLAUDE_CMD`, no
13
+ * `ollama launch`-shaped wrapper is special-cased here: that fix was for a
14
+ * specific cobra flag-parsing quirk in `claude`'s own launcher story, and
15
+ * there is no equivalent evidence for `opencode` — inventing one would be
16
+ * guessing at a bug that may not exist.
17
+ *
18
+ * This module's understanding of the real `opencode` CLI started from a docs
19
+ * research spike, then was checked against real `opencode run --format json`
20
+ * invocations (via `npx opencode-ai@1.18.26`, a free `opencode/*` model) —
21
+ * a plain text reply, a bash tool call, a slow bash tool call (to look for
22
+ * multi-line status transitions), a `--session <id>` resume, and an invalid
23
+ * `--model` string. Every fact below is tagged:
24
+ *
25
+ * [OFFICIAL] — stated in opencode's own published docs.
26
+ * [VERIFIED] — confirmed against a real `opencode run --format json`
27
+ * invocation, per the spike above. One real run is
28
+ * corroboration, not a version-pinned contract — still
29
+ * parsed defensively.
30
+ * [SOURCE] — inferred from opencode's repo/issue tracker, NOT written
31
+ * down anywhere in prose, and NOT independently exercised by
32
+ * the spike above (e.g. a multi-line tool_use transition
33
+ * never actually appeared in any run tried). High confidence
34
+ * on field NAMES, but not a contractual guarantee — parsed
35
+ * defensively throughout this module for exactly that
36
+ * reason.
37
+ *
38
+ * COMMAND SHAPE [OFFICIAL]: `opencode run [message..]` — the prompt is a
39
+ * POSITIONAL argument, not a flag (contrast `claude -p <prompt>`, also
40
+ * positional-ish but behind `-p`). Confirmed flags this module uses:
41
+ * `--model`/`-m` (`provider/model` string, free-form — same vocabulary
42
+ * shape as Flue's own `provider/model-id`, e.g. `anthropic/claude-sonnet-4-
43
+ * 20250514`, `ollama/qwen3-coder:30b`), `--format json` (structured NDJSON
44
+ * output — opencode's analog of `claude`'s `--output-format stream-json`),
45
+ * `--dir` (working directory), `--session <id>` (resume a specific
46
+ * session), `--auto` (auto-approve permissions not explicitly denied —
47
+ * opencode's rough analog of `claude`'s `--dangerously-skip-permissions`),
48
+ * `--variant` (provider-specific reasoning effort — opencode's analog of
49
+ * `claude`'s `--effort`).
50
+ *
51
+ * NO SYSTEM-PROMPT FLAG [OFFICIAL]: opencode has nothing like `claude`'s
52
+ * `--system-prompt`. `request.system_prompt` is prepended to the message
53
+ * text instead (see `run()` below) — a REAL LIMITATION, not a workaround
54
+ * that fully replicates a separate channel: from opencode's own point of
55
+ * view, the system prompt is just the first paragraph of the user's
56
+ * message, not a distinct role in the transcript it sees.
57
+ *
58
+ * NO PER-CALL TOOL-ALLOWLIST FLAG [OFFICIAL]: tool restriction is
59
+ * CONFIG-ONLY, via a `permission` map in an `opencode.json` file (values
60
+ * `"allow"`/`"ask"`/`"deny"` per tool name) pointed at by the `OPENCODE_CONFIG`
61
+ * env var — see `writeTempPermissionConfig()` below. `--auto` only
62
+ * suppresses `"ask"`; it never overrides an explicit `"deny"`.
63
+ *
64
+ * KNOWN LIMITATION — CONFIG PRECEDENCE: per opencode's own config-merge
65
+ * docs, a target repo's OWN project-level `opencode.json` (if one exists)
66
+ * merges at HIGHER precedence than the file `OPENCODE_CONFIG` points at.
67
+ * That means a repo carrying its own `opencode.json` can silently override
68
+ * (widen or narrow) the restriction this module writes — a real, documented
69
+ * gap in this backend's tool-restriction guarantee, not a bug this module
70
+ * can paper over from the outside.
71
+ *
72
+ * `write`/`apply_patch` ARE GATED THROUGH `edit` [OFFICIAL]: opencode's own
73
+ * docs say these two are not independent permission keys — both ride the
74
+ * `edit` key. This module never writes `write`/`apply_patch` keys of their
75
+ * own for exactly that reason.
76
+ *
77
+ * NDJSON EVENT SHAPE [VERIFIED]: one JSON object per line,
78
+ * `{ type, timestamp, sessionID, part, ... }`. Types this module reacts to:
79
+ * `step_start` (carries the REAL `sessionID` — captured from the first one
80
+ * seen and returned as `AgentResult.session_id`), `tool_use` (carries
81
+ * `part.tool`/`part.callID`/`part.state.status`/`part.state.input`/
82
+ * `part.state.output`/`part.state.time.{start,end}` — every observed call,
83
+ * even a deliberately slow one, arrived as exactly ONE already-terminal
84
+ * line, `status: "completed"`, never a separate pending/running line first),
85
+ * `text` (carries the response text at `part.text`), `step_finish` (carries
86
+ * `part.reason`: `"stop"` = done, `"tool-calls"` = more coming; and
87
+ * `part.tokens.{input,output,reasoning,cache.{read,write}}` +
88
+ * `part.cost` as a plain number — ALL VERIFIED field names/shapes), `error`
89
+ * (carries `error.name`/`error.data.message` — verified via an invalid
90
+ * `--model` string, which this build surfaced as `error.name:
91
+ * "UnknownError"` with exit code 1, NOT the exit-0-on-error upstream bug
92
+ * described below; that bug may be real for other error classes/versions,
93
+ * so the defense against it stays).
94
+ *
95
+ * NDJSON EVENT SHAPE, UNVERIFIED PORTION [SOURCE]: whether `tool_use` can
96
+ * ever arrive as MULTIPLE lines for the same call (a pending/running status
97
+ * before the terminal one) was not observed in the spike above — every call
98
+ * tried was a fast bash command that appeared already-`completed` on its
99
+ * first (and only) line. `OcToolCallTracker` still defensively folds a
100
+ * multi-line sequence if one occurs (see its own doc comment), but that path
101
+ * is unexercised, not confirmed absent.
102
+ *
103
+ * SESSION IDS ARE NOT CLIENT-CHOOSABLE [OFFICIAL + VERIFIED]: opencode's
104
+ * server assigns a ULID-based id (`ses_<26 chars>`, exact shape confirmed —
105
+ * e.g. `ses_f9bf9448cffe9LnZCxoR5m2uyf`) on session creation. The spike
106
+ * above also confirmed the RESUME half: capturing a `sessionID` from one
107
+ * call and passing it back via `--session <id>` on a second, unrelated
108
+ * `run()` genuinely continued the first call's conversation (the model
109
+ * correctly recalled content from the first turn). So a first-contact call
110
+ * passes NO `--session`/`--continue` at all, and the real id is captured
111
+ * from the first `step_start` event. See
112
+ * `pendingSessionLabel()` for the placeholder this module hands back before
113
+ * that capture happens, and `agents.ts`'s `agentSessionId()`/`send()` for how
114
+ * the placeholder gets replaced with the real id for the REST of a phase's
115
+ * retries — this is the "session-id lifecycle" change described in that
116
+ * module.
117
+ *
118
+ * DEVIATION FROM A LITERAL "only pass --session when request.resume" RULE:
119
+ * within one phase, `agents.ts`'s `send()` re-assigns its local `sessionId`
120
+ * to whatever THIS module's last call returned, but `AgentRequest.resume`
121
+ * itself is computed once per phase and does NOT flip to `true` for a
122
+ * same-phase JSON-repair retry or gate-correction (`agents.ts` only ever
123
+ * threads `!isNewSession` through, unchanged, on every `send()` in a phase).
124
+ * Passed literally, that would mean: brand-new phase, first call succeeds
125
+ * and captures a real `ses_...` id — the very next correction in that same
126
+ * phase would see `request.resume === false` and `request.session_id` ===
127
+ * a REAL id, and a literal "only pass --session when resume" rule would
128
+ * drop `--session` entirely, causing opencode to silently open a SECOND,
129
+ * unrelated session and lose the conversation the correction is supposed to
130
+ * continue. `run()` instead keys off whether `request.session_id` is this
131
+ * module's OWN placeholder shape (see `isPendingSessionLabel()`): a
132
+ * placeholder means genuinely first contact (no `--session`, let opencode
133
+ * mint one); anything else — a same-phase corrected id OR a real id carried
134
+ * over from `agent_map.json` on `resume: true` — gets `--session <id>`.
135
+ * This is a deliberate judgment call flagged for review: it stays
136
+ * functionally equivalent to "pass --session whenever there's a real id to
137
+ * resume," which is what the source instructions' own mechanism (the
138
+ * mid-phase `sessionId` correction) requires to actually work.
139
+ *
140
+ * KNOWN UPSTREAM BUGS [SOURCE, filed against opencode's own repo]: exit code
141
+ * can be 0 even on a session error or an invalid-model error — this module
142
+ * never trusts exit code 0 alone; it also requires either a `step_finish`
143
+ * with `reason: "stop"` or an explicit `error` event, and treats "the
144
+ * stream ended with neither" as a hard failure too (same spirit as
145
+ * `agent_cc.ts`'s "no result message" check). Some invocations reportedly
146
+ * hang indefinitely on upstream API errors with no exit code ever — this
147
+ * module does NOT attempt a watchdog/timeout for that (out of scope, the
148
+ * same choice `agent_cc.ts` makes: timeouts are the caller's job); it only
149
+ * guarantees that a missing final event, once the process DOES exit, throws
150
+ * a clear error instead of something worse.
151
+ *
152
+ * STDIN [OFFICIAL]: opencode reads stdin when it is not a TTY, merging it
153
+ * with the positional message. `child.stdin.end()` runs immediately after
154
+ * spawn — not for `agent_cc.ts`'s reason (a ~3s "waiting to see if anything
155
+ * is piped" stall), but to avoid ANY unintended stdin-content merge into the
156
+ * prompt, since the full prompt (system + user) already travels as the
157
+ * positional `message` argument.
158
+ *
159
+ * AUTH [OFFICIAL]: `~/.local/share/opencode/auth.json` is the credential
160
+ * store; `opencode auth list` is the documented non-interactive
161
+ * is-it-configured check. This module does not drive `opencode auth login`
162
+ * — see `cli/commands/doctor.ts`'s opencode checks, which treat auth the
163
+ * same way `claude login` is treated for `claude_code`: already the
164
+ * operator's job, informational only.
165
+ *
166
+ * KNOWN RISK — SINGLE-ARGV PROMPT SIZE: `system_prompt` + `prompt` travel as
167
+ * ONE positional argv element (see `run()`'s `message`), unlike
168
+ * `agent_cc.ts`'s two separate flags plus a separate `--json-schema` blob.
169
+ * On Linux, `execve` enforces a ~128KB ceiling PER ARGUMENT
170
+ * (`MAX_ARG_STRLEN`), independent of the much larger total `ARG_MAX` — a
171
+ * large combined system+user prompt (e.g. a big `previous_envelope` JSON
172
+ * blob folded into the user prompt, see `agents.ts`) could approach that
173
+ * ceiling and fail with a bare `E2BIG` via `child.on("error")`, for a
174
+ * prompt pair that would run fine under `claude_code`. STDIN is
175
+ * deliberately closed above, foreclosing the natural workaround (opencode
176
+ * DOES read and merge non-TTY stdin). Not fixed here: no measurement of
177
+ * real envelope sizes against this ceiling has been done, and the fix
178
+ * (piping the prompt some other way) needs opencode-side confirmation of
179
+ * what it actually supports — disclosed as a real, unverified risk rather
180
+ * than guessed at.
181
+ */
182
+ import { spawn } from "node:child_process";
183
+ import { createInterface } from "node:readline";
184
+ import { randomUUID } from "node:crypto";
185
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
186
+ import os from "node:os";
187
+ import path from "node:path";
188
+ import { UsageBreakdown, makeAgentResult } from "./data_types.js";
189
+ import { nowIso, operatorEnv } from "./utils.js";
190
+ const RESULT_SNIPPET_CHARS = 20_000;
191
+ const ARG_VALUE_CHARS = 20_000;
192
+ const LABEL_CHARS = 80;
193
+ const PRIMARY_ARGS = ["command", "path", "file_path", "pattern", "query", "url"];
194
+ function clipText(text, limit) {
195
+ return text.length <= limit ? text : text.slice(0, limit).trimEnd() + "…";
196
+ }
197
+ function labelFor(tool, args) {
198
+ let value = "";
199
+ for (const key of PRIMARY_ARGS) {
200
+ if (typeof args[key] === "string" && args[key].trim()) {
201
+ value = args[key];
202
+ break;
203
+ }
204
+ }
205
+ if (!value) {
206
+ for (const v of Object.values(args)) {
207
+ if (typeof v === "string" && v.trim()) {
208
+ value = v;
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ value = String(value).split(/\s+/).filter(Boolean).join(" ");
214
+ return value ? `${tool}: ${clipText(value, LABEL_CHARS)}` : tool;
215
+ }
216
+ /** `state.output` is expected to be a string in the common case; defensive for anything JSON-serializable. */
217
+ function snippetOf(output) {
218
+ if (output == null)
219
+ return "";
220
+ if (typeof output === "string")
221
+ return output;
222
+ try {
223
+ return JSON.stringify(output);
224
+ }
225
+ catch {
226
+ return String(output);
227
+ }
228
+ }
229
+ /** Epoch milliseconds (the confirmed shape of `part.state.time.{start,end}` — verified against a real run, see the module doc comment) -> ISO string. Falls back to `nowIso()` for anything that doesn't look like one. */
230
+ function isoFromEpochMs(value) {
231
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
232
+ return new Date(value).toISOString();
233
+ }
234
+ return nowIso();
235
+ }
236
+ /**
237
+ * Folds `tool_use` events into the SAME record shape `agent_flue`'s
238
+ * `ToolCallTracker` and `agent_cc`'s `CcToolCallTracker` produce, so
239
+ * `agents.ts`'s `eventForwarder` needs no backend-specific branching beyond
240
+ * picking which tracker class to instantiate.
241
+ *
242
+ * Unlike CC's assistant/tool_use + user/tool_result PAIR (two distinct
243
+ * messages, linked by `tool_use_id`), opencode's NDJSON carries ONE
244
+ * `tool_use` event per call [VERIFIED for the common case: every call
245
+ * observed in the module's spike, including a deliberately slow one,
246
+ * arrived as a single already-`"completed"` line]. Whether `part.state.status`
247
+ * can ALSO transition across multiple lines for one call (e.g. `pending` ->
248
+ * `running` -> `completed`/`error`) before that terminal line remains
249
+ * [SOURCE]-only — not observed, not confirmed absent — so this folds on
250
+ * "looks terminal" rather than a hardcoded status enum, in case it does:
251
+ * `state.output` being present is treated as the authoritative terminal
252
+ * signal (an explicit `"error"`/`"completed"`/`"done"` status also counts).
253
+ */
254
+ export class OcToolCallTracker {
255
+ open = new Map();
256
+ // Ids already folded into a returned record. The "looks terminal"
257
+ // heuristic (state.output present, OR an explicit terminal status) is
258
+ // deliberately loose since the real status vocabulary is unverified — a
259
+ // "running" line that already streams partial output would otherwise be
260
+ // treated as terminal, get deleted from `open`, and a LATER "completed"
261
+ // line for the SAME id would then look like a brand-new call (no entry in
262
+ // `open`) and fold into a second, duplicate tool_call record. This set is
263
+ // the guard against that: once an id has been emitted, every further line
264
+ // for it is dropped, not re-opened.
265
+ closed = new Set();
266
+ observe(message) {
267
+ if (message?.type !== "tool_use")
268
+ return null;
269
+ const part = message.part ?? {};
270
+ const state = part.state ?? {};
271
+ const id = part.id ?? part.callID ?? part.toolCallId ?? `${part.tool ?? "tool"}-${message.timestamp ?? nowIso()}`;
272
+ const tool = part.tool ?? "tool";
273
+ if (this.closed.has(id))
274
+ return null;
275
+ const existing = this.open.get(id);
276
+ if (!existing) {
277
+ this.open.set(id, {
278
+ tool,
279
+ args: state.input ?? {},
280
+ started_at: state.time?.start !== undefined ? isoFromEpochMs(state.time.start) : message.timestamp || nowIso(),
281
+ });
282
+ }
283
+ else if (state.input && Object.keys(state.input).length > 0) {
284
+ // A later line for the same call may be the first to carry the full
285
+ // input (e.g. a "running" transition after a bare "pending") — keep
286
+ // the freshest.
287
+ existing.args = state.input;
288
+ }
289
+ const status = state.status;
290
+ const isTerminal = state.output !== undefined || status === "completed" || status === "error" || status === "done";
291
+ if (!isTerminal)
292
+ return null;
293
+ const opened = this.open.get(id);
294
+ this.open.delete(id);
295
+ this.closed.add(id);
296
+ const finalArgs = opened?.args ?? state.input ?? {};
297
+ const record = {
298
+ tool,
299
+ tool_call_id: id,
300
+ args: Object.fromEntries(Object.entries(finalArgs).map(([key, value]) => [key, typeof value === "string" ? clipText(value, ARG_VALUE_CHARS) : value])),
301
+ ok: status !== "error",
302
+ label: labelFor(tool, finalArgs),
303
+ };
304
+ const snippet = snippetOf(state.output);
305
+ if (snippet)
306
+ record.result_snippet = clipText(snippet, RESULT_SNIPPET_CHARS);
307
+ record.ended_at = state.time?.end !== undefined ? isoFromEpochMs(state.time.end) : message.timestamp || nowIso();
308
+ if (opened?.started_at) {
309
+ record.started_at = opened.started_at;
310
+ const startMs = Date.parse(opened.started_at);
311
+ const endMs = Date.parse(record.ended_at);
312
+ if (!Number.isNaN(startMs) && !Number.isNaN(endMs))
313
+ record.duration_ms = Math.max(0, endMs - startMs);
314
+ }
315
+ return record;
316
+ }
317
+ }
318
+ // ── tool-name resolution ─────────────────────────────────────────────────────
319
+ // SPF's canonical lowercase vocabulary -> opencode's `permission` config
320
+ // keys. SPF's canonical vocabulary is whatever agent_cc.ts's own
321
+ // TOOL_NAME_MAP recognizes (read/write/edit/bash/grep/glob/webfetch) — this
322
+ // module mirrors that set, not a narrower one, so a roster agent using
323
+ // "webfetch" (e.g. the packaged roster's scout/refiner) validates the same
324
+ // way under every backend. "write" and "edit" both resolve to opencode's own
325
+ // "edit" key — per opencode's docs, "write"/"apply_patch" are gated THROUGH
326
+ // "edit", not independent keys (see the module doc comment). "ls" has no
327
+ // opencode built-in either (same as agent_cc.ts's own "ls" note) — dropped,
328
+ // not unknown, so a roster entry naming it fails nothing at validate time.
329
+ const TOOL_NAME_MAP = {
330
+ read: "read",
331
+ write: "edit",
332
+ edit: "edit",
333
+ bash: "bash",
334
+ grep: "grep",
335
+ glob: "glob",
336
+ webfetch: "webfetch",
337
+ };
338
+ const TOOL_ALIASES = { find: "glob" };
339
+ const DROPPED_TOOLS = new Set(["ls"]);
340
+ export function isKnownToolName(name) {
341
+ return DROPPED_TOOLS.has(name) || (TOOL_ALIASES[name] ?? name) in TOOL_NAME_MAP;
342
+ }
343
+ // Every permission key opencode itself recognizes [OFFICIAL] that this
344
+ // module can decide either way — "write"/"apply_patch" deliberately absent,
345
+ // since they ride "edit" (see above). None of "lsp"/"todowrite"/"websearch"/
346
+ // "question" has an spf-level tool name that maps to it, so a `tools:` list
347
+ // that restricts anything always denies these four — there is no spf
348
+ // vocabulary to request them with.
349
+ const OPENCODE_PERMISSION_KEYS = ["bash", "edit", "read", "grep", "glob", "lsp", "todowrite", "webfetch", "websearch", "question"];
350
+ /** Builds the `permission` map for a temporary `opencode.json` — see `writeTempPermissionConfig`. */
351
+ function permissionMapFor(toolNames) {
352
+ const allowed = new Set(toolNames
353
+ .filter((n) => !DROPPED_TOOLS.has(n))
354
+ .map((n) => TOOL_NAME_MAP[TOOL_ALIASES[n] ?? n])
355
+ .filter((v) => Boolean(v)));
356
+ const permission = {};
357
+ for (const key of OPENCODE_PERMISSION_KEYS)
358
+ permission[key] = allowed.has(key) ? "allow" : "deny";
359
+ return permission;
360
+ }
361
+ /**
362
+ * `request.tools` is `null`/`undefined` -> no config file at all, `--auto`
363
+ * alone (every tool usable, mirroring CC's `"default"`). An array (including
364
+ * `[]`) -> a real temp `opencode.json` restricting to exactly what was
365
+ * asked for. Caller is responsible for `rmSync`-ing `dir` when done — see
366
+ * `run()`'s `finally`.
367
+ */
368
+ function writeTempPermissionConfig(toolNames) {
369
+ const dir = mkdtempSync(path.join(os.tmpdir(), "spf-opencode-"));
370
+ const configPath = path.join(dir, "opencode.json");
371
+ writeFileSync(configPath, JSON.stringify({ permission: permissionMapFor(toolNames) }, null, 2));
372
+ return { dir, configPath };
373
+ }
374
+ // SPF's off|minimal|low|medium|high|xhigh|max -> opencode's --variant.
375
+ // opencode's docs confirm high/max/minimal as EXAMPLES, not an exhaustive
376
+ // enum [SOURCE for the full mapping below] — best-effort, documented as
377
+ // such rather than treated as verified.
378
+ const VARIANT_MAP = {
379
+ off: "minimal",
380
+ minimal: "minimal",
381
+ low: "low",
382
+ medium: "medium",
383
+ high: "high",
384
+ xhigh: "max",
385
+ max: "max",
386
+ };
387
+ /**
388
+ * Resolve `SPF_OPENCODE_CMD` for THIS call, substituting a literal `{model}`
389
+ * token with `model` — same mechanism and rationale as `agent_cc.ts`'s
390
+ * `resolveClaudeCmdSpec`. Exported as its own pure function for the same
391
+ * reason: unit-testable without spawning a real subprocess.
392
+ */
393
+ export function resolveOpencodeCmdSpec(model) {
394
+ return (process.env.SPF_OPENCODE_CMD || "opencode").replaceAll("{model}", model);
395
+ }
396
+ // ── session ids ──────────────────────────────────────────────────────────────
397
+ const PENDING_SESSION_PREFIX = "oc-pending-";
398
+ /**
399
+ * NOT a real session id, and NOT interchangeable with `agent_cc.ts`'s
400
+ * `newSessionId()` — opencode's server assigns its own `ses_<ULID>` id on
401
+ * first contact; nothing this module mints is ever valid to pass to
402
+ * `--session`. This exists purely as a COSMETIC placeholder for
403
+ * `agents.ts`'s pre-call logging (the `agent_start` trace event, the
404
+ * console line) before the real id is captured from the first response —
405
+ * see `run()`'s session-id handling and this module's doc comment for the
406
+ * full lifecycle.
407
+ */
408
+ export function pendingSessionLabel() {
409
+ return `${PENDING_SESSION_PREFIX}${randomUUID().slice(0, 8)}`;
410
+ }
411
+ function isPendingSessionLabel(sessionId) {
412
+ return sessionId.startsWith(PENDING_SESSION_PREFIX);
413
+ }
414
+ /**
415
+ * The `--session` decision — see the module doc comment's "DEVIATION FROM A
416
+ * LITERAL only-pass-when-resume RULE" note for why this keys off the id's
417
+ * SHAPE (a placeholder means genuinely first contact) rather than
418
+ * `request.resume`. Extracted as its own pure function specifically so this
419
+ * judgment call is unit-testable without spawning a real subprocess.
420
+ */
421
+ export function sessionArgs(sessionId) {
422
+ return isPendingSessionLabel(sessionId) ? [] : ["--session", sessionId];
423
+ }
424
+ // ── process lifecycle ────────────────────────────────────────────────────────
425
+ const inFlight = new Set();
426
+ /** Kill any still-running `opencode` children — call once, at process exit. Safe if none are running. */
427
+ export async function shutdown() {
428
+ for (const child of inFlight)
429
+ child.kill("SIGTERM");
430
+ inFlight.clear();
431
+ }
432
+ class OcRunError extends Error {
433
+ }
434
+ /**
435
+ * Run one `opencode run` turn. See the module doc comment for the full
436
+ * session-id lifecycle, the system-prompt-via-prepend limitation, and the
437
+ * tool-restriction-via-temp-config mechanism.
438
+ *
439
+ * `onEvent` receives each parsed NDJSON line UNFOLDED, exactly as
440
+ * `agent_cc.run()`/`agent_flue.run()` forward their own raw events — folding
441
+ * into one record per tool call is `eventForwarder`'s job (`agents.ts`), via
442
+ * this module's `OcToolCallTracker`.
443
+ */
444
+ export async function run(request, onEvent, onSpawn, onExit) {
445
+ // No distinct system-prompt channel (see the module doc comment) — folded
446
+ // into the one positional message opencode actually sees.
447
+ const message = `${request.system_prompt}\n\n---\n\n${request.prompt}`;
448
+ const args = [
449
+ "run",
450
+ message,
451
+ "--format",
452
+ "json",
453
+ "--auto",
454
+ "--model",
455
+ request.model,
456
+ "--dir",
457
+ request.cwd,
458
+ "--variant",
459
+ VARIANT_MAP[request.thinking],
460
+ ...sessionArgs(request.session_id),
461
+ ];
462
+ const cmdSpec = resolveOpencodeCmdSpec(request.model);
463
+ const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
464
+ const [cmd, ...cmdArgs] = cmdTokens;
465
+ const fullArgs = [...cmdArgs, ...args];
466
+ const baseEnv = request.env ?? operatorEnv();
467
+ // `request.tools` null/undefined -> every tool, no config file written at
468
+ // all (see writeTempPermissionConfig's own doc comment). An array
469
+ // (including []) -> a real temp opencode.json, pointed at via
470
+ // OPENCODE_CONFIG on the CHILD's env only — never mutates process.env.
471
+ const tempConfig = request.tools != null ? writeTempPermissionConfig(request.tools) : null;
472
+ const childEnv = tempConfig ? { ...baseEnv, OPENCODE_CONFIG: tempConfig.configPath } : baseEnv;
473
+ try {
474
+ const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: childEnv });
475
+ // See the module doc comment's STDIN note — closed immediately to avoid
476
+ // any unintended merge with the positional message.
477
+ child.stdin.end();
478
+ inFlight.add(child);
479
+ const pid = child.pid ?? -1;
480
+ onSpawn?.(pid);
481
+ const lines = createInterface({ input: child.stdout });
482
+ let capturedSessionId = null;
483
+ let text = "";
484
+ let sawStop = false;
485
+ let sawError = false;
486
+ let errorDetail = "";
487
+ let lastFinishPart = null;
488
+ let stderr = "";
489
+ child.stderr.on("data", (chunk) => {
490
+ stderr += String(chunk);
491
+ });
492
+ lines.on("line", (line) => {
493
+ if (!line.trim())
494
+ return;
495
+ let msg;
496
+ try {
497
+ msg = JSON.parse(line);
498
+ }
499
+ catch {
500
+ return; // a non-JSON stray line — not fatal, skip it
501
+ }
502
+ onEvent?.(msg);
503
+ if (!capturedSessionId && typeof msg.sessionID === "string" && msg.sessionID) {
504
+ capturedSessionId = msg.sessionID;
505
+ }
506
+ if (msg.type === "text") {
507
+ // part.text [VERIFIED] — see the module doc comment. The `msg.text`
508
+ // fallback is defensive belt-and-braces, not a second observed shape.
509
+ const chunk = msg.part?.text ?? msg.text;
510
+ if (typeof chunk === "string")
511
+ text += chunk;
512
+ }
513
+ else if (msg.type === "step_finish") {
514
+ const finishPart = msg.part ?? {};
515
+ lastFinishPart = finishPart;
516
+ if (finishPart.reason === "stop")
517
+ sawStop = true;
518
+ }
519
+ else if (msg.type === "error") {
520
+ sawError = true;
521
+ errorDetail = msg.error?.data?.message || msg.error?.name || "unknown error";
522
+ }
523
+ });
524
+ const exitCode = await new Promise((resolve, reject) => {
525
+ child.on("error", reject); // spawn failure (e.g. opencode not on PATH)
526
+ child.on("close", (code) => resolve(code ?? 1));
527
+ });
528
+ inFlight.delete(child);
529
+ onExit?.(pid);
530
+ // Known upstream bug [SOURCE, not reproduced] — an invalid-model run in
531
+ // the module's spike surfaced as an `error` event with exit code 1 (the
532
+ // well-behaved case), not exit 0. The upstream reports of exit-0-on-error
533
+ // may still be real for other error classes/versions, so this module
534
+ // still never trusts exit code 0 alone; an explicit error event or a
535
+ // missing final "stop" is the real signal. See the module doc comment.
536
+ if (exitCode !== 0 && !sawStop && !sawError) {
537
+ throw new OcRunError(`opencode exited ${exitCode} before producing a result: ${stderr.slice(-2000) || "(no stderr)"}`);
538
+ }
539
+ if (sawError) {
540
+ throw new OcRunError(`opencode reported an error (${errorDetail}): ${stderr.slice(-2000) || "no detail"}`);
541
+ }
542
+ if (!sawStop) {
543
+ throw new OcRunError(`opencode produced no final result — no step_finish(reason="stop") and no error event (exit ${exitCode}): ${stderr.slice(-2000) || "(no stderr)"}`);
544
+ }
545
+ // A successful run with no captured session id would mean the
546
+ // (verified, but not version-pinned) `sessionID` field assumption in
547
+ // this module's NDJSON parsing stopped holding for whatever opencode
548
+ // build is actually installed. Fail loud here rather than letting
549
+ // `pendingSessionLabel()`'s placeholder leak into `AgentResult.session_id`
550
+ // — agents.ts would otherwise silently persist it to agent_map.json, and
551
+ // every later send() in this phase (and every later phase, via rejoin)
552
+ // would permanently lose session continuity with no diagnostic at all.
553
+ if (!capturedSessionId) {
554
+ throw new OcRunError("opencode reported success but no sessionID was seen on any NDJSON event — this module's assumption about the session-id field name may be wrong for the installed opencode version; refusing to silently continue with a placeholder session id");
555
+ }
556
+ const usage = new UsageBreakdown();
557
+ // Token/cost field shapes [VERIFIED] — see the module doc comment. Still
558
+ // parsed defensively (absent fields fall back to 0 rather than throwing)
559
+ // since a real cost/pricing provider's response wasn't exercised (the
560
+ // spike's free `opencode/*` models always returned `cost: 0`).
561
+ const finish = lastFinishPart ?? {};
562
+ const tokens = finish.tokens ?? finish.usage ?? {};
563
+ const cache = tokens.cache ?? {};
564
+ const inputTokens = tokens.input ?? tokens.input_tokens ?? 0;
565
+ const outputTokens = tokens.output ?? tokens.output_tokens ?? 0;
566
+ const cacheRead = cache.read ?? tokens.cache_read ?? 0;
567
+ const cacheWrite = cache.write ?? tokens.cache_write ?? 0;
568
+ const reasoningTokens = tokens.reasoning ?? tokens.reasoning_tokens ?? 0;
569
+ const totalTokens = inputTokens + outputTokens + cacheRead + cacheWrite;
570
+ const cost = typeof finish.cost === "number" ? finish.cost : (finish.cost?.total ?? 0);
571
+ usage.add_turn({ input: inputTokens, output: outputTokens, cacheRead, cacheWrite, reasoning: reasoningTokens, cost: { total: cost } }, totalTokens);
572
+ return makeAgentResult({
573
+ session_id: capturedSessionId ?? request.session_id,
574
+ text,
575
+ // No structured-output equivalent on this backend (see the module doc
576
+ // comment) — the caller (agents.ts) falls back to extracting JSON from
577
+ // `text`, same as agent_cc.ts's own `report: null` fallback path.
578
+ report: null,
579
+ tokens: usage.total_tokens,
580
+ cost: usage.total_cost,
581
+ usage,
582
+ context_tokens: 0,
583
+ context_window: 0,
584
+ });
585
+ }
586
+ finally {
587
+ if (tempConfig)
588
+ rmSync(tempConfig.dir, { recursive: true, force: true });
589
+ }
590
+ }
@@ -140,21 +140,21 @@ interface RunForAgents {
140
140
  */
141
141
  tiering?: TierResolution | null;
142
142
  tracer: {
143
- event: (record: ReturnType<typeof makeEventRecord>) => string;
144
- processStart: (adwId: string, kind: string, name: string, pid: number, command: string) => void;
145
- processEnd: (adwId: string, pid: number) => void;
146
- envelopeRow: (phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number) => void;
147
- gateRow: (phase: Phase, gate: string, report: GateReport, attempt: number) => void;
148
- agentSessionRow: (adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number) => void;
143
+ event: (record: ReturnType<typeof makeEventRecord>) => Promise<string>;
144
+ processStart: (adwId: string, kind: string, name: string, pid: number, command: string) => Promise<void>;
145
+ processEnd: (adwId: string, pid: number) => Promise<void>;
146
+ envelopeRow: (phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number) => Promise<void>;
147
+ gateRow: (phase: Phase, gate: string, report: GateReport, attempt: number) => Promise<void>;
148
+ agentSessionRow: (adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number) => Promise<void>;
149
149
  };
150
150
  console: {
151
- agentStarted: (name: string, model: string, sessionId: string) => void;
152
- gateResult: (name: string, report: GateReport) => void;
153
- retry: (name: string, attempt: number, limit: number, reason: string) => void;
154
- envelopeSummary: (envelope: EnvelopeBase, typeName: string) => void;
155
- agentFinished: (name: string, tokens: number, cost: number) => void;
151
+ agentStarted: (name: string, model: string, sessionId: string) => Promise<void>;
152
+ gateResult: (name: string, report: GateReport) => Promise<void>;
153
+ retry: (name: string, attempt: number, limit: number, reason: string) => Promise<void>;
154
+ envelopeSummary: (envelope: EnvelopeBase, typeName: string) => Promise<void>;
155
+ agentFinished: (name: string, tokens: number, cost: number) => Promise<void>;
156
156
  };
157
- addUsage: (tokens: number, cost: number) => void;
157
+ addUsage: (tokens: number, cost: number) => Promise<void>;
158
158
  saveAgentMap: (agent: string, entry: {
159
159
  session_id: string;
160
160
  model: string;