@gr8ful/spf 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +8 -0
  2. package/assets/skill/references/config.md +1 -0
  3. package/dist/cli/commands/doctor.js +25 -3
  4. package/dist/cli/commands/estimate.d.ts +22 -6
  5. package/dist/cli/commands/estimate.js +32 -10
  6. package/dist/cli/commands/loop.d.ts +20 -0
  7. package/dist/cli/commands/loop.js +20 -1
  8. package/dist/cli/commands/ui.js +2 -1
  9. package/dist/cli/commands/watch.js +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/interview.js +13 -0
  12. package/dist/cli/ui/run_dashboard.js +13 -7
  13. package/dist/core/agent_cc.d.ts +19 -3
  14. package/dist/core/agent_cc.js +38 -18
  15. package/dist/core/agent_flue.js +51 -14
  16. package/dist/core/agent_opencode.d.ts +62 -25
  17. package/dist/core/agent_opencode.js +71 -30
  18. package/dist/core/agents.d.ts +51 -4
  19. package/dist/core/agents.js +79 -4
  20. package/dist/core/console.d.ts +24 -4
  21. package/dist/core/console.js +20 -7
  22. package/dist/core/data_types.d.ts +300 -19
  23. package/dist/core/data_types.js +134 -5
  24. package/dist/core/issues/jira_provider.d.ts +58 -3
  25. package/dist/core/issues/jira_provider.js +89 -18
  26. package/dist/core/issues/markdown_adf.d.ts +58 -0
  27. package/dist/core/issues/markdown_adf.js +705 -0
  28. package/dist/core/issues/provider.d.ts +23 -0
  29. package/dist/core/loop.d.ts +39 -1
  30. package/dist/core/loop.js +33 -2
  31. package/dist/core/ollama_provider.d.ts +96 -13
  32. package/dist/core/ollama_provider.js +172 -26
  33. package/dist/core/otel.js +10 -1
  34. package/dist/core/otel_propagation.d.ts +168 -24
  35. package/dist/core/otel_propagation.js +219 -43
  36. package/dist/core/permissions.d.ts +16 -1
  37. package/dist/core/permissions.js +91 -3
  38. package/dist/core/providers.js +8 -3
  39. package/dist/core/refine.js +13 -1
  40. package/dist/core/runner.d.ts +33 -2
  41. package/dist/core/runner.js +40 -5
  42. package/dist/core/tiering.js +7 -3
  43. package/dist/core/tracer.d.ts +7 -1
  44. package/dist/core/tracer.js +15 -3
  45. package/dist/ui/server/db.d.ts +8 -1
  46. package/dist/ui/server/db.js +21 -4
  47. package/dist/ui/server/serve.d.ts +7 -0
  48. package/dist/ui/server/serve.js +10 -7
  49. package/dist/ui/shared/types.d.ts +16 -0
  50. package/package.json +3 -3
@@ -317,8 +317,18 @@ const SfAgent = Object.assign(sfAgentRender, {
317
317
  });
318
318
  const pendingUsage = new Map();
319
319
  let runtimePromise = null;
