@gr8ful/spf 0.14.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 +51 -9
- package/assets/skill/references/config.md +12 -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 +121 -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 +91 -30
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +95 -5
- 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 +356 -15
- package/dist/core/data_types.js +180 -7
- 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/issues/github_provider.d.ts +66 -2
- package/dist/core/issues/github_provider.js +161 -2
- package/dist/core/issues/jira_provider.d.ts +50 -9
- package/dist/core/issues/jira_provider.js +62 -2
- 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/refine.js +2 -2
- 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/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
- package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
- package/web/index.html +33 -2
- package/web/logo.svg +4 -4
- package/web/assets/index-C7nF068F.css +0 -1
- package/web/assets/index-mzSArcnQ.js +0 -11
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
package/dist/cli/interview.js
CHANGED
|
@@ -62,6 +62,7 @@ export function gatherContext(repoRoot, existingEnv = new Map()) {
|
|
|
62
62
|
gitName: gitConfigValue(repoRoot, "user.name"),
|
|
63
63
|
scripts: readScripts(repoRoot),
|
|
64
64
|
claudeOnPath: binaryOnPath("claude"),
|
|
65
|
+
opencodeOnPath: binaryOnPath("opencode"),
|
|
65
66
|
existingEnv,
|
|
66
67
|
rosterNames: readRosterNames(),
|
|
67
68
|
};
|
|
@@ -90,10 +91,38 @@ export async function runInterview(asker, ctx) {
|
|
|
90
91
|
asker.heading("Coding agent");
|
|
91
92
|
const codingAgent = await asker.select("Which backend runs each agent?", [
|
|
92
93
|
{ value: "claude_code", label: "claude_code — shells out to the `claude` CLI you already use", hint: "cc" },
|
|
94
|
+
{ value: "opencode", label: "opencode — shells out to the `opencode` CLI, provider/model-id (anthropic, ollama, ...)", hint: "oc" },
|
|
93
95
|
{ value: "flue", label: "flue — in-process, provider/model-id (openai, anthropic, openrouter, ...)" },
|
|
94
96
|
], "claude_code");
|
|
95
97
|
defaults.coding_agent = codingAgent;
|
|
96
|
-
if (codingAgent === "
|
|
98
|
+
if (codingAgent === "opencode") {
|
|
99
|
+
if (!ctx.opencodeOnPath) {
|
|
100
|
+
asker.note("warning: `opencode` was not found on PATH — install it (npm install -g opencode-ai), or pick a launch command below, before running spf.");
|
|
101
|
+
}
|
|
102
|
+
const launchCommand = await asker.text("Launch command for the `opencode` CLI", { default: "opencode" });
|
|
103
|
+
if (launchCommand !== "opencode")
|
|
104
|
+
configEnv["SPF_OPENCODE_CMD"] = launchCommand;
|
|
105
|
+
// opencode's own model vocabulary is "provider/model-id" — same shape
|
|
106
|
+
// Flue speaks (see agent_opencode.ts's module doc comment) — so this
|
|
107
|
+
// asks the same way the flue branch below does, minus Flue's own
|
|
108
|
+
// resolveModel() validation (that function belongs to agent_flue.ts,
|
|
109
|
+
// not opencode).
|
|
110
|
+
const model = await asker.text('Model ("provider/model-id", opencode\'s own vocabulary)', {
|
|
111
|
+
default: "anthropic/claude-sonnet-4-6",
|
|
112
|
+
validate: (val) => (val.trim() ? null : "required"),
|
|
113
|
+
});
|
|
114
|
+
defaults.model = model;
|
|
115
|
+
asker.note("opencode manages its own authentication (`opencode auth login`, or a provider's own env var like ANTHROPIC_API_KEY) — spf does not drive that login flow. `spf doctor` checks for ~/.local/share/opencode/auth.json (informational only).");
|
|
116
|
+
// Same pinned-roster fix the claude_code/ollama/cloudflare branches below
|
|
117
|
+
// already apply: the packaged roster pins planner/reviewer/documenter to
|
|
118
|
+
// their own Flue-style provider/model-id strings, which always win over
|
|
119
|
+
// defaults.model — override all three to the model chosen above.
|
|
120
|
+
for (const name of ["planner", "reviewer", "documenter"]) {
|
|
121
|
+
agentOverrides.push({ name, model: defaults.model });
|
|
122
|
+
}
|
|
123
|
+
notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to match, since they're already valid provider/model-id strings for opencode too, but not necessarily ones you've configured auth for.");
|
|
124
|
+
}
|
|
125
|
+
else if (codingAgent === "claude_code") {
|
|
97
126
|
if (!ctx.claudeOnPath) {
|
|
98
127
|
asker.note("warning: `claude` was not found on PATH — install it (or pick a launch command below) before running spf.");
|
|
99
128
|
}
|
|
@@ -412,14 +441,22 @@ export async function runInterview(asker, ctx) {
|
|
|
412
441
|
});
|
|
413
442
|
const projectKey = await asker.text("Jira project key", { validate: (val) => (val.trim() ? null : "required") });
|
|
414
443
|
watch.jira = { base_url: baseUrl.replace(/\/+$/, ""), project_key: projectKey.toUpperCase() };
|
|
444
|
+
asker.note("Jira workflow status (the board's Status column) stays untouched by default — labels alone drive spf watch. " +
|
|
445
|
+
"Opt in with watch.jira.status_map (per-state -> Jira status name) to also sync it, then run `spf watch init` to validate.");
|
|
415
446
|
}
|
|
416
447
|
const needsGithub = issueProvider === "github" || codeHost === "github";
|
|
417
448
|
if (needsGithub) {
|
|
418
|
-
asker.note(
|
|
449
|
+
asker.note(issueProvider === "github"
|
|
450
|
+
? 'classic PAT, "repo" scope (private) or "public_repo" (public-only) — add "project" too if you plan to use watch.github.status_map (native Projects v2 board sync).'
|
|
451
|
+
: 'classic PAT, "repo" scope (private) or "public_repo" (public-only).');
|
|
419
452
|
const token = await asker.secret("GITHUB_TOKEN", { current: ctx.existingEnv.get("GITHUB_TOKEN") });
|
|
420
453
|
if (token)
|
|
421
454
|
env["GITHUB_TOKEN"] = token;
|
|
422
455
|
envExampleKeys.push("GITHUB_TOKEN");
|
|
456
|
+
if (issueProvider === "github") {
|
|
457
|
+
asker.note("GitHub Projects v2 board status (the board's Status column) stays untouched by default — labels alone drive spf watch. " +
|
|
458
|
+
"Opt in with watch.github.project_number + watch.github.status_map (per-state -> Status option name) to also sync it, then run `spf watch init` to validate.");
|
|
459
|
+
}
|
|
423
460
|
}
|
|
424
461
|
if (issueProvider === "jira") {
|
|
425
462
|
const email = await asker.text("JIRA_EMAIL", { default: ctx.gitEmail ?? "" });
|
|
@@ -497,10 +534,63 @@ export async function runInterview(asker, ctx) {
|
|
|
497
534
|
const protectedList = protectedFiles.split(",").map((s) => s.trim()).filter(Boolean);
|
|
498
535
|
if (protectedList.join(",") !== ".spf/,spf.config.yaml")
|
|
499
536
|
defaults.protected_files = protectedList;
|
|
500
|
-
|
|
537
|
+
// Local sqlite (today's default, unchanged) or a remote Cloudflare D1
|
|
538
|
+
// database (SPF #66, PR 3) — `resolveObservabilityDb`/`resolveDataPaths`
|
|
539
|
+
// (data_types.ts/paths.ts, PR 1+2) already accept either shape; this is
|
|
540
|
+
// just wiring the interview up to the option. `spf init --yes` never
|
|
541
|
+
// reaches this "advanced" gate at all, so it stays zero-prompt and
|
|
542
|
+
// defaults to local exactly as before.
|
|
543
|
+
asker.note("Data egress: choosing remote (D1) sends the complete trace — your raw request text, tool arguments and results, envelope payloads, and gate violation details, not just spans or metadata — to Cloudflare on every run. Stick with local sqlite if that data should never leave this machine.");
|
|
544
|
+
const dbBackend = await asker.select("Store trace data locally or remotely (Cloudflare D1)?", [
|
|
545
|
+
{ value: "local", label: "local sqlite (default)" },
|
|
546
|
+
{ value: "d1", label: "remote — Cloudflare D1 (sends full trace off-box, see note above)" },
|
|
547
|
+
], "local");
|
|
548
|
+
let dbValue;
|
|
549
|
+
if (dbBackend === "d1") {
|
|
550
|
+
const databaseId = await asker.text("Cloudflare D1 database_id", {
|
|
551
|
+
validate: (v) => (v.trim() ? null : "required"),
|
|
552
|
+
});
|
|
553
|
+
// `account_id_env`/`api_token_env` both default (schema-side, see
|
|
554
|
+
// data_types.ts's D1DbConfigSchema) to the SAME env vars the Cloudflare
|
|
555
|
+
// Workers AI provider branch above already reads — leaving both out
|
|
556
|
+
// here keeps the generated config terse (the object below just says
|
|
557
|
+
// `{kind:"d1", database_id}`) while still resolving to the exact same
|
|
558
|
+
// normalized descriptor `resolveObservabilityDb` would produce with
|
|
559
|
+
// them spelled out.
|
|
560
|
+
dbValue = { kind: "d1", database_id: databaseId };
|
|
561
|
+
// Reuse CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_API_TOKEN if the coding-agent
|
|
562
|
+
// section above already collected them (this run is ALSO routed
|
|
563
|
+
// through Cloudflare Workers AI) — `envExampleKeys` is the definitive
|
|
564
|
+
// record of "already asked this run", true whether or not the answer
|
|
565
|
+
// itself ended up blank (e.g. an existing .env value was kept as-is).
|
|
566
|
+
// Otherwise collect them now, same validation/env-bucket convention as
|
|
567
|
+
// that branch: both are secrets, so they go into `env` (written to
|
|
568
|
+
// `.env`), never `configEnv`.
|
|
569
|
+
if (!envExampleKeys.includes("CLOUDFLARE_ACCOUNT_ID")) {
|
|
570
|
+
const accountId = await asker.text("CLOUDFLARE_ACCOUNT_ID", {
|
|
571
|
+
validate: (v) => (v.trim() ? null : "required — find it in the Cloudflare dashboard (right sidebar)"),
|
|
572
|
+
});
|
|
573
|
+
env["CLOUDFLARE_ACCOUNT_ID"] = accountId;
|
|
574
|
+
envExampleKeys.push("CLOUDFLARE_ACCOUNT_ID");
|
|
575
|
+
}
|
|
576
|
+
if (!envExampleKeys.includes("CLOUDFLARE_API_TOKEN")) {
|
|
577
|
+
const apiToken = await asker.secret("CLOUDFLARE_API_TOKEN", { current: ctx.existingEnv.get("CLOUDFLARE_API_TOKEN") });
|
|
578
|
+
if (apiToken)
|
|
579
|
+
env["CLOUDFLARE_API_TOKEN"] = apiToken;
|
|
580
|
+
envExampleKeys.push("CLOUDFLARE_API_TOKEN");
|
|
581
|
+
}
|
|
582
|
+
// D1's own consistency model, not local WAL sqlite's — see
|
|
583
|
+
// trace_db.ts's D1TraceDb class doc comment (SPF #66) for the full
|
|
584
|
+
// spike-verified explanation this paraphrases.
|
|
585
|
+
asker.note("D1 is not local WAL sqlite: spf ui and a running chain each talk to it over independent HTTP calls, not one shared file, so (per Cloudflare's own read-replication docs) a live trace view can briefly show a slightly-stale read while a chain is actively writing — D1's own consistency model, not a bug. No phase/gate/run outcome depends on that live read.");
|
|
586
|
+
asker.note("spf doctor probes the resolved D1 endpoint (informational, never a hard failure).");
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
dbValue = await asker.text("observability.db", { default: ".spf/data/spf.db" });
|
|
590
|
+
}
|
|
501
591
|
const pollMs = await asker.text("observability.poll_ms", { default: "500" });
|
|
502
|
-
if (
|
|
503
|
-
defaults["__observability__"] = { ...(
|
|
592
|
+
if (dbValue !== ".spf/data/spf.db" || pollMs !== "500") {
|
|
593
|
+
defaults["__observability__"] = { ...(dbValue !== ".spf/data/spf.db" ? { db: dbValue } : {}), ...(pollMs !== "500" ? { poll_ms: Number(pollMs) } : {}) };
|
|
504
594
|
}
|
|
505
595
|
if (watch) {
|
|
506
596
|
const watchPollMs = await asker.text("watch.poll_ms", { default: "60000" });
|
|
@@ -0,0 +1,247 @@
|
|
|
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 type { AgentRequest, AgentResult } from "./data_types.ts";
|
|
183
|
+
/** One line of `opencode run --format json` output, loosely typed — see the module doc comment for what's actually verified vs. inferred. */
|
|
184
|
+
export type OcStreamMessage = Record<string, any>;
|
|
185
|
+
/**
|
|
186
|
+
* Folds `tool_use` events into the SAME record shape `agent_flue`'s
|
|
187
|
+
* `ToolCallTracker` and `agent_cc`'s `CcToolCallTracker` produce, so
|
|
188
|
+
* `agents.ts`'s `eventForwarder` needs no backend-specific branching beyond
|
|
189
|
+
* picking which tracker class to instantiate.
|
|
190
|
+
*
|
|
191
|
+
* Unlike CC's assistant/tool_use + user/tool_result PAIR (two distinct
|
|
192
|
+
* messages, linked by `tool_use_id`), opencode's NDJSON carries ONE
|
|
193
|
+
* `tool_use` event per call [VERIFIED for the common case: every call
|
|
194
|
+
* observed in the module's spike, including a deliberately slow one,
|
|
195
|
+
* arrived as a single already-`"completed"` line]. Whether `part.state.status`
|
|
196
|
+
* can ALSO transition across multiple lines for one call (e.g. `pending` ->
|
|
197
|
+
* `running` -> `completed`/`error`) before that terminal line remains
|
|
198
|
+
* [SOURCE]-only — not observed, not confirmed absent — so this folds on
|
|
199
|
+
* "looks terminal" rather than a hardcoded status enum, in case it does:
|
|
200
|
+
* `state.output` being present is treated as the authoritative terminal
|
|
201
|
+
* signal (an explicit `"error"`/`"completed"`/`"done"` status also counts).
|
|
202
|
+
*/
|
|
203
|
+
export declare class OcToolCallTracker {
|
|
204
|
+
private open;
|
|
205
|
+
private closed;
|
|
206
|
+
observe(message: OcStreamMessage): Record<string, any> | null;
|
|
207
|
+
}
|
|
208
|
+
export declare function isKnownToolName(name: string): boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Resolve `SPF_OPENCODE_CMD` for THIS call, substituting a literal `{model}`
|
|
211
|
+
* token with `model` — same mechanism and rationale as `agent_cc.ts`'s
|
|
212
|
+
* `resolveClaudeCmdSpec`. Exported as its own pure function for the same
|
|
213
|
+
* reason: unit-testable without spawning a real subprocess.
|
|
214
|
+
*/
|
|
215
|
+
export declare function resolveOpencodeCmdSpec(model: string): string;
|
|
216
|
+
/**
|
|
217
|
+
* NOT a real session id, and NOT interchangeable with `agent_cc.ts`'s
|
|
218
|
+
* `newSessionId()` — opencode's server assigns its own `ses_<ULID>` id on
|
|
219
|
+
* first contact; nothing this module mints is ever valid to pass to
|
|
220
|
+
* `--session`. This exists purely as a COSMETIC placeholder for
|
|
221
|
+
* `agents.ts`'s pre-call logging (the `agent_start` trace event, the
|
|
222
|
+
* console line) before the real id is captured from the first response —
|
|
223
|
+
* see `run()`'s session-id handling and this module's doc comment for the
|
|
224
|
+
* full lifecycle.
|
|
225
|
+
*/
|
|
226
|
+
export declare function pendingSessionLabel(): string;
|
|
227
|
+
/**
|
|
228
|
+
* The `--session` decision — see the module doc comment's "DEVIATION FROM A
|
|
229
|
+
* LITERAL only-pass-when-resume RULE" note for why this keys off the id's
|
|
230
|
+
* SHAPE (a placeholder means genuinely first contact) rather than
|
|
231
|
+
* `request.resume`. Extracted as its own pure function specifically so this
|
|
232
|
+
* judgment call is unit-testable without spawning a real subprocess.
|
|
233
|
+
*/
|
|
234
|
+
export declare function sessionArgs(sessionId: string): string[];
|
|
235
|
+
/** Kill any still-running `opencode` children — call once, at process exit. Safe if none are running. */
|
|
236
|
+
export declare function shutdown(): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* Run one `opencode run` turn. See the module doc comment for the full
|
|
239
|
+
* session-id lifecycle, the system-prompt-via-prepend limitation, and the
|
|
240
|
+
* tool-restriction-via-temp-config mechanism.
|
|
241
|
+
*
|
|
242
|
+
* `onEvent` receives each parsed NDJSON line UNFOLDED, exactly as
|
|
243
|
+
* `agent_cc.run()`/`agent_flue.run()` forward their own raw events — folding
|
|
244
|
+
* into one record per tool call is `eventForwarder`'s job (`agents.ts`), via
|
|
245
|
+
* this module's `OcToolCallTracker`.
|
|
246
|
+
*/
|
|
247
|
+
export declare function run(request: AgentRequest, onEvent?: (message: OcStreamMessage) => void, onSpawn?: (pid: number) => void, onExit?: (pid: number) => void): Promise<AgentResult>;
|