@gr8ful/spf 0.17.0 → 0.18.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.
@@ -110,11 +110,13 @@ the two runs' cost/tokens/gates together.
110
110
  |---|---|---|
111
111
  | `db` | path \| `{kind: sqlite, path?}` \| `{kind: d1, database_id, account_id_env?, api_token_env?}` | Where the trace db lives. A bare string (or the equivalent spelled-out `{kind: sqlite, ...}`) is a repo-relative local sqlite path, default `.spf/data/spf.db`. `{kind: d1, ...}` points at a remote Cloudflare D1 database instead — `database_id` is required; `account_id_env`/`api_token_env` default to `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN` (the same env vars the `cloudflare` model provider reads), naming the env vars to read the credentials from, never the credentials themselves. **Egress**: `kind: d1` sends the complete trace — raw request text, tool arguments/results, envelope payloads, gate violation details — to Cloudflare's D1 HTTP API on every run; `kind: sqlite` (the default) never leaves the machine. See `observability.md`'s "Two stores, one truth" and "D1 ships the complete trace off-box" sections for what changes (and what deliberately doesn't) with a D1-backed trace db — notably, the local backend's WAL live-read guarantee does not hold for D1. |
112
112
  | `poll_ms` | int | UI live-poll cadence. Default `500`. |
113
- | `otel.endpoint` | string | OTLP/HTTP collector endpoint (e.g., `https://your-host/v1/traces`). Omit to disable OTel export. |
113
+ | `otel.endpoint` | string | OTLP/HTTP collector endpoint (e.g., `https://your-host/v1/traces`). Omit to disable OTel export (traces AND metrics — both share this one switch). |
114
114
  | `otel.headers` | object | Optional HTTP headers (e.g., auth tokens). Each value is a string. |
115
- | `otel.service_name` | string | Optional service name in exported spans. Default `spf`. |
115
+ | `otel.service_name` | string | Optional service name in exported spans/metrics. Default `spf`. |
116
+ | `otel.metrics` | bool | Default `true`. Set `false` to keep trace export on while opting out of the process-scoped metrics pipeline (`spf.tokens`, `spf.cost_usd`, `spf.phase.duration`, `spf.gate.result`, `spf.agent.calls` — see `observability.md`). Has no effect on activation either way. |
117
+ | `otel.allow_env` | bool | Default `false`. When `true` AND `otel.endpoint` is ALREADY set here, the standard `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars may SUPPLEMENT this block (e.g. a CI-injected collector token) — config-declared values win on conflict, and this can never activate export on its own. |
116
118
 
117
- **No ambient env activation**: OTEL export requires explicit `observability.otel` config — the `OTEL_EXPORTER_OTLP_ENDPOINT` shell variable is never consulted. An unrelated shell env variable must not become a data-egress switch.
119
+ **No ambient env activation**: OTEL export requires explicit `observability.otel` config — the `OTEL_EXPORTER_OTLP_ENDPOINT` shell variable is never consulted UNLESS `otel.allow_env: true` and the block above is already active (see that row).
118
120
 
119
121
  **`spf init`'s interview** asks local-vs-D1 only inside the "advanced" gate (declining advanced settings, or `--yes`, leaves `observability.db` unset — local sqlite at its default path, today's behavior, unchanged). Choosing D1 asks for `database_id` (required) and, unless this same interview run already collected `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN` for the Cloudflare Workers AI provider, asks for those too — reusing them instead of asking twice when it did. The generated config omits `account_id_env`/`api_token_env` (they match the schema's own defaults above), so a D1 choice writes just `db: {kind: d1, database_id: ...}`.
120
122
 
@@ -545,6 +547,7 @@ when `.gitmodules` is present.
545
547
  | `writes` | no | What this agent may modify **in the repo**, enforced after every call. `undefined`/`null` = unrestricted (still barred from `protected_files`); `[]` = no repo writes; a list = only those paths (trailing `/` = directory prefix, `*` = one path segment, `**` = crosses segments, anything else = exact path). |
546
548
  | `env_allowlist` | no | Opt-in filter on the environment handed to this agent's subprocess/sandbox. `undefined` (default) = the full operator environment, unchanged. A list = only those keys, plus the baseline (`PATH`, `HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`) either backend keeps regardless. |
547
549
  | `sandbox` | no | Per-agent backend override — see `sandbox` below. `undefined`/`null` = inherit `sandbox.backend`; `"local"` = force local for this agent regardless of the repo-wide setting; a backend name = use that backend for this agent only. A bare name, never a nested block — everything else (`image`, `egress`, `transport`, credentials *policy*) stays repo-wide. |
550
+ | `lora_adapter` | no | Explicit override for the `spf.lora_adapter` OTel span attribute (see `observability.md`). Unset (the common case) falls back to parsing `model` — `provider/base:adapter` (the suffix after the last `:`), else `provider/adapter-name` when the id contains `-lora-` (this org's own vLLM/Switchyard served-model convention). Purely descriptive: never changes routing, dispatch, or which model actually serves the call. |
548
551
 
549
552
  Output types are deliberately absent from config: an entry defines who an
550
553
  agent *is*; the call site defines how it's *used*.
@@ -75,9 +75,123 @@ reading `parent_id`.
75
75
  `spf.config.yaml`, a lossy, allowlisted projection of phase/agent/tool spans
76
76
  (status, model, token/cost counts, gate results — never prompts, envelopes,
77
77
  tool arguments, or source code) is also pushed to an OTLP/HTTP collector,
78
- fire-and-forget. SQLite remains the source of truth regardless; see
78
+ fire-and-forget, via the real `@opentelemetry/sdk-trace-base` +
79
+ `exporter-trace-otlp-http` packages (SPF's own bespoke sha256 trace/span-id
80
+ scheme and attribute allowlist ride on top of the real SDK's encoder, not a
81
+ hand-rolled one — see `core/otel.ts`'s header for exactly what that means for
82
+ the wire bytes). SQLite remains the source of truth regardless; see
79
83
  `config.md`'s `observability.otel.*` rows for the field reference.
80
84
 
85
+ **Optional OTel metrics.** The same `observability.otel.endpoint` also gates
86
+ a second, PROCESS-scoped pipeline (`core/otel_metrics.ts`, built on
87
+ `@opentelemetry/sdk-metrics` + `exporter-metrics-otlp-http`) — set
88
+ `observability.otel.metrics: false` to keep trace export on while opting out
89
+ of metrics specifically. Five instruments: `spf.tokens` (counter; attrs
90
+ `kind` — input/output/cache_read/cache_write — `agent`, `model`),
91
+ `spf.cost_usd` (counter; `agent`, `model`), `spf.phase.duration` (histogram,
92
+ seconds; `kind`, `owner`, `status`), `spf.gate.result` (counter; `gate`,
93
+ `result` — pass/fail), and `spf.agent.calls` (counter; `agent`, `model`,
94
+ `coding_agent`). Unlike the span exporter (one per run), there is exactly
95
+ one `MeterProvider` for the life of the `spf` process — see `otel_metrics.
96
+ ts`'s own header for why that matters under `spf watch`'s daemon loop.
97
+
98
+ **Outbound trace-context propagation.** When otel is configured, SPF also
99
+ tries to carry `traceparent` onto the OUTBOUND model calls each agent makes,
100
+ so this run's spans join whatever trace the model-serving stack itself
101
+ produces (see "Tracing across the inference stack" below). The three coding
102
+ agent backends get different-confidence treatment:
103
+ - `coding_agent: claude_code` — a real, verified guarantee.
104
+ `agent_cc.ts`'s single `spawn()` choke point sets `TRACEPARENT` and
105
+ `ANTHROPIC_CUSTOM_HEADERS` (`traceparent`/`x-request-id`, newline-
106
+ separated `Name: Value` pairs — the CLI's own documented format,
107
+ requires `claude` CLI >= 2.1.227) on the `claude` subprocess's
108
+ environment for every call.
109
+ - `coding_agent: opencode` — config-verified, behavior best-effort.
110
+ `agent_opencode.ts` injects `TRACEPARENT` onto the subprocess env
111
+ (parity with `claude_code`; unverified whether the opencode CLI itself
112
+ reads it) AND writes `traceparent`/`x-request-id` as static
113
+ `provider.<id>.options.headers` into the temp `opencode.json` —
114
+ opencode's documented per-provider options surface, honored by the AI
115
+ SDK on that provider's requests. Static is correct because one
116
+ `opencode run` subprocess is exactly one agent call. Two caveats,
117
+ documented in the module's own doc comment: a bare model id with no
118
+ `provider/` prefix skips the headers (SPF never guesses a provider id),
119
+ and a target repo's own `opencode.json` merges at higher precedence and
120
+ can override them.
121
+ - `coding_agent: flue` — best-effort. `@flue/opentelemetry`'s own docs say
122
+ plainly that `dispatch()` "does not propagate trace context" on its own,
123
+ so SPF additionally registers `@opentelemetry/instrumentation-http` +
124
+ `-undici` globally, with a propagator that carries both the standard W3C
125
+ `traceparent` and a custom `x-request-id`. This reaches any provider
126
+ whose Node SDK issues requests through `http`/`https`/`undici` (verified)
127
+ — it does NOT reach a provider transport that bypasses both (unverified
128
+ for the Anthropic/Google/Mistral SDKs' internal transports specifically;
129
+ flagged, not assumed). See `core/otel_propagation.ts`'s header for the
130
+ full mechanism. Since #80, flue's spans additionally JOIN SPF's
131
+ deterministic trace: `agent_flue.ts` registers each run's session id
132
+ (== flue's instance id) against that call's traceparent, and the
133
+ installed instrumentation's `resolveRootContext` option — consulted per
134
+ root span, keyed on the event context's instance id — roots each
135
+ session's spans under the right agent-call span. This is done per-span
136
+ rather than by wrapping `dispatch()` in a context because flue's node
137
+ runtime executes all submissions in one process-lifetime claim loop
138
+ whose async context is captured once — a dispatch-time wrap would
139
+ mis-attribute every agent after the first into the first agent's trace
140
+ (verified against `@flue/runtime`'s dist; see `core/otel_propagation
141
+ .ts`'s header). Unmapped sessions (backlog restarts, post-settlement
142
+ stragglers) degrade to the old separate-trace behavior — never
143
+ mis-attributed, never an error; correlate those by `x-request-id`/
144
+ `spf.adw_id`/time window.
145
+
146
+ ## Tracing across the inference stack
147
+
148
+ A run's SPF spans are not the only spans in play once a `claude_code` agent
149
+ is pointed at a self-hosted model through Switchyard/vLLM (this repo's own
150
+ inference-platform-aws stack): the `claude` CLI's own request, Switchyard's
151
+ routing hop, and vLLM's own serving span can ALL be emitted to the same Tempo
152
+ (or any OTLP-compatible) backend. What joins them:
153
+
154
+ - **The `traceparent` SPF injects** (see above) is a real W3C header on the
155
+ actual HTTP request `claude` makes to `ANTHROPIC_BASE_URL` — whatever
156
+ receives that request (Switchyard, vLLM directly, an Envoy AI Gateway
157
+ hop) that is ALSO instrumented with OTel and honors an inbound
158
+ `traceparent` will parent its own span under SPF's agent-call span,
159
+ landing in the SAME trace.
160
+ - **SPF's OWN span export is a separate, deterministic trace** — one
161
+ `adw_id` = one trace id (`sha256(adw_id)`), independent of whatever trace
162
+ id the model-serving hop's own OTel SDK would otherwise mint. Since SPF
163
+ controls the OUTBOUND `traceparent` it sends (not merely observes one),
164
+ a `claude_code` call's downstream spans (Switchyard, vLLM) land as
165
+ CHILDREN of SPF's own deterministic trace id, not the other way around —
166
+ a Tempo query for `sha256(adw_id)` finds the whole cross-service picture
167
+ for that run, agent call down through the model server.
168
+ - **The `flue` backend joins the same trace** (since #80): Flue's own
169
+ spans (`flue.coordinator`, `invoke_agent`, `chat <model>`,
170
+ `execute_tool`) inherit SPF's deterministic trace id (SDK-random span
171
+ ids underneath) via the instrumentation's per-span `resolveRootContext`
172
+ rooted on the run's registered session-id -> traceparent map, and the
173
+ `traceparent` the http/undici instrumentations inject onto Flue's
174
+ provider requests carries it too — downstream Switchyard/vLLM spans
175
+ land in the same trace, exactly as the `claude_code` path described
176
+ above. Unmapped submissions (a restarted process draining a durable
177
+ backlog, post-settlement bookkeeping) fall back to a separate trace —
178
+ never MIS-attributed into another agent's trace — correlatable by
179
+ `x-request-id`/`spf.adw_id`/time window; see the "Outbound
180
+ trace-context propagation" section above.
181
+ - **`x-request-id`** rides alongside `traceparent` specifically so a
182
+ collector/log pipeline that correlates by individual REQUEST (rather
183
+ than by trace) has a stable id to key on — it is this call's own
184
+ (SPF-side) span id for `claude_code`, and the currently-active span's id
185
+ for `flue`.
186
+ - **vLLM's own cache-read pass-through**
187
+ (`usage.prompt_tokens_details.cached_tokens`, surfaced here as both
188
+ `spf.tokens.cache_read` and `gen_ai.usage.cache_read.input_tokens`)
189
+ requires the upstream vLLM server to be started with
190
+ `--enable-prompt-tokens-details` (off by default) — SPF has no way to
191
+ detect this at config-validation time; a correctly-wired pipeline reads
192
+ `0` forever against a server that hasn't set the flag, with no bug
193
+ anywhere in SPF's own code.
194
+
81
195
  **Spend is itemized per phase.** `agent_end.usage` carries tokens *and*
82
196
  dollars for each component Flue reports (matching pi-ai's field names
83
197
  one-for-one) — `input`, `output`, `cache_read`, `cache_write` — summed
package/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import * as agentFlue from "../core/agent_flue.js";
11
11
  import * as agentOpencode from "../core/agent_opencode.js";
12
12
  import * as notify from "../core/notify/notifier.js";
13
13
  import * as otel from "../core/otel.js";
14
+ import * as otelMetrics from "../core/otel_metrics.js";
14
15
  import * as paths from "../core/paths.js";
15
16
  import * as sandbox from "../core/sandbox.js";
16
17
  import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
@@ -261,5 +262,10 @@ export async function main() {
261
262
  // deadline, so a fast-exiting command neither drops spans mid-flight nor
262
263
  // waits on an unreachable collector. Never throws (core/otel.ts).
263
264
  await otel.flushAll();
265
+ // Shuts down the process-scoped OTel MeterProvider (a no-op if metrics
266
+ // were never configured/resolved this process) — see otel_metrics.ts's
267
+ // own module header for why this is process-scoped rather than per-run
268
+ // like the span exporter above. Never throws.
269
+ await otelMetrics.shutdownOtelMetrics();
264
270
  }
265
271
  }
@@ -115,6 +115,17 @@ export declare class CcToolCallTracker {
115
115
  private finish;
116
116
  }
117
117
  export declare function isKnownToolName(name: string): boolean;
118
+ /**
119
+ * Merges `TRACEPARENT`/`ANTHROPIC_CUSTOM_HEADERS` into `baseEnv` for
120
+ * outbound OTel propagation, or returns `baseEnv` UNCHANGED when
121
+ * `otel` is absent (the common case — `observability.otel` not configured).
122
+ * `ANTHROPIC_CUSTOM_HEADERS`'s verified format is newline-separated
123
+ * `Name: Value` pairs (see this module's own header for the citation); an
124
+ * operator-supplied value already present in `baseEnv` is kept and appended
125
+ * to, not overwritten — a real header injected via config/settings must
126
+ * still reach the wire alongside this module's own.
127
+ */
128
+ export declare function injectOtelEnv(baseEnv: Record<string, string>, otel: AgentRequest["otel"] | undefined): Record<string, string>;
118
129
  /**
119
130
  * Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
120
131
  * token with `model` — see the module doc comment for why a fixed tag baked
@@ -248,6 +248,27 @@ const EFFORT_MAP = {
248
248
  xhigh: "xhigh",
249
249
  max: "max",
250
250
  };
251
+ /**
252
+ * Merges `TRACEPARENT`/`ANTHROPIC_CUSTOM_HEADERS` into `baseEnv` for
253
+ * outbound OTel propagation, or returns `baseEnv` UNCHANGED when
254
+ * `otel` is absent (the common case — `observability.otel` not configured).
255
+ * `ANTHROPIC_CUSTOM_HEADERS`'s verified format is newline-separated
256
+ * `Name: Value` pairs (see this module's own header for the citation); an
257
+ * operator-supplied value already present in `baseEnv` is kept and appended
258
+ * to, not overwritten — a real header injected via config/settings must
259
+ * still reach the wire alongside this module's own.
260
+ */
261
+ export function injectOtelEnv(baseEnv, otel) {
262
+ if (!otel)
263
+ return baseEnv;
264
+ const ownHeaders = [`traceparent: ${otel.traceparent}`, `x-request-id: ${otel.x_request_id}`].join("\n");
265
+ const existing = baseEnv.ANTHROPIC_CUSTOM_HEADERS;
266
+ return {
267
+ ...baseEnv,
268
+ TRACEPARENT: otel.traceparent,
269
+ ANTHROPIC_CUSTOM_HEADERS: existing ? `${existing}\n${ownHeaders}` : ownHeaders,
270
+ };
271
+ }
251
272
  /**
252
273
  * Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
253
274
  * token with `model` — see the module doc comment for why a fixed tag baked
@@ -337,7 +358,24 @@ export async function run(request, onEvent, onSpawn, onExit) {
337
358
  const isOllamaLaunch = isOllamaLaunchCmd(cmdSpec);
338
359
  const needsOllamaLaunchSeparator = isOllamaLaunch && !cmdArgs.includes("--");
339
360
  const fullArgs = needsOllamaLaunchSeparator ? [...cmdArgs, "--", ...args] : [...cmdArgs, ...args];
340
- const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: request.env ?? operatorEnv() });
361
+ // Outbound OTel propagation (SPF's otel-sdk extension): `request.otel` is
362
+ // set only when `observability.otel` is configured for this run (see
363
+ // `agents.ts`'s `send()`) — absent otherwise, so `env` below is
364
+ // byte-identical to before this existed for every repo that hasn't
365
+ // configured otel. `TRACEPARENT` is the standard W3C env var the `claude`
366
+ // CLI's own subprocesses/telemetry already look for; `ANTHROPIC_CUSTOM_
367
+ // HEADERS` additionally puts `traceparent` (redundant with the env var,
368
+ // but this is the only way to reach the ACTUAL outbound HTTP request the
369
+ // CLI itself makes to its configured `ANTHROPIC_BASE_URL`) and
370
+ // `x-request-id` (this agent call's own span id) onto that request's
371
+ // headers. Format verified against Claude Code's own docs (https://
372
+ // code.claude.com/docs/en/env-vars, fetched live for this feature —
373
+ // requires CLI >= 2.1.227): "Custom headers to add to requests (`Name:
374
+ // Value` format, newline-separated for multiple headers)". An
375
+ // operator-supplied `ANTHROPIC_CUSTOM_HEADERS` already present in
376
+ // `request.env` is PRESERVED, not clobbered — this appends to it.
377
+ const env = injectOtelEnv(request.env ?? operatorEnv(), request.otel);
378
+ const child = spawn(cmd, fullArgs, { cwd: request.cwd, env });
341
379
  // The prompt travels as a positional argv element, not stdin — closing it
342
380
  // immediately avoids a real, observed ~3s "no stdin data received" stall
343
381
  // where `claude` otherwise waits to see whether anything is piped in.
@@ -30,6 +30,7 @@ import { registerOllamaModel } from "./ollama_provider.js";
30
30
  import { registerCloudflareModel } from "./cloudflare_provider.js";
31
31
  import * as sandbox from "./sandbox.js";
32
32
  import { nowIso, operatorEnv } from "./utils.js";
33
+ import { installFluePropagation, registerFlueSessionTrace, unregisterFlueSessionTrace } from "./otel_propagation.js";
33
34
  const RESULT_SNIPPET_CHARS = 20_000; // tool output rides along whole; clip only guards pathological cases
34
35
  const ARG_VALUE_CHARS = 20_000; // args too — the UI scrolls, it must not be handed cut-off data
35
36
  const LABEL_CHARS = 80; // "bash: <command>" shown as the event name
@@ -402,6 +403,14 @@ export async function run(request, onEvent, onSpawn, onExit) {
402
403
  // real-Bearer-token (not dummy-key) auth.
403
404
  if (provider === "cloudflare")
404
405
  await registerCloudflareModel(modelId);
406
+ // Outbound OTel propagation (SPF's otel-sdk extension) — see
407
+ // `otel_propagation.ts`'s own header for what this does and does not
408
+ // guarantee. `request.otel` is set only when `observability.otel` is
409
+ // configured for this run (see `agents.ts`'s `send()`); the installer is
410
+ // itself a no-op on `undefined` AND idempotent across every later call in
411
+ // this same process, so this costs nothing for a repo that hasn't
412
+ // configured otel and installs at most once for one that has.
413
+ installFluePropagation(request.otel);
405
414
  await ensureRuntime(request.flue_db_path);
406
415
  REGISTRY.set(request.session_id, {
407
416
  model: request.model,
@@ -417,6 +426,19 @@ export async function run(request, onEvent, onSpawn, onExit) {
417
426
  const pid = process.pid ?? -1;
418
427
  onSpawn?.(pid);
419
428
  const handle = init(SfAgent, { id: request.session_id });
429
+ // Join this session's flue spans to SPF's deterministic trace: the session
430
+ // id (== flue's instance id, which every flue observability event's ctx.id
431
+ // carries — see otel_propagation.ts's header) is registered against this
432
+ // call's traceparent, and the installed instrumentation resolves root
433
+ // spans through that map per span. WRAPPING dispatch() in a context here
434
+ // would be WRONG, not just redundant (verified against @flue/runtime's
435
+ // node dist): execution runs in ONE process-lifetime claim loop whose
436
+ // async context is captured by the first dispatch that started it, so a
437
+ // dispatch-time context wrap would silently mis-attribute every later
438
+ // agent's spans into the FIRST agent's trace. No-op when otel is
439
+ // unconfigured.
440
+ if (request.otel)
441
+ registerFlueSessionTrace(request.session_id, request.otel.traceparent);
420
442
  const receipt = await handle.dispatch(request.prompt);
421
443
  const slot = { usage: new UsageBreakdown(), context_tokens: 0 };
422
444
  pendingUsage.set(receipt.submissionId, slot);
@@ -455,6 +477,10 @@ export async function run(request, onEvent, onSpawn, onExit) {
455
477
  }
456
478
  finally {
457
479
  pendingUsage.delete(receipt.submissionId);
480
+ // Post-settlement bookkeeping spans flue mints after this point resolve
481
+ // to an unparented root (separate trace) — by design; see
482
+ // otel_propagation.ts's unregisterFlueSessionTrace.
483
+ unregisterFlueSessionTrace(request.session_id);
458
484
  onExit?.(pid);
459
485
  }
460
486
  }
@@ -65,9 +65,48 @@
65
65
  * docs, a target repo's OWN project-level `opencode.json` (if one exists)
66
66
  * merges at HIGHER precedence than the file `OPENCODE_CONFIG` points at.
67
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.
68
+ * (widen or narrow) the restriction this module writes, and likewise
69
+ * override the OTel propagation headers described below a real,
70
+ * documented gap in both guarantees, not a bug this module can paper over
71
+ * from the outside.
72
+ *
73
+ * OUTBOUND OTEL PROPAGATION [OFFICIAL config surface; best-effort
74
+ * behavior]: when `request.otel` is present (`agents.ts`'s `send()` threads
75
+ * it only when `observability.otel` is configured — absent otherwise, and
76
+ * this whole path is byte-identical to before), this module propagates
77
+ * trace context two ways:
78
+ *
79
+ * 1. `TRACEPARENT` on the child's env — the standard W3C env var, same
80
+ * shape `agent_cc.ts` sets. No published statement confirms the
81
+ * opencode CLI itself reads it (UNVERIFIED either way; set for parity
82
+ * and for any opencode-spawned subprocess telemetry, at zero cost).
83
+ * 2. `traceparent` + `x-request-id` as STATIC provider headers in the
84
+ * temp `opencode.json` (`provider.<id>.options.headers` — opencode's
85
+ * documented per-provider options surface;
86
+ * https://opencode.ai/docs/providers/). Static values are CORRECT
87
+ * here, unlike the general case, because one `opencode run` subprocess
88
+ * IS exactly one SPF agent call — the traceparent can never go stale
89
+ * mid-run. This is the header that actually reaches the wire:
90
+ * opencode's provider requests flow through the AI SDK, which honors
91
+ * `options.headers` for that provider's requests. Subject to the
92
+ * CONFIG PRECEDENCE limitation above: a repo's own `opencode.json` can
93
+ * override these headers.
94
+ *
95
+ * `injectOtelEnv()` / `otelProviderHeaders()` / `tempConfigContents()` are
96
+ * exported pure functions so every fragment is unit-testable without
97
+ * spawning a real subprocess — same discipline as the rest of this module.
98
+ *
99
+ * OPERATOR-CONFIG MERGE: when a caller-provided `OPENCODE_CONFIG` already
100
+ * exists in the base env (operatorEnv() passthrough or an agent's
101
+ * env_allowlist) AND this module needs a temp config of its own (a tools:
102
+ * restriction or the propagation headers), the operator's file is READ and
103
+ * its contents MERGED into the temp file (SPF's own blocks win on
104
+ * conflict — see `mergeOperatorConfig()`), rather than the pre-existing
105
+ * replace-it-outright behavior. This closes a real regression class:
106
+ * enabling OTel or a tools: list on an already-configured workflow used to
107
+ * silently drop the operator's provider routing/credentials config. An
108
+ * unreadable or unparseable operator file falls back to the old replace
109
+ * behavior (and is left untouched on disk) rather than failing the run.
71
110
  *
72
111
  * `write`/`apply_patch` ARE GATED THROUGH `edit` [OFFICIAL]: opencode's own
73
112
  * docs say these two are not independent permission keys — both ride the
@@ -206,6 +245,69 @@ export declare class OcToolCallTracker {
206
245
  observe(message: OcStreamMessage): Record<string, any> | null;
207
246
  }
208
247
  export declare function isKnownToolName(name: string): boolean;
248
+ /**
249
+ * Merges `TRACEPARENT` into `baseEnv` for outbound OTel propagation, or
250
+ * returns `baseEnv` UNCHANGED when `otel` is absent (the common case —
251
+ * `observability.otel` not configured). See the module doc comment's
252
+ * OUTBOUND OTEL PROPAGATION section: unlike `agent_cc.ts`'s sibling, no
253
+ * published statement confirms the opencode CLI itself reads this var, so
254
+ * the REAL propagation path is the temp-config headers below; this env var
255
+ * is set for parity at zero cost.
256
+ */
257
+ export declare function injectOtelEnv(baseEnv: Record<string, string>, otel: AgentRequest["otel"] | undefined): Record<string, string>;
258
+ export interface OpencodeOtelHeaders {
259
+ provider: string;
260
+ headers: {
261
+ traceparent: string;
262
+ "x-request-id": string;
263
+ };
264
+ }
265
+ /**
266
+ * Builds the `provider.<id>.options.headers` fragment for a temp
267
+ * `opencode.json` (see the module doc comment's OUTBOUND OTEL PROPAGATION
268
+ * section for why static values are correct here). Returns `null` when
269
+ * `otel` is absent, or when the model id carries no `provider/` prefix —
270
+ * opencode's own `--model` vocabulary is documented as `provider/model-id`,
271
+ * but a bare model name has no provider id this module could key headers
272
+ * under, and guessing the wrong provider id would write a config block
273
+ * opencode merges onto a DIFFERENT provider than the one being called.
274
+ */
275
+ export declare function otelProviderHeaders(model: string, otel: AgentRequest["otel"] | undefined): OpencodeOtelHeaders | null;
276
+ /**
277
+ * Builds the JSON CONTENT of the temp `opencode.json`: a `permission` map
278
+ * when `toolNames` is an array (including `[]` — restrict to exactly those,
279
+ * mirroring CC's `--tools`), a `provider` block when `otelHeaders` is
280
+ * present, both when both apply. `null` when NEITHER applies — no config
281
+ * file needed at all, `--auto` alone (every tool usable, mirroring CC's
282
+ * `"default"`). Exported as its own pure function, same reasoning as
283
+ * `resolveOpencodeCmdSpec`: this shape is unit-testable without touching
284
+ * the filesystem or spawning a subprocess.
285
+ */
286
+ export declare function tempConfigContents(toolNames: string[] | null | undefined, otelHeaders: OpencodeOtelHeaders | null): Record<string, unknown> | null;
287
+ /**
288
+ * Recursive plain-object merge of SPF's own temp-config blocks ONTO the
289
+ * operator's existing `OPENCODE_CONFIG` contents — SPF's values win on
290
+ * conflicting plain-object leaves/deeper keys (a `tools:` restriction is a
291
+ * safety gate and the propagation headers are this call's own, so neither
292
+ * may be silently watered down), while every key the operator set and SPF
293
+ * never touches (provider `baseURL`/`apiKey`/model routing, MCP servers,
294
+ * agents) passes through intact. Arrays are replaced, not concatenated:
295
+ * this module never writes arrays itself, so a replaced array is always
296
+ * one of the OPERATOR's own values being deliberately overridden by an
297
+ * SPF block — the same "SPF wins on conflict" rule.
298
+ */
299
+ export declare function mergeOperatorConfig(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown>;
300
+ /**
301
+ * Reads the operator's existing `OPENCODE_CONFIG` file for merging, or
302
+ * returns `null` when there is nothing usable to merge with — file missing
303
+ * / unreadable / not valid JSON with a plain-object root. JSON.parse is
304
+ * deliberately used as-is: opencode's own docs advertise plain-JSON config,
305
+ * and silently treating a JSONC file (comments) as replaceable would repeat
306
+ * the pre-existing clobber the merge exists to fix — so an unparseable file
307
+ * simply falls through to the pre-existing replace behavior, with the
308
+ * operator's own file untouched on disk.
309
+ */
310
+ export declare function readOperatorConfig(configPath: string): Record<string, unknown> | null;
209
311
  /**
210
312
  * Resolve `SPF_OPENCODE_CMD` for THIS call, substituting a literal `{model}`
211
313
  * token with `model` — same mechanism and rationale as `agent_cc.ts`'s
@@ -65,9 +65,48 @@
65
65
  * docs, a target repo's OWN project-level `opencode.json` (if one exists)
66
66
  * merges at HIGHER precedence than the file `OPENCODE_CONFIG` points at.
67
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.
68
+ * (widen or narrow) the restriction this module writes, and likewise
69
+ * override the OTel propagation headers described below a real,
70
+ * documented gap in both guarantees, not a bug this module can paper over
71
+ * from the outside.
72
+ *
73
+ * OUTBOUND OTEL PROPAGATION [OFFICIAL config surface; best-effort
74
+ * behavior]: when `request.otel` is present (`agents.ts`'s `send()` threads
75
+ * it only when `observability.otel` is configured — absent otherwise, and
76
+ * this whole path is byte-identical to before), this module propagates
77
+ * trace context two ways:
78
+ *
79
+ * 1. `TRACEPARENT` on the child's env — the standard W3C env var, same
80
+ * shape `agent_cc.ts` sets. No published statement confirms the
81
+ * opencode CLI itself reads it (UNVERIFIED either way; set for parity
82
+ * and for any opencode-spawned subprocess telemetry, at zero cost).
83
+ * 2. `traceparent` + `x-request-id` as STATIC provider headers in the
84
+ * temp `opencode.json` (`provider.<id>.options.headers` — opencode's
85
+ * documented per-provider options surface;
86
+ * https://opencode.ai/docs/providers/). Static values are CORRECT
87
+ * here, unlike the general case, because one `opencode run` subprocess
88
+ * IS exactly one SPF agent call — the traceparent can never go stale
89
+ * mid-run. This is the header that actually reaches the wire:
90
+ * opencode's provider requests flow through the AI SDK, which honors
91
+ * `options.headers` for that provider's requests. Subject to the
92
+ * CONFIG PRECEDENCE limitation above: a repo's own `opencode.json` can
93
+ * override these headers.
94
+ *
95
+ * `injectOtelEnv()` / `otelProviderHeaders()` / `tempConfigContents()` are
96
+ * exported pure functions so every fragment is unit-testable without
97
+ * spawning a real subprocess — same discipline as the rest of this module.
98
+ *
99
+ * OPERATOR-CONFIG MERGE: when a caller-provided `OPENCODE_CONFIG` already
100
+ * exists in the base env (operatorEnv() passthrough or an agent's
101
+ * env_allowlist) AND this module needs a temp config of its own (a tools:
102
+ * restriction or the propagation headers), the operator's file is READ and
103
+ * its contents MERGED into the temp file (SPF's own blocks win on
104
+ * conflict — see `mergeOperatorConfig()`), rather than the pre-existing
105
+ * replace-it-outright behavior. This closes a real regression class:
106
+ * enabling OTel or a tools: list on an already-configured workflow used to
107
+ * silently drop the operator's provider routing/credentials config. An
108
+ * unreadable or unparseable operator file falls back to the old replace
109
+ * behavior (and is left untouched on disk) rather than failing the run.
71
110
  *
72
111
  * `write`/`apply_patch` ARE GATED THROUGH `edit` [OFFICIAL]: opencode's own
73
112
  * docs say these two are not independent permission keys — both ride the
@@ -182,7 +221,7 @@
182
221
  import { spawn } from "node:child_process";
183
222
  import { createInterface } from "node:readline";
184
223
  import { randomUUID } from "node:crypto";
185
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
224
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
186
225
  import os from "node:os";
187
226
  import path from "node:path";
188
227
  import { UsageBreakdown, makeAgentResult } from "./data_types.js";
@@ -359,16 +398,114 @@ function permissionMapFor(toolNames) {
359
398
  return permission;
360
399
  }
361
400
  /**
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`.
401
+ * Merges `TRACEPARENT` into `baseEnv` for outbound OTel propagation, or
402
+ * returns `baseEnv` UNCHANGED when `otel` is absent (the common case —
403
+ * `observability.otel` not configured). See the module doc comment's
404
+ * OUTBOUND OTEL PROPAGATION section: unlike `agent_cc.ts`'s sibling, no
405
+ * published statement confirms the opencode CLI itself reads this var, so
406
+ * the REAL propagation path is the temp-config headers below; this env var
407
+ * is set for parity at zero cost.
408
+ */
409
+ export function injectOtelEnv(baseEnv, otel) {
410
+ if (!otel)
411
+ return baseEnv;
412
+ return { ...baseEnv, TRACEPARENT: otel.traceparent };
413
+ }
414
+ /**
415
+ * Builds the `provider.<id>.options.headers` fragment for a temp
416
+ * `opencode.json` (see the module doc comment's OUTBOUND OTEL PROPAGATION
417
+ * section for why static values are correct here). Returns `null` when
418
+ * `otel` is absent, or when the model id carries no `provider/` prefix —
419
+ * opencode's own `--model` vocabulary is documented as `provider/model-id`,
420
+ * but a bare model name has no provider id this module could key headers
421
+ * under, and guessing the wrong provider id would write a config block
422
+ * opencode merges onto a DIFFERENT provider than the one being called.
423
+ */
424
+ export function otelProviderHeaders(model, otel) {
425
+ if (!otel)
426
+ return null;
427
+ const slash = model.indexOf("/");
428
+ if (slash <= 0)
429
+ return null;
430
+ return { provider: model.slice(0, slash), headers: { traceparent: otel.traceparent, "x-request-id": otel.x_request_id } };
431
+ }
432
+ /**
433
+ * Builds the JSON CONTENT of the temp `opencode.json`: a `permission` map
434
+ * when `toolNames` is an array (including `[]` — restrict to exactly those,
435
+ * mirroring CC's `--tools`), a `provider` block when `otelHeaders` is
436
+ * present, both when both apply. `null` when NEITHER applies — no config
437
+ * file needed at all, `--auto` alone (every tool usable, mirroring CC's
438
+ * `"default"`). Exported as its own pure function, same reasoning as
439
+ * `resolveOpencodeCmdSpec`: this shape is unit-testable without touching
440
+ * the filesystem or spawning a subprocess.
441
+ */
442
+ export function tempConfigContents(toolNames, otelHeaders) {
443
+ const contents = {};
444
+ if (toolNames != null)
445
+ contents.permission = permissionMapFor(toolNames);
446
+ if (otelHeaders)
447
+ contents.provider = { [otelHeaders.provider]: { options: { headers: otelHeaders.headers } } };
448
+ return Object.keys(contents).length === 0 ? null : contents;
449
+ }
450
+ /**
451
+ * Recursive plain-object merge of SPF's own temp-config blocks ONTO the
452
+ * operator's existing `OPENCODE_CONFIG` contents — SPF's values win on
453
+ * conflicting plain-object leaves/deeper keys (a `tools:` restriction is a
454
+ * safety gate and the propagation headers are this call's own, so neither
455
+ * may be silently watered down), while every key the operator set and SPF
456
+ * never touches (provider `baseURL`/`apiKey`/model routing, MCP servers,
457
+ * agents) passes through intact. Arrays are replaced, not concatenated:
458
+ * this module never writes arrays itself, so a replaced array is always
459
+ * one of the OPERATOR's own values being deliberately overridden by an
460
+ * SPF block — the same "SPF wins on conflict" rule.
461
+ */
462
+ export function mergeOperatorConfig(base, overlay) {
463
+ const out = { ...base };
464
+ for (const [key, value] of Object.entries(overlay)) {
465
+ const existing = out[key];
466
+ out[key] =
467
+ existing && typeof existing === "object" && !Array.isArray(existing) && value && typeof value === "object" && !Array.isArray(value)
468
+ ? mergeOperatorConfig(existing, value)
469
+ : value;
470
+ }
471
+ return out;
472
+ }
473
+ /**
474
+ * Reads the operator's existing `OPENCODE_CONFIG` file for merging, or
475
+ * returns `null` when there is nothing usable to merge with — file missing
476
+ * / unreadable / not valid JSON with a plain-object root. JSON.parse is
477
+ * deliberately used as-is: opencode's own docs advertise plain-JSON config,
478
+ * and silently treating a JSONC file (comments) as replaceable would repeat
479
+ * the pre-existing clobber the merge exists to fix — so an unparseable file
480
+ * simply falls through to the pre-existing replace behavior, with the
481
+ * operator's own file untouched on disk.
482
+ */
483
+ export function readOperatorConfig(configPath) {
484
+ try {
485
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
486
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
487
+ }
488
+ catch {
489
+ return null;
490
+ }
491
+ }
492
+ /**
493
+ * Writes `contents` (see `tempConfigContents`) to a temp `opencode.json`.
494
+ * Caller is responsible for `rmSync`-ing `dir` when done — see `run()`'s
495
+ * `finally`.
367
496
  */
368
- function writeTempPermissionConfig(toolNames) {
497
+ function writeTempConfig(contents) {
369
498
  const dir = mkdtempSync(path.join(os.tmpdir(), "spf-opencode-"));
370
499
  const configPath = path.join(dir, "opencode.json");
371
- writeFileSync(configPath, JSON.stringify({ permission: permissionMapFor(toolNames) }, null, 2));
500
+ try {
501
+ writeFileSync(configPath, JSON.stringify(contents, null, 2));
502
+ }
503
+ catch (err) {
504
+ // This runs BEFORE run()'s try/finally — a failed write would otherwise
505
+ // leak the just-minted temp dir with nobody responsible for it.
506
+ rmSync(dir, { recursive: true, force: true });
507
+ throw err;
508
+ }
372
509
  return { dir, configPath };
373
510
  }
374
511
  // SPF's off|minimal|low|medium|high|xhigh|max -> opencode's --variant.
@@ -464,12 +601,27 @@ export async function run(request, onEvent, onSpawn, onExit) {
464
601
  const [cmd, ...cmdArgs] = cmdTokens;
465
602
  const fullArgs = [...cmdArgs, ...args];
466
603
  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;
604
+ // `request.tools` null/undefined AND no otel config -> every tool, no
605
+ // config file written at all (see tempConfigContents' own doc comment).
606
+ // Otherwise -> a real temp opencode.json (tool restriction, OTel provider
607
+ // headers, or both), pointed at via OPENCODE_CONFIG on the CHILD's env
608
+ // only never mutates process.env.
609
+ const otelHeaders = otelProviderHeaders(request.model, request.otel);
610
+ let configContents = tempConfigContents(request.tools, otelHeaders);
611
+ // A caller-provided OPENCODE_CONFIG (operatorEnv() passthrough or an
612
+ // agent's env_allowlist) is MERGED into the temp file, never replaced —
613
+ // SPF's own blocks win on conflict (see mergeOperatorConfig), but
614
+ // everything else the operator configured (provider baseURL/apiKey/model
615
+ // routing, MCP servers) survives. Without this, enabling OTel or a tools:
616
+ // list on an already-configured workflow would silently re-route or break
617
+ // agent calls.
618
+ if (configContents && baseEnv.OPENCODE_CONFIG) {
619
+ const operatorCfg = readOperatorConfig(baseEnv.OPENCODE_CONFIG);
620
+ if (operatorCfg)
621
+ configContents = mergeOperatorConfig(operatorCfg, configContents);
622
+ }
623
+ const tempConfig = configContents ? writeTempConfig(configContents) : null;
624
+ const childEnv = injectOtelEnv(tempConfig ? { ...baseEnv, OPENCODE_CONFIG: tempConfig.configPath } : baseEnv, request.otel);
473
625
  try {
474
626
  const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: childEnv });
475
627
  // See the module doc comment's STDIN note — closed immediately to avoid