320
- function ensureRuntime(flueDbPath) {
320
+ /**
321
+ * `requestTimeoutMs` (from `defaults.request_timeout_ms`) only has an effect
322
+ * on the FIRST call — `SfAgent.durability` is a static Flue reads once, and
323
+ * `runtimePromise` below already memoizes `start()` to run once per process.
324
+ * A later call with a different value is silently ignored, same as `start()`
325
+ * itself already is; every dispatch in one `spf` process shares one config
326
+ * anyway, so this can't happen in practice outside a test harness.
327
+ */
328
+ function ensureRuntime(flueDbPath, requestTimeoutMs) {
321
329
  if (!runtimePromise) {
330
+ if (requestTimeoutMs !== undefined)
331
+ SfAgent.durability = { timeoutMs: requestTimeoutMs };
322
332
  runtimePromise = start({ agents: [SfAgent], db: sqlite(flueDbPath) }).then((flue) => {
323
333
  observe((event) => {
324
334
  if (event.type !== "turn" || !event.submissionId)
@@ -394,8 +404,35 @@ export async function run(request, onEvent, onSpawn, onExit) {
394
404
  // imports this module for `resolveModel()` alone (doctor.ts, interview.ts)
395
405
  // without ever dispatching an ollama call.
396
406
  const [provider, modelId] = resolveModel(request.model);
407
+ // Outbound OTel propagation (SPF's otel-sdk extension) — see
408
+ // `otel_propagation.ts`'s own header for what this does and does not
409
+ // guarantee. `request.otel` is set only when `observability.otel` is
410
+ // configured for this run (see `agents.ts`'s `send()`); the installer is
411
+ // itself a no-op on `undefined` AND idempotent across every later call in
412
+ // this same process, so this costs nothing for a repo that hasn't
413
+ // configured otel and installs at most once for one that has. Called
414
+ // BEFORE `registerOllamaModel` below (not after, as an earlier version had
415
+ // it): `ollama_provider.ts`'s `modelFor()`/`resolve()` both consult
416
+ // `isFluePropagationInstalled()` to decide whether THEY need to supply
417
+ // `traceparent`/`x-correlation-id`/`x-spf-agent` themselves — if
418
+ // installation happened AFTER the first registration in a run that has
419
+ // otel configured, that first model would be built (and cached in
420
+ // `Model.headers`) believing propagation wasn't installed yet, then keep
421
+ // stale static headers alongside the instrumentation's own per-request
422
+ // ones for the rest of the process (a duplicate-header bug, same shape as
423
+ // BLOCKER A). Installing first means every registration in this run sees
424
+ // the SAME, final installed-state.
425
+ installFluePropagation(request.otel);
426
+ // `adw_id`/`agent_name` (when the caller supplied them — see
427
+ // `data_types.ts`'s `AgentRequest` doc) become this model id's static
428
+ // `x-correlation-id`/`x-spf-agent` gateway headers, re-stamped on every
429
+ // call — see `ollama_provider.ts`'s "Gateway headers" section and
430
+ // `registerOllamaModel`'s own doc (MAJOR-D) for why this must run every
431
+ // time, not just on first registration, and why it's a no-op for these
432
+ // two headers specifically once otel propagation is installed (see
433
+ // `registerFlueSessionTrace` below instead, in that case).
397
434
  if (provider === "ollama")
398
- await registerOllamaModel(modelId);
435
+ await registerOllamaModel(modelId, { adwId: request.adw_id, agentName: request.agent_name });
399
436
  // Cloudflare Workers AI is the same self-registration shape as Ollama
400
437
  // (no pi-ai/Flue built-in "cloudflare" provider on Node) — see
401
438
  // cloudflare_provider.ts's header comment for the Workers AI OpenAI-
@@ -403,15 +440,7 @@ export async function run(request, onEvent, onSpawn, onExit) {
403
440
  // real-Bearer-token (not dummy-key) auth.
404
441
  if (provider === "cloudflare")
405
442
  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);
414
- await ensureRuntime(request.flue_db_path);
443
+ await ensureRuntime(request.flue_db_path, request.request_timeout_ms);
415
444
  REGISTRY.set(request.session_id, {
416
445
  model: request.model,
417
446
  thinking: request.thinking,
@@ -436,9 +465,17 @@ export async function run(request, onEvent, onSpawn, onExit) {
436
465
  // async context is captured by the first dispatch that started it, so a
437
466
  // dispatch-time context wrap would silently mis-attribute every later
438
467
  // 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);
468
+ // unconfigured. The SAME registration also carries `adw_id`/`agent_name`
469
+ // (MAJOR-D) — `otel_propagation.ts`'s `GatewayHeadersPropagator` reads
470
+ // them back per real outbound call this session makes, exactly the way
471
+ // `resolveFlueRootContext` already reads `traceparent` back per span.
472
+ if (request.otel) {
473
+ registerFlueSessionTrace(request.session_id, {
474
+ traceparent: request.otel.traceparent,
475
+ adwId: request.adw_id,
476
+ agentName: request.agent_name,
477
+ });
478
+ }
442
479
  const receipt = await handle.dispatch(request.prompt);
443
480
  const slot = { usage: new UsageBreakdown(), context_tokens: 0 };
444
481
  pendingUsage.set(receipt.submissionId, slot);
@@ -70,32 +70,61 @@
70
70
  * documented gap in both guarantees, not a bug this module can paper over
71
71
  * from the outside.
72
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:
73
+ * OUTBOUND OTEL + GATEWAY-HEADER PROPAGATION [OFFICIAL config surface;
74
+ * best-effort behavior]: `request.otel` is present only when
75
+ * `observability.otel` is configured (`agents.ts`'s `send()`); `request.
76
+ * adw_id`/`request.agent_name` are present on EVERY call regardless (the
77
+ * gateway needs `x-correlation-id`/`x-spf-agent` whether or not SPF's own
78
+ * otel export is on — see `data_types.ts`'s `AgentRequest.adw_id` doc). This
79
+ * module propagates whatever subset of the two is present, two ways:
78
80
  *
79
81
  * 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;
82
+ * shape `agent_cc.ts` sets, ONLY when `otel` is present. No published
83
+ * statement confirms the opencode CLI itself reads it (UNVERIFIED
84
+ * either way; set for parity and for any opencode-spawned subprocess
85
+ * telemetry, at zero cost).
86
+ * 2. `traceparent` (when `otel` present) and `x-correlation-id`/
87
+ * `x-spf-agent` (when `adw_id`/`agent_name` present) as STATIC provider
88
+ * headers in the temp `opencode.json` (`provider.<id>.options.headers`
89
+ * — opencode's documented per-provider options surface;
86
90
  * https://opencode.ai/docs/providers/). Static values are CORRECT
87
91
  * 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.
92
+ * IS exactly one SPF agent call — none of these three headers can go
93
+ * stale mid-run. This is the header set that actually reaches the
94
+ * wire: opencode's provider requests flow through the AI SDK, which
95
+ * honors `options.headers` for that provider's requests. Subject to
96
+ * the CONFIG PRECEDENCE limitation above: a repo's own `opencode.json`
97
+ * can override these headers. `x-request-id` is NEVER one of them —
98
+ * Envoy/Switchyard own that header end-to-end; this module used to
99
+ * send it here (BLOCKER B) and no longer does.
94
100
  *
95
101
  * `injectOtelEnv()` / `otelProviderHeaders()` / `tempConfigContents()` are
96
102
  * exported pure functions so every fragment is unit-testable without
97
103
  * spawning a real subprocess — same discipline as the rest of this module.
98
104
  *
105
+ * MINOR-4 (deliberate, not an oversight): `otelProviderHeaders()` returns
106
+ * non-null — and so `tempConfigContents()` writes a real `provider` block —
107
+ * whenever EITHER `otel` OR `gateway.adwId`/`gateway.agentName` is present,
108
+ * and `data_types.ts`'s `AgentRequest.adw_id`/`agent_name` doc is explicit
109
+ * that those two are set by `agents.ts`'s `send()` from `run.adw_id`/
110
+ * `agent.name` UNCONDITIONALLY — every SPF run has an adw_id, every agent
111
+ * has a name — NOT gated on `observability.otel` being configured at all.
112
+ * The practical consequence: for any `opencode` agent whose `--model`
113
+ * carries a `provider/` prefix (opencode's own documented model-id shape;
114
+ * see `otelProviderHeaders`'s own doc for the bare-model-name exception),
115
+ * this module writes a temporary `opencode.json` (`mkdtempSync` + one
116
+ * `writeFileSync`, removed in `run()`'s `finally`) on EVERY invocation, even
117
+ * with `observability.otel` unconfigured and no `tools:` restriction in
118
+ * play — there is no "otel off, no gateway identity, no tools restriction"
119
+ * case left in practice once a run has an adw_id, which is always. This is
120
+ * KEPT, not gated behind an extra "only when otel/tools apply" check: the
121
+ * gateway needs `x-correlation-id`/`x-spf-agent` on every real call to group
122
+ * it by run (same rationale as `ollama_provider.ts`'s static per-model
123
+ * headers), independent of whether tracing is turned on, and a temp file
124
+ * per subprocess invocation is cheap relative to spawning that subprocess
125
+ * at all. A caller that truly wants zero config-file overhead has no lever
126
+ * for that today short of clearing `adw_id`/`agent_name` on the request.
127
+ *
99
128
  * OPERATOR-CONFIG MERGE: when a caller-provided `OPENCODE_CONFIG` already
100
129
  * exists in the base env (operatorEnv() passthrough or an agent's
101
130
  * env_allowlist) AND this module needs a temp config of its own (a tools:
@@ -257,22 +286,30 @@ export declare function isKnownToolName(name: string): boolean;
257
286
  export declare function injectOtelEnv(baseEnv: Record<string, string>, otel: AgentRequest["otel"] | undefined): Record<string, string>;
258
287
  export interface OpencodeOtelHeaders {
259
288
  provider: string;
260
- headers: {
261
- traceparent: string;
262
- "x-request-id": string;
263
- };
289
+ /** Never carries `x-request-id` — see the module doc comment's OUTBOUND OTEL + GATEWAY-HEADER PROPAGATION section. */
290
+ headers: Record<string, string>;
291
+ }
292
+ /** This call's own SPF identity, when the caller has it — see `data_types.ts`'s `AgentRequest.adw_id`/`agent_name` doc. Both optional; an absent one simply omits its header. */
293
+ export interface GatewayCallIdentity {
294
+ adwId?: string;
295
+ agentName?: string;
264
296
  }
265
297
  /**
266
298
  * 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 —
299
+ * `opencode.json` (see the module doc comment's OUTBOUND OTEL + GATEWAY-
300
+ * HEADER PROPAGATION section for why static values are correct here).
301
+ * Returns `null` when NEITHER `otel` nor `gateway` has anything to
302
+ * contribute, or when the model id carries no `provider/` prefix —
270
303
  * opencode's own `--model` vocabulary is documented as `provider/model-id`,
271
304
  * but a bare model name has no provider id this module could key headers
272
305
  * under, and guessing the wrong provider id would write a config block
273
306
  * opencode merges onto a DIFFERENT provider than the one being called.
307
+ * `x-correlation-id`/`x-spf-agent` are added whenever `gateway` supplies
308
+ * them, regardless of whether `otel` is configured — see
309
+ * `data_types.ts`'s `AgentRequest.adw_id` doc for why that pair isn't
310
+ * gated on `observability.otel` the way `traceparent` is.
274
311
  */
275
- export declare function otelProviderHeaders(model: string, otel: AgentRequest["otel"] | undefined): OpencodeOtelHeaders | null;
312
+ export declare function otelProviderHeaders(model: string, otel: AgentRequest["otel"] | undefined, gateway?: GatewayCallIdentity): OpencodeOtelHeaders | null;
276
313
  /**
277
314
  * Builds the JSON CONTENT of the temp `opencode.json`: a `permission` map
278
315
  * when `toolNames` is an array (including `[]` — restrict to exactly those,
@@ -70,32 +70,61 @@
70
70
  * documented gap in both guarantees, not a bug this module can paper over
71
71
  * from the outside.
72
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:
73
+ * OUTBOUND OTEL + GATEWAY-HEADER PROPAGATION [OFFICIAL config surface;
74
+ * best-effort behavior]: `request.otel` is present only when
75
+ * `observability.otel` is configured (`agents.ts`'s `send()`); `request.
76
+ * adw_id`/`request.agent_name` are present on EVERY call regardless (the
77
+ * gateway needs `x-correlation-id`/`x-spf-agent` whether or not SPF's own
78
+ * otel export is on — see `data_types.ts`'s `AgentRequest.adw_id` doc). This
79
+ * module propagates whatever subset of the two is present, two ways:
78
80
  *
79
81
  * 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;
82
+ * shape `agent_cc.ts` sets, ONLY when `otel` is present. No published
83
+ * statement confirms the opencode CLI itself reads it (UNVERIFIED
84
+ * either way; set for parity and for any opencode-spawned subprocess
85
+ * telemetry, at zero cost).
86
+ * 2. `traceparent` (when `otel` present) and `x-correlation-id`/
87
+ * `x-spf-agent` (when `adw_id`/`agent_name` present) as STATIC provider
88
+ * headers in the temp `opencode.json` (`provider.<id>.options.headers`
89
+ * — opencode's documented per-provider options surface;
86
90
  * https://opencode.ai/docs/providers/). Static values are CORRECT
87
91
  * 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.
92
+ * IS exactly one SPF agent call — none of these three headers can go
93
+ * stale mid-run. This is the header set that actually reaches the
94
+ * wire: opencode's provider requests flow through the AI SDK, which
95
+ * honors `options.headers` for that provider's requests. Subject to
96
+ * the CONFIG PRECEDENCE limitation above: a repo's own `opencode.json`
97
+ * can override these headers. `x-request-id` is NEVER one of them —
98
+ * Envoy/Switchyard own that header end-to-end; this module used to
99
+ * send it here (BLOCKER B) and no longer does.
94
100
  *
95
101
  * `injectOtelEnv()` / `otelProviderHeaders()` / `tempConfigContents()` are
96
102
  * exported pure functions so every fragment is unit-testable without
97
103
  * spawning a real subprocess — same discipline as the rest of this module.
98
104
  *
105
+ * MINOR-4 (deliberate, not an oversight): `otelProviderHeaders()` returns
106
+ * non-null — and so `tempConfigContents()` writes a real `provider` block —
107
+ * whenever EITHER `otel` OR `gateway.adwId`/`gateway.agentName` is present,
108
+ * and `data_types.ts`'s `AgentRequest.adw_id`/`agent_name` doc is explicit
109
+ * that those two are set by `agents.ts`'s `send()` from `run.adw_id`/
110
+ * `agent.name` UNCONDITIONALLY — every SPF run has an adw_id, every agent
111
+ * has a name — NOT gated on `observability.otel` being configured at all.
112
+ * The practical consequence: for any `opencode` agent whose `--model`
113
+ * carries a `provider/` prefix (opencode's own documented model-id shape;
114
+ * see `otelProviderHeaders`'s own doc for the bare-model-name exception),
115
+ * this module writes a temporary `opencode.json` (`mkdtempSync` + one
116
+ * `writeFileSync`, removed in `run()`'s `finally`) on EVERY invocation, even
117
+ * with `observability.otel` unconfigured and no `tools:` restriction in
118
+ * play — there is no "otel off, no gateway identity, no tools restriction"
119
+ * case left in practice once a run has an adw_id, which is always. This is
120
+ * KEPT, not gated behind an extra "only when otel/tools apply" check: the
121
+ * gateway needs `x-correlation-id`/`x-spf-agent` on every real call to group
122
+ * it by run (same rationale as `ollama_provider.ts`'s static per-model
123
+ * headers), independent of whether tracing is turned on, and a temp file
124
+ * per subprocess invocation is cheap relative to spawning that subprocess
125
+ * at all. A caller that truly wants zero config-file overhead has no lever
126
+ * for that today short of clearing `adw_id`/`agent_name` on the request.
127
+ *
99
128
  * OPERATOR-CONFIG MERGE: when a caller-provided `OPENCODE_CONFIG` already
100
129
  * exists in the base env (operatorEnv() passthrough or an agent's
101
130
  * env_allowlist) AND this module needs a temp config of its own (a tools:
@@ -413,21 +442,33 @@ export function injectOtelEnv(baseEnv, otel) {
413
442
  }
414
443
  /**
415
444
  * 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 —
445
+ * `opencode.json` (see the module doc comment's OUTBOUND OTEL + GATEWAY-
446
+ * HEADER PROPAGATION section for why static values are correct here).
447
+ * Returns `null` when NEITHER `otel` nor `gateway` has anything to
448
+ * contribute, or when the model id carries no `provider/` prefix —
419
449
  * opencode's own `--model` vocabulary is documented as `provider/model-id`,
420
450
  * but a bare model name has no provider id this module could key headers
421
451
  * under, and guessing the wrong provider id would write a config block
422
452
  * opencode merges onto a DIFFERENT provider than the one being called.
453
+ * `x-correlation-id`/`x-spf-agent` are added whenever `gateway` supplies
454
+ * them, regardless of whether `otel` is configured — see
455
+ * `data_types.ts`'s `AgentRequest.adw_id` doc for why that pair isn't
456
+ * gated on `observability.otel` the way `traceparent` is.
423
457
  */
424
- export function otelProviderHeaders(model, otel) {
425
- if (!otel)
426
- return null;
458
+ export function otelProviderHeaders(model, otel, gateway = {}) {
427
459
  const slash = model.indexOf("/");
428
460
  if (slash <= 0)
429
461
  return null;
430
- return { provider: model.slice(0, slash), headers: { traceparent: otel.traceparent, "x-request-id": otel.x_request_id } };
462
+ const headers = {};
463
+ if (otel)
464
+ headers.traceparent = otel.traceparent;
465
+ if (gateway.adwId)
466
+ headers["x-correlation-id"] = gateway.adwId;
467
+ if (gateway.agentName)
468
+ headers["x-spf-agent"] = gateway.agentName;
469
+ if (Object.keys(headers).length === 0)
470
+ return null;
471
+ return { provider: model.slice(0, slash), headers };
431
472
  }
432
473
  /**
433
474
  * Builds the JSON CONTENT of the temp `opencode.json`: a `permission` map
@@ -601,12 +642,12 @@ export async function run(request, onEvent, onSpawn, onExit) {
601
642
  const [cmd, ...cmdArgs] = cmdTokens;
602
643
  const fullArgs = [...cmdArgs, ...args];
603
644
  const baseEnv = request.env ?? operatorEnv();
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);
645
+ // `request.tools` null/undefined AND no otel config AND no adw_id/agent_name
646
+ // -> every tool, no config file written at all (see tempConfigContents'
647
+ // own doc comment). Otherwise -> a real temp opencode.json (tool
648
+ // restriction, gateway provider headers, or both), pointed at via
649
+ // OPENCODE_CONFIG on the CHILD's env only — never mutates process.env.
650
+ const otelHeaders = otelProviderHeaders(request.model, request.otel, { adwId: request.adw_id, agentName: request.agent_name });
610
651
  let configContents = tempConfigContents(request.tools, otelHeaders);
611
652
  // A caller-provided OPENCODE_CONFIG (operatorEnv() passthrough or an
612
653
  // agent's env_allowlist) is MERGED into the temp file, never replaced —
@@ -37,15 +37,56 @@ export declare class BudgetExceeded extends Error {
37
37
  * message that says `$0.00 of max_run_cost $0.40` tells nobody anything.
38
38
  */
39
39
  export declare function formatUsd(value: number): string;
40
+ /**
41
+ * True when THIS ONE AGENT, if dispatched right now, would report a
42
+ * gateway-estimated cost — a `claude_code` agent AND a non-Anthropic
43
+ * `ANTHROPIC_BASE_URL`. The per-agent primitive `Run.recordDispatch`
44
+ * (`runner.ts`) calls AT DISPATCH TIME, in `execute()` below, right before
45
+ * the real coding-agent call — so `run.cost_is_estimate` reflects what this
46
+ * run actually DISPATCHED, never what the roster merely makes possible (see
47
+ * `isGatewayEstimatedCost`'s own doc comment for why that distinction is
48
+ * the whole point of this function existing separately).
49
+ *
50
+ * `env` defaults to `process.env` (already carrying `cfg.env`'s own
51
+ * defaults — see `applyConfigEnv`, applied once at CLI startup before any
52
+ * `Run` is constructed) but is overridable so a test never touches the
53
+ * real environment.
54
+ */
55
+ export declare function isGatewayEstimatedDispatch(agent: AgentConfig, env?: Record<string, string | undefined>): boolean;
56
+ /**
57
+ * Whole-ROSTER check: true when ANY configured agent (dispatched or not)
58
+ * is a `claude_code` agent AND `ANTHROPIC_BASE_URL` is non-Anthropic.
59
+ *
60
+ * NOT what `Run.cost_is_estimate` is computed from — a chain can configure
61
+ * a `claude_code` agent it never actually dispatches this run (a
62
+ * conditional phase, a different `--agent` override, ...), and labeling a
63
+ * real, non-gateway cost as "estimated" because the ROSTER merely contains
64
+ * such an agent would be its own kind of dishonesty. `Run` instead starts
65
+ * `cost_is_estimate` at `false` and `recordDispatch()` (`runner.ts`, driven
66
+ * by `isGatewayEstimatedDispatch` above) flips it true only when a
67
+ * qualifying dispatch actually happens. This whole-roster version is kept
68
+ * as the general "could this config ever need the estimate label" check
69
+ * (`spf estimate`-shaped questions, and this file's own test suite) — never
70
+ * wire it back into the per-run label.
71
+ */
72
+ export declare function isGatewayEstimatedCost(cfg: SFConfig, env?: Record<string, string | undefined>): boolean;
40
73
  /**
41
74
  * The accumulating totals a budget check reads. Structurally a subset of
42
- * `Run` (`core/runner.ts`) — `run.tokens`/`run.cost` are incremented by
43
- * `run.addUsage()` after every send — so the real `Run` satisfies it with no
44
- * adapter, and a test can pass a plain object with fake usage.
75
+ * `Run` (`core/runner.ts`) — `run.tokens`/`run.cost`/`run.billable_tokens`
76
+ * are incremented by `run.addUsage()` after every send — so the real `Run`
77
+ * satisfies it with no adapter, and a test can pass a plain object with fake
78
+ * usage.
79
+ *
80
+ * `tokens` (the display total, cache reads included) is kept on this
81
+ * interface for structural parity with `Run` even though `assertRunBudget`
82
+ * itself no longer reads it — only `billable_tokens` does. See
83
+ * `UsageBreakdown.billable_tokens`'s doc comment (`data_types.ts`) for why
84
+ * the two diverge.
45
85
  */
46
86
  export interface RunBudgetState {
47
87
  cfg: SFConfig;
48
88
  tokens: number;
89
+ billable_tokens: number;
49
90
  cost: number;
50
91
  }
51
92
  /**
@@ -122,6 +163,8 @@ interface RunForAgents {
122
163
  /** Run-total tokens/cost so far, mirrored by `addUsage` below — read by `assertRunBudget` before every send. */
123
164
  tokens: number;
124
165
  cost: number;
166
+ /** The BILLABLE half of `tokens` — what `assertRunBudget` actually checks `max_run_tokens` against. See `UsageBreakdown.billable_tokens`'s doc comment. */
167
+ billable_tokens: number;
125
168
  repo_root: string;
126
169
  spf_dir: string | null;
127
170
  data_dir: string;
@@ -159,8 +202,12 @@ interface RunForAgents {
159
202
  retry: (name: string, attempt: number, limit: number, reason: string) => Promise<void>;
160
203
  envelopeSummary: (envelope: EnvelopeBase, typeName: string) => Promise<void>;
161
204
  agentFinished: (name: string, tokens: number, cost: number) => Promise<void>;
205
+ /** Free-form detail line — used here only to name a `defaults.read_only_ignore` restore so it's visible without failing the phase. */
206
+ note: (message: string) => Promise<void>;
162
207
  };
163
- addUsage: (tokens: number, cost: number) => Promise<void>;
208
+ addUsage: (tokens: number, cost: number, billableTokens: number) => Promise<void>;
209
+ /** See `Run.recordDispatch`'s own doc comment (`runner.ts`) and `isGatewayEstimatedDispatch` above — called once per real dispatch, before it happens. */
210
+ recordDispatch: (agent: AgentConfig) => void;
164
211
  saveAgentMap: (agent: string, entry: {
165
212
  session_id: string;
166
213
  model: string;
@@ -69,6 +69,68 @@ export function formatUsd(value) {
69
69
  const fixed = value.toFixed(3);
70
70
  return `$${fixed.endsWith("0") ? fixed.slice(0, -1) : fixed}`;
71
71
  }
72
+ /** Anthropic's own API — a `claude_code` agent talking to exactly this is billed honestly; anything else is a gateway/proxy standing in for it. */
73
+ const ANTHROPIC_DEFAULT_BASE_URL_RE = /^https:\/\/api\.anthropic\.com\/?$/i;
74
+ /**
75
+ * WHY THIS MATTERS: `agent_cc.ts`'s `run()` reports `final.total_cost_usd`
76
+ * (the `claude` CLI's OWN number) into `UsageBreakdown.total_cost` verbatim
77
+ * — that figure is Anthropic's price table applied to whichever model the
78
+ * CLI thinks it called, computed CLIENT-SIDE with no visibility into what
79
+ * actually served the request. Pointed at a gateway (Ollama Cloud, a
80
+ * Cloudflare AI Gateway proxy, ...) fronting a different provider/billing
81
+ * model entirely (subscription, flat per-token gateway price, ...), that
82
+ * number is a plausible-looking ESTIMATE of what Anthropic would have
83
+ * charged for this many tokens — not a fact about what was actually billed.
84
+ * `Console.sessionFinished` reads this to change the run summary's "cost"
85
+ * line from a bare dollar figure to a labeled estimate rather than silently
86
+ * presenting a guess as ground truth.
87
+ */
88
+ function usesNonAnthropicGateway(env) {
89
+ const baseUrl = env["ANTHROPIC_BASE_URL"];
90
+ if (!baseUrl || !baseUrl.trim())
91
+ return false;
92
+ return !ANTHROPIC_DEFAULT_BASE_URL_RE.test(baseUrl.trim());
93
+ }
94
+ /**
95
+ * True when THIS ONE AGENT, if dispatched right now, would report a
96
+ * gateway-estimated cost — a `claude_code` agent AND a non-Anthropic
97
+ * `ANTHROPIC_BASE_URL`. The per-agent primitive `Run.recordDispatch`
98
+ * (`runner.ts`) calls AT DISPATCH TIME, in `execute()` below, right before
99
+ * the real coding-agent call — so `run.cost_is_estimate` reflects what this
100
+ * run actually DISPATCHED, never what the roster merely makes possible (see
101
+ * `isGatewayEstimatedCost`'s own doc comment for why that distinction is
102
+ * the whole point of this function existing separately).
103
+ *
104
+ * `env` defaults to `process.env` (already carrying `cfg.env`'s own
105
+ * defaults — see `applyConfigEnv`, applied once at CLI startup before any
106
+ * `Run` is constructed) but is overridable so a test never touches the
107
+ * real environment.
108
+ */
109
+ export function isGatewayEstimatedDispatch(agent, env = process.env) {
110
+ return agent.coding_agent === "claude_code" && usesNonAnthropicGateway(env);
111
+ }
112
+ /**
113
+ * Whole-ROSTER check: true when ANY configured agent (dispatched or not)
114
+ * is a `claude_code` agent AND `ANTHROPIC_BASE_URL` is non-Anthropic.
115
+ *
116
+ * NOT what `Run.cost_is_estimate` is computed from — a chain can configure
117
+ * a `claude_code` agent it never actually dispatches this run (a
118
+ * conditional phase, a different `--agent` override, ...), and labeling a
119
+ * real, non-gateway cost as "estimated" because the ROSTER merely contains
120
+ * such an agent would be its own kind of dishonesty. `Run` instead starts
121
+ * `cost_is_estimate` at `false` and `recordDispatch()` (`runner.ts`, driven
122
+ * by `isGatewayEstimatedDispatch` above) flips it true only when a
123
+ * qualifying dispatch actually happens. This whole-roster version is kept
124
+ * as the general "could this config ever need the estimate label" check
125
+ * (`spf estimate`-shaped questions, and this file's own test suite) — never
126
+ * wire it back into the per-run label.
127
+ */
128
+ export function isGatewayEstimatedCost(cfg, env = process.env) {
129
+ const usesClaudeCode = cfg.agents.some((a) => a.coding_agent === "claude_code");
130
+ if (!usesClaudeCode)
131
+ return false;
132
+ return usesNonAnthropicGateway(env);
133
+ }
72
134
  /**
73
135
  * Throw if this run has spent its budget. Called BEFORE every agent
74
136
  * dispatch, never after.
@@ -92,8 +154,8 @@ export function assertRunBudget(run) {
92
154
  throw new BudgetExceeded(`run budget exceeded: ${formatUsd(run.cost)} of max_run_cost ${formatUsd(maxCost)} — ` +
93
155
  `raise defaults.max_run_cost or split the work`);
94
156
  }
95
- if (maxTokens !== undefined && run.tokens >= maxTokens) {
96
- throw new BudgetExceeded(`run budget exceeded: ${run.tokens.toLocaleString("en-US")} tokens of max_run_tokens ` +
157
+ if (maxTokens !== undefined && run.billable_tokens >= maxTokens) {
158
+ throw new BudgetExceeded(`run budget exceeded: ${run.billable_tokens.toLocaleString("en-US")} tokens of max_run_tokens ` +
97
159
  `${maxTokens.toLocaleString("en-US")} — raise defaults.max_run_tokens or split the work`);
98
160
  }
99
161
  }
@@ -833,8 +895,11 @@ export async function execute(run, phase, call) {
833
895
  output_type_name: call.output_type.name,
834
896
  cwd: spec ? spec.workspace_dir : run.repo_root,
835
897
  flue_db_path: path.join(run.data_dir, "flue.db"),
898
+ request_timeout_ms: run.cfg.defaults.request_timeout_ms,
836
899
  env: agentEnv(agent),
837
900
  sandbox: spec,
901
+ adw_id: run.adw_id,
902
+ agent_name: agent.name,
838
903
  otel: otelCtx && otelBlock
839
904
  ? {
840
905
  traceparent: otelCtx.traceparent,
@@ -854,6 +919,9 @@ export async function execute(run, phase, call) {
854
919
  const onExit = (pid) => void run.tracer.processEnd(run.adw_id, pid).catch(() => { });
855
920
  if (spec)
856
921
  await sandbox.reconcileWorkspace(spec);
922
+ // Recorded right before the real dispatch, not derived from the roster
923
+ // up front — see `isGatewayEstimatedDispatch`'s doc comment for why.
924
+ run.recordDispatch(agent);
857
925
  let result;
858
926
  if (agent.coding_agent === "claude_code") {
859
927
  result = await agentCc.run(request, forward, onSpawn, onExit);
@@ -866,7 +934,7 @@ export async function execute(run, phase, call) {
866
934
  }
867
935
  // SPEND IS RECORDED BEFORE THE EXTRACT CAN THROW: a failed extract must
868
936
  // not also lose this call's tokens/cost off the Run's ledger.
869
- await run.addUsage(result.tokens, result.cost);
937
+ await run.addUsage(result.tokens, result.cost, result.usage.billable_tokens);
870
938
  spent.merge(result.usage);
871
939
  // opencode-ONLY: every subsequent send() in THIS phase (JSON-repair
872
940
  // retries, gate corrections) must target the real captured session, not
@@ -934,7 +1002,14 @@ export async function execute(run, phase, call) {
934
1002
  // wrote somewhere it was not allowed to.
935
1003
  let touched;
936
1004
  try {
937
- touched = permissions.enforce(run, phase, agent, treeBefore);
1005
+ touched = permissions.enforce(run, phase, agent, treeBefore, (ignoredPaths) => {
1006
+ // Fire-and-forget, same discipline as onSpawn/onExit above: this is a
1007
+ // logging side effect off the main control flow, not something a
1008
+ // failed write here should turn into an unhandled rejection.
1009
+ void run.console
1010
+ .note(`${agent.name}: restored and ignored dependency-lockfile churn (defaults.read_only_ignore) without failing the phase: ${ignoredPaths.join(", ")}`)
1011
+ .catch(() => { });
1012
+ });
938
1013
  }
939
1014
  catch (breach) {
940
1015
  await run.tracer.event(makeEventRecord({
@@ -27,7 +27,15 @@ interface Tracer {
27
27
  export interface RunObserver {
28
28
  onPhaseStart?(phase: Phase): void;
29
29
  onPhaseEnd?(phase: Phase, seconds: number): void;
30
- onUsage?(tokens: number, cost: number): void;
30
+ /**
31
+ * `tokens` is the DISPLAY total (every re-sent token, cache reads
32
+ * included — context occupancy); `billableTokens` is what
33
+ * `defaults.max_run_tokens` actually checks (see
34
+ * `UsageBreakdown.billable_tokens`'s doc comment in `data_types.ts`). A
35
+ * dashboard comparing spend against the ceiling must compare
36
+ * `billableTokens`, never `tokens` — see `run_dashboard.tsx`.
37
+ */
38
+ onUsage?(tokens: number, cost: number, billableTokens: number): void;
31
39
  onSessionEnd?(ok: boolean): void;
32
40
  }
33
41
  /** Bound to one run's tracer. Reachable as `run.console` everywhere. */
@@ -71,13 +79,25 @@ export declare class Console {
71
79
  observer?: RunObserver | null);
72
80
  private emit;
73
81
  sessionStarted(adwId: string, engineer: string): Promise<void>;
74
- sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): Promise<void>;
82
+ /**
83
+ * `costIsEstimate` (default `false`, byte-identical to before this param
84
+ * existed): true when `run.cost_is_estimate` found a `claude_code` agent
85
+ * pointed at a non-Anthropic `ANTHROPIC_BASE_URL` (see `runner.ts`'s
86
+ * `Run.recordDispatch` / `agents.ts`'s `isGatewayEstimatedDispatch`) —
87
+ * `total_cost_usd` from `claude`'s own CLI is
88
+ * Anthropic's price table applied client-side, which is a fact only when
89
+ * Anthropic itself served the request, and a labeled guess otherwise. The
90
+ * label is cosmetic only: `cost` itself is unchanged (still the real sum
91
+ * `UsageBreakdown.total_cost` accumulated), and nothing about the budget
92
+ * check (`assertRunBudget`) reads this flag.
93
+ */
94
+ sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string, costIsEstimate?: boolean): Promise<void>;
75
95
  phaseStarted(phase: Phase): Promise<void>;
76
96
  phaseEnded(phase: Phase, seconds: number): Promise<void>;
77
97
  /** Free-form detail inside the current phase — what `ph.log()` recorded. */
78
98
  note(message: string): Promise<void>;
79
- /** `Run.addUsage()`'s only hook into `Console` — the running total lives on `Run`, not here, so this just forwards it to the observer. No line prints for this on its own; the totals already show up in `sessionFinished`'s panel. */
80
- notifyUsage(tokens: number, cost: number): Promise<void>;
99
+ /** `Run.addUsage()`'s only hook into `Console` — the running totals live on `Run`, not here, so this just forwards them to the observer. No line prints for this on its own; the totals already show up in `sessionFinished`'s panel. `billableTokens` rides alongside `tokens` so a consumer comparing against `defaults.max_run_tokens` (a billable ceiling) never has to guess which number to use — see `RunObserver.onUsage`'s own doc comment. */
100
+ notifyUsage(tokens: number, cost: number, billableTokens: number): Promise<void>;
81
101
  agentStarted(name: string, model: string, sessionId: string): Promise<void>;
82
102
  agentFinished(name: string, tokens: number, cost: number): Promise<void>;
83
103
  retry(name: string, attempt: number, limit: number, reason: string): Promise<void>;