@gr8ful/spf 0.15.0 → 0.16.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.
- package/README.md +15 -5
- package/assets/skill/references/config.md +9 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +109 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +27 -27
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +86 -4
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +126 -12
- package/dist/core/data_types.js +101 -4
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/index.html +2 -2
- package/web/assets/index-CRujNW-1.js +0 -11
- 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
|
+
}
|
package/dist/core/agents.d.ts
CHANGED
|
@@ -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;
|