@desplega.ai/agent-swarm 1.79.4 → 1.80.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 (130) hide show
  1. package/openapi.json +496 -32
  2. package/package.json +14 -6
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +102 -31
  5. package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
  6. package/src/be/migrations/064_scripts.sql +39 -0
  7. package/src/be/migrations/065_script_embeddings.sql +7 -0
  8. package/src/be/pricing-normalize.ts +81 -0
  9. package/src/be/scripts/db.ts +391 -0
  10. package/src/be/scripts/embeddings.ts +231 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +193 -0
  13. package/src/be/seed-pricing.ts +293 -0
  14. package/src/cli.tsx +22 -5
  15. package/src/commands/artifact.ts +3 -2
  16. package/src/commands/claude-managed-setup.ts +21 -4
  17. package/src/commands/codex-login.ts +5 -3
  18. package/src/commands/onboard.tsx +2 -1
  19. package/src/commands/runner.ts +663 -246
  20. package/src/commands/setup.tsx +5 -3
  21. package/src/hooks/hook.ts +4 -3
  22. package/src/http/context.ts +6 -2
  23. package/src/http/index.ts +126 -68
  24. package/src/http/memory.ts +28 -0
  25. package/src/http/openapi.ts +1 -0
  26. package/src/http/page-proxy.ts +2 -1
  27. package/src/http/route-def.ts +1 -0
  28. package/src/http/schedules.ts +37 -0
  29. package/src/http/scripts.ts +381 -0
  30. package/src/http/session-data.ts +74 -23
  31. package/src/linear/outbound.ts +9 -2
  32. package/src/otel-impl.ts +200 -0
  33. package/src/otel.ts +132 -0
  34. package/src/providers/claude-adapter.ts +52 -6
  35. package/src/providers/claude-managed-adapter.ts +43 -17
  36. package/src/providers/claude-managed-pricing.ts +34 -0
  37. package/src/providers/codex-adapter.ts +38 -27
  38. package/src/providers/codex-models.ts +22 -3
  39. package/src/providers/devin-adapter.ts +11 -0
  40. package/src/providers/opencode-adapter.ts +31 -7
  41. package/src/providers/pi-mono-adapter.ts +39 -7
  42. package/src/providers/pricing-sources.md +52 -0
  43. package/src/providers/swarm-events-shared.ts +8 -4
  44. package/src/providers/types.ts +33 -10
  45. package/src/scripts-runtime/ctx.ts +23 -0
  46. package/src/scripts-runtime/eval-harness.ts +39 -0
  47. package/src/scripts-runtime/executors/native.ts +229 -0
  48. package/src/scripts-runtime/executors/registry.ts +16 -0
  49. package/src/scripts-runtime/executors/types.ts +63 -0
  50. package/src/scripts-runtime/extract-signature.ts +81 -0
  51. package/src/scripts-runtime/import-allowlist.ts +109 -0
  52. package/src/scripts-runtime/loader.ts +96 -0
  53. package/src/scripts-runtime/redacted.ts +48 -0
  54. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  55. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  56. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  57. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  58. package/src/scripts-runtime/stdlib/index.ts +16 -0
  59. package/src/scripts-runtime/stdlib/table.ts +17 -0
  60. package/src/scripts-runtime/swarm-config.ts +35 -0
  61. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  62. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  63. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  64. package/src/server.ts +18 -0
  65. package/src/tests/api-key.test.ts +33 -0
  66. package/src/tests/claude-managed-adapter.test.ts +17 -3
  67. package/src/tests/claude-managed-setup.test.ts +10 -1
  68. package/src/tests/codex-adapter.test.ts +20 -19
  69. package/src/tests/codex-login.test.ts +1 -1
  70. package/src/tests/context-snapshot.test.ts +2 -2
  71. package/src/tests/context-window.test.ts +65 -1
  72. package/src/tests/devin-adapter.test.ts +2 -0
  73. package/src/tests/http/context-routes.test.ts +161 -0
  74. package/src/tests/linear-outbound-sync.test.ts +109 -0
  75. package/src/tests/mcp-tools.test.ts +69 -0
  76. package/src/tests/migration-063-schema-relax.test.ts +109 -0
  77. package/src/tests/opencode-adapter.test.ts +146 -1
  78. package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
  79. package/src/tests/pages-view-count.test.ts +30 -5
  80. package/src/tests/providers/codex-cost.test.ts +18 -0
  81. package/src/tests/providers/opencode-cost.test.ts +74 -0
  82. package/src/tests/providers/pi-cost.test.ts +128 -0
  83. package/src/tests/redacted.test.ts +29 -0
  84. package/src/tests/runner-tool-spans.test.ts +268 -0
  85. package/src/tests/script-executor-conformance.test.ts +142 -0
  86. package/src/tests/script-executor-registry.test.ts +17 -0
  87. package/src/tests/scripts-db.test.ts +329 -0
  88. package/src/tests/scripts-embeddings.test.ts +291 -0
  89. package/src/tests/scripts-extract-signature.test.ts +47 -0
  90. package/src/tests/scripts-http.test.ts +350 -0
  91. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  92. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  93. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  94. package/src/tests/scripts-runtime.test.ts +289 -0
  95. package/src/tests/sdk-allowlist.test.ts +59 -0
  96. package/src/tests/secret-scrubber.test.ts +54 -1
  97. package/src/tests/session-costs-codex-recompute.test.ts +35 -22
  98. package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
  99. package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
  100. package/src/tests/store-progress-cost.test.ts +6 -1
  101. package/src/tests/swarm-config.test.ts +38 -0
  102. package/src/tests/tool-annotations.test.ts +2 -2
  103. package/src/tests/tool-call-progress.test.ts +30 -0
  104. package/src/tests/workflow-e2e.test.ts +218 -0
  105. package/src/tests/workflow-executors.test.ts +32 -2
  106. package/src/tests/workflow-input-redaction.test.ts +232 -0
  107. package/src/tests/workflow-swarm-script.test.ts +273 -0
  108. package/src/tools/memory-rate.ts +2 -1
  109. package/src/tools/script-common.ts +88 -0
  110. package/src/tools/script-delete.ts +35 -0
  111. package/src/tools/script-query-types.ts +37 -0
  112. package/src/tools/script-run.ts +43 -0
  113. package/src/tools/script-search.ts +32 -0
  114. package/src/tools/script-upsert.ts +43 -0
  115. package/src/tools/store-progress.ts +16 -60
  116. package/src/tools/tool-config.ts +7 -0
  117. package/src/tools/utils.ts +65 -12
  118. package/src/types.ts +122 -10
  119. package/src/utils/api-key.ts +28 -0
  120. package/src/utils/context-window.ts +104 -4
  121. package/src/utils/page-session.ts +8 -6
  122. package/src/utils/secret-scrubber.ts +29 -1
  123. package/src/workflows/engine.ts +12 -4
  124. package/src/workflows/executors/index.ts +1 -0
  125. package/src/workflows/executors/registry.ts +2 -0
  126. package/src/workflows/executors/script.ts +12 -1
  127. package/src/workflows/executors/swarm-script.ts +170 -0
  128. package/src/workflows/input.ts +65 -0
  129. package/src/workflows/recovery.ts +31 -3
  130. package/src/workflows/resume.ts +43 -5
@@ -66,6 +66,11 @@ import {
66
66
  type WebSearchItem,
67
67
  } from "@openai/codex-sdk";
68
68
  import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/memory/raters/llm";
69
+ import {
70
+ CONTEXT_FORMULA,
71
+ clampContextPercent,
72
+ computeContextUsedUnified,
73
+ } from "../utils/context-window";
69
74
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
70
75
  import { scrubSecrets } from "../utils/secret-scrubber";
71
76
  import { type CodexAgentsMdHandle, writeCodexAgentsMd } from "./codex-agents-md";
@@ -523,6 +528,11 @@ class CodexSession implements ProviderSession {
523
528
  const inputTokens = usage?.input_tokens ?? 0;
524
529
  const cachedInputTokens = usage?.cached_input_tokens ?? 0;
525
530
  const outputTokens = usage?.output_tokens ?? 0;
531
+ // Phase 6: Codex SDK surfaces `reasoning_output_tokens` separately from
532
+ // `output_tokens` for reasoning models (gpt-5.3-codex, gpt-5.4 thinking).
533
+ // Pre-fix this number was read into `lastUsage` but never reached
534
+ // `CostData`, so reasoning-heavy sessions silently under-billed.
535
+ const reasoningOutputTokens = usage?.reasoning_output_tokens ?? 0;
526
536
  return {
527
537
  // Runner overrides with its own session id.
528
538
  sessionId: "",
@@ -540,9 +550,12 @@ class CodexSession implements ProviderSession {
540
550
  ),
541
551
  inputTokens,
542
552
  outputTokens,
553
+ reasoningOutputTokens,
543
554
  cacheReadTokens: cachedInputTokens,
544
- // Codex does not distinguish cache writes in its Usage payload.
545
- cacheWriteTokens: 0,
555
+ // Phase 6: undefined (NOT 0). Codex SDK can't honestly report cache
556
+ // writes; leaving it undefined preserves that distinction in the DB
557
+ // instead of mixing genuine zeros with "unknown".
558
+ cacheWriteTokens: undefined,
546
559
  durationMs: Date.now() - this.startedAt,
547
560
  numTurns: this.numTurns,
548
561
  model: this.resolvedModel,
@@ -760,36 +773,34 @@ class CodexSession implements ProviderSession {
760
773
  case "turn.completed": {
761
774
  this.lastUsage = event.usage;
762
775
  if (event.usage) {
763
- // The Codex SDK reports `input_tokens` as the SUM of every prompt
764
- // sent to the model across the entire turn (one `codex exec` call
765
- // can fan out to dozens of model invocations as MCP tools roundtrip
766
- // back and forth). For chatty turns this number routinely exceeds
767
- // the model's context window, even though no single model call did.
776
+ // Phase 9: switch from the codex-specific "peak proxy" formula
777
+ // (`uncached_input + output`) to the unified
778
+ // `input + cache_read + cache_create + output` so cross-provider
779
+ // percent comparisons are meaningful.
768
780
  //
769
- // For peak-context reporting we want a proxy for "the largest
770
- // single-call prompt". We approximate it as the uncached portion
771
- // (cached tokens are reused across calls so they count once toward
772
- // the actual peak), plus the output. This isn't perfect — the SDK
773
- // would have to expose per-call stats for thatbut it's far more
774
- // representative than `(input + output) / window` which clamps to
775
- // 1.0 the moment a turn makes any meaningful tool history.
776
- const uncachedInput = Math.max(
777
- 0,
778
- event.usage.input_tokens - event.usage.cached_input_tokens,
779
- );
780
- const peakProxy = uncachedInput + event.usage.output_tokens;
781
- // `contextPercent` is on a 0-100 scale across all providers — claude
782
- // emits `(used / total) * 100`, pi-mono passes through `usage.percent`
783
- // which is already 0-100. The dashboard at
784
- // ui/src/pages/tasks/[id]/page.tsx renders it via `.toFixed(0)`
785
- // expecting an integer percent, so a 0-1 fraction would render as
786
- // "0%" instead of e.g. "40%".
781
+ // Note: Codex's `input_tokens` already includes cached_input_tokens
782
+ // (it's the TOTAL across the turn — see the longer comment that
783
+ // used to live here, preserved in git history). We therefore pass
784
+ // `cacheReadTokens: 0` to avoid double-counting the cached portion.
785
+ // The trade-off the old comment flagged is still real a chatty
786
+ // turn can over-report because `input_tokens` is the SUM across
787
+ // every model call in the turn but having the SAME formula
788
+ // everywhere wins over the local optimum. Clamp catches the
789
+ // chatty-turn overshoot at 100%. Old rows tagged 'peak-proxy'
790
+ // remain in `task_context_snapshots`; the UI surfaces both.
791
+ const contextUsed = computeContextUsedUnified({
792
+ inputTokens: event.usage.input_tokens,
793
+ cacheReadTokens: 0,
794
+ cacheCreateTokens: 0,
795
+ outputTokens: event.usage.output_tokens,
796
+ });
787
797
  this.emit({
788
798
  type: "context_usage",
789
- contextUsedTokens: peakProxy,
799
+ contextUsedTokens: contextUsed,
790
800
  contextTotalTokens: this.contextWindow,
791
- contextPercent: Math.min(100, (peakProxy / this.contextWindow) * 100),
801
+ contextPercent: clampContextPercent(contextUsed, this.contextWindow) ?? 0,
792
802
  outputTokens: event.usage.output_tokens,
803
+ contextFormula: CONTEXT_FORMULA,
793
804
  });
794
805
  }
795
806
  break;
@@ -126,12 +126,22 @@ export const CODEX_MODEL_PRICING: Record<CodexModel, CodexModelPricing> = {
126
126
  },
127
127
  };
128
128
 
129
+ /**
130
+ * Phase 6 — one-warning-per-process tracking so unknown models log once
131
+ * instead of spamming the worker log on every turn.
132
+ */
133
+ const _warnedUnknownCodexModels = new Set<string>();
134
+
129
135
  /**
130
136
  * Compute USD cost from a Codex `Usage` payload. The Codex SDK reports
131
137
  * `input_tokens` as the TOTAL input fed to the model across the turn (cached
132
138
  * + uncached), so we subtract `cached_input_tokens` before billing the
133
- * uncached portion at the full rate. Returns 0 for unknown models so we never
134
- * inflate cost on a typo.
139
+ * uncached portion at the full rate.
140
+ *
141
+ * Phase 6: returns 0 for unknown models AND logs a one-time warning, so an
142
+ * operator running `MODEL_OVERRIDE=gpt-future-2027` notices that the worker
143
+ * is silently dropping cost. The server-side recompute path (Phase 2) tags
144
+ * such rows `costSource='unpriced'`, which surfaces as a yellow UI badge.
135
145
  */
136
146
  export function computeCodexCostUsd(
137
147
  model: string,
@@ -140,7 +150,16 @@ export function computeCodexCostUsd(
140
150
  outputTokens: number,
141
151
  ): number {
142
152
  const pricing = CODEX_MODEL_PRICING[model as CodexModel];
143
- if (!pricing) return 0;
153
+ if (!pricing) {
154
+ if (!_warnedUnknownCodexModels.has(model)) {
155
+ _warnedUnknownCodexModels.add(model);
156
+ console.warn(
157
+ `[codex] unpriced model ${JSON.stringify(model)} — adapter cost will report $0; ` +
158
+ "server-side recompute will tag costSource='unpriced' if the pricing table has no rows.",
159
+ );
160
+ }
161
+ return 0;
162
+ }
144
163
  const uncachedInput = Math.max(0, inputTokens - cachedInputTokens);
145
164
  const inputCost = (uncachedInput / 1_000_000) * pricing.inputPerMillion;
146
165
  const cachedCost = (cachedInputTokens / 1_000_000) * pricing.cachedInputPerMillion;
@@ -279,6 +279,12 @@ class DevinSession implements ProviderSession {
279
279
  if (this.settled || this.aborted) return;
280
280
  this.pollCount += 1;
281
281
 
282
+ // Phase 8: Devin's session API does NOT report per-poll context-window
283
+ // info (the model is fully managed by Devin). We deliberately don't emit
284
+ // a synthetic `context_usage` event here — faking one with `contextUsedTokens=0`
285
+ // would be misleading. `peakContextTokens` stays null for devin tasks,
286
+ // which the UI surfaces as "not available" rather than "0".
287
+
282
288
  let response: DevinSessionResponse;
283
289
  try {
284
290
  response = await getSession(this.orgId, this.devinApiKey, this._sessionId!);
@@ -788,6 +794,11 @@ class DevinSession implements ProviderSession {
788
794
  numTurns: this.pollCount,
789
795
  model: "devin",
790
796
  isError,
797
+ // Phase 3 — tag CostData so the API recompute path engages. Devin's
798
+ // pricing is ACU-based (one row under `provider='devin', model='*',
799
+ // token_class='acu'`); the harness USD value above is already correct,
800
+ // but tagging the row exposes its source to the UI badge.
801
+ provider: "devin",
791
802
  };
792
803
  }
793
804
  }
@@ -12,7 +12,11 @@ import { existsSync, mkdirSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import type { AssistantMessage, Config, Event as OpencodeEvent } from "@opencode-ai/sdk";
14
14
  import { createOpencode } from "@opencode-ai/sdk";
15
- import { getContextWindowSize } from "../utils/context-window";
15
+ import {
16
+ CONTEXT_FORMULA,
17
+ clampContextPercent,
18
+ getContextWindowSize,
19
+ } from "../utils/context-window";
16
20
  import { validateOpencodeCredentials } from "../utils/credentials";
17
21
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
18
22
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -104,7 +108,7 @@ function resolvePluginPath(): string {
104
108
  return join(import.meta.dir, "../../plugin/opencode-plugins/agent-swarm.ts");
105
109
  }
106
110
 
107
- class OpencodeSession implements ProviderSession {
111
+ export class OpencodeSession implements ProviderSession {
108
112
  private _sessionId: string;
109
113
  private listeners: Array<(event: ProviderEvent) => void> = [];
110
114
  // Buffer for events emitted before any listener is attached.
@@ -115,6 +119,7 @@ class OpencodeSession implements ProviderSession {
115
119
  // leaving agent_tasks.provider/.model NULL. Buffer + flush on first attach.
116
120
  private pendingEvents: ProviderEvent[] = [];
117
121
  private completionResolve!: (result: ProviderResult) => void;
122
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: reserved for future error-propagation paths; symmetric with completionResolve.
118
123
  private completionReject!: (err: Error) => void;
119
124
  private completionPromise: Promise<ProviderResult>;
120
125
  private server: { url: string; close(): void };
@@ -237,6 +242,15 @@ class OpencodeSession implements ProviderSession {
237
242
  case "message.updated": {
238
243
  const msg = ev.properties.info;
239
244
  if (!isAssistantMessage(msg) || msg.sessionID !== this._sessionId) break;
245
+ // Phase 9 fix: opencode fires `message.updated` repeatedly during a single
246
+ // assistant turn (streaming text deltas, tool transitions, etc.) and only
247
+ // populates `tokens`/`cost` on the FINAL update once `time.completed` is
248
+ // set. Accumulating on every event would either no-op (zero tokens) or —
249
+ // if opencode ever back-fills intermediate snapshots — multi-count. Gate
250
+ // the accumulator AND the context emit on the finalized signal so both
251
+ // paths see the same canonical "this turn is done" moment.
252
+ const messageFinalized = msg.time?.completed != null;
253
+ if (!messageFinalized) break;
240
254
  // Accumulate cost from each completed assistant message ("step")
241
255
  this.totalCostUsd += msg.cost;
242
256
  this.inputTokens += msg.tokens?.input ?? 0;
@@ -247,21 +261,31 @@ class OpencodeSession implements ProviderSession {
247
261
  if (!this.model && msg.modelID) this.model = msg.modelID;
248
262
 
249
263
  // Emit context_usage so the runner can POST /api/tasks/:id/context
250
- // (drives the dashboard's context-usage progress bar) and the
251
- // dashboard's activity timeline shows per-turn progress.
264
+ // (drives the dashboard's context-usage progress bar). The runner-side
265
+ // throttle (CONTEXT_THROTTLE_MS = 30s) means the FIRST emit wins for any
266
+ // short task — so this MUST carry real numbers, not the zero-tokens
267
+ // placeholder opencode sends on intermediate streaming updates. The
268
+ // `time.completed` gate above (in the accumulator block) guarantees we
269
+ // only land here for finalized messages.
252
270
  const turnInput = msg.tokens?.input ?? 0;
253
271
  const turnOutput = msg.tokens?.output ?? 0;
254
272
  const turnCacheRead = msg.tokens?.cache?.read ?? 0;
255
273
  const turnCacheWrite = msg.tokens?.cache?.write ?? 0;
256
- const contextUsed = turnInput + turnCacheRead + turnCacheWrite;
274
+ // Phase 8 + Phase 9: unified `input + cache + output` formula instead
275
+ // of the previous `input + cache_read + cache_write` (which omitted
276
+ // output and slightly mis-counted vs every other adapter).
277
+ const contextUsed = turnInput + turnCacheRead + turnCacheWrite + turnOutput;
257
278
  const contextTotal = getContextWindowSize(this.model || msg.modelID || "default");
258
- if (contextTotal > 0) {
279
+ if (contextTotal > 0 && contextUsed > 0) {
259
280
  this.emit({
260
281
  type: "context_usage",
261
282
  contextUsedTokens: contextUsed,
262
283
  contextTotalTokens: contextTotal,
263
- contextPercent: (contextUsed / contextTotal) * 100,
284
+ // Phase 8: clamp so a turn that briefly overshoots (e.g. due to
285
+ // a stale total) doesn't render as a 130% gauge in the UI.
286
+ contextPercent: clampContextPercent(contextUsed, contextTotal) ?? 0,
264
287
  outputTokens: turnOutput,
288
+ contextFormula: CONTEXT_FORMULA,
265
289
  });
266
290
  }
267
291
  break;
@@ -264,7 +264,7 @@ function cleanupAgentsMdSymlink(cwd: string): void {
264
264
  }
265
265
  }
266
266
 
267
- class PiMonoSession implements ProviderSession {
267
+ export class PiMonoSession implements ProviderSession {
268
268
  private listeners: Array<(event: ProviderEvent) => void> = [];
269
269
  private eventQueue: ProviderEvent[] = [];
270
270
  private _sessionId: string | undefined;
@@ -275,6 +275,14 @@ class PiMonoSession implements ProviderSession {
275
275
  private logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
276
276
  /** Track last emitted message text to avoid duplicates across turns */
277
277
  private lastEmittedMessage = "";
278
+ /** Phase 7: wallclock start so we can populate `durationMs` on the cost row. */
279
+ private sessionStartedAt: number = Date.now();
280
+ /**
281
+ * Phase 7: previous output-token total — used to derive per-turn delta for
282
+ * `context_usage.outputTokens` since pi-ai's `getContextUsage()` doesn't
283
+ * surface it directly.
284
+ */
285
+ private prevOutputTokens = 0;
278
286
 
279
287
  constructor(agentSession: AgentSession, config: ProviderSessionConfig, createdSymlink: boolean) {
280
288
  this.agentSession = agentSession;
@@ -282,6 +290,7 @@ class PiMonoSession implements ProviderSession {
282
290
  this.createdSymlink = createdSymlink;
283
291
  this.logFileHandle = Bun.file(config.logFile).writer();
284
292
  this._sessionId = agentSession.sessionId;
293
+ this.sessionStartedAt = Date.now();
285
294
 
286
295
  // Emit session_init immediately
287
296
  this.emit({ type: "session_init", sessionId: this._sessionId, provider: "pi" });
@@ -293,6 +302,18 @@ class PiMonoSession implements ProviderSession {
293
302
  this.completionPromise = this.runSession();
294
303
  }
295
304
 
305
+ /**
306
+ * Canonical model slug for downstream reporting (latestModel, raw_log envelopes).
307
+ * Composes `${provider}/${id}` from the resolved pi-ai model so the UI snapshot
308
+ * lookup matches (e.g. `openrouter/deepseek/deepseek-v4-flash`). Falls back to
309
+ * the configured model string if the session didn't resolve one.
310
+ */
311
+ private reportedModel(): string {
312
+ const m = this.agentSession.model;
313
+ if (m) return `${m.provider}/${m.id}`;
314
+ return this.config.model;
315
+ }
316
+
296
317
  private emit(event: ProviderEvent): void {
297
318
  // Scrub secrets from raw_log / raw_stderr content before egress (log file
298
319
  // write, listener dispatch, downstream session-logs push + pretty-print).
@@ -329,7 +350,7 @@ class PiMonoSession implements ProviderSession {
329
350
  .trim()
330
351
  : String(msg.content || "").trim();
331
352
  if (text && text !== this.lastEmittedMessage) {
332
- const model = this.agentSession.model?.name ?? this.config.model;
353
+ const model = this.reportedModel();
333
354
  this.emit({
334
355
  type: "raw_log",
335
356
  content: JSON.stringify({
@@ -344,21 +365,30 @@ class PiMonoSession implements ProviderSession {
344
365
  this.lastEmittedMessage = text;
345
366
  }
346
367
  }
347
- // Emit context_usage for dashboard tracking
368
+ // Emit context_usage for dashboard tracking.
369
+ // Phase 7: derive `outputTokens` from `SessionStats` delta (pi-ai's
370
+ // `getContextUsage()` doesn't expose per-turn output tokens, but the
371
+ // session-stats counter is monotonic so a delta is correct).
348
372
  const usage = this.agentSession.getContextUsage();
349
373
  if (usage && usage.tokens != null) {
374
+ const stats = this.agentSession.getSessionStats();
375
+ const currOutput = stats?.tokens?.output ?? 0;
376
+ const outputDelta = Math.max(0, currOutput - this.prevOutputTokens);
377
+ this.prevOutputTokens = currOutput;
350
378
  this.emit({
351
379
  type: "context_usage",
352
380
  contextUsedTokens: usage.tokens,
353
381
  contextTotalTokens: usage.contextWindow,
354
382
  contextPercent: usage.percent ?? 0,
355
- outputTokens: 0,
383
+ outputTokens: outputDelta,
384
+ // Phase 9: pi-ai owns the formula — we just relay its number.
385
+ contextFormula: "pi-delegated",
356
386
  });
357
387
  }
358
388
  break;
359
389
  }
360
390
  case "tool_execution_start": {
361
- const model = this.agentSession.model?.name ?? this.config.model;
391
+ const model = this.reportedModel();
362
392
  this.emit({
363
393
  type: "raw_log",
364
394
  content: JSON.stringify({
@@ -489,9 +519,11 @@ class PiMonoSession implements ProviderSession {
489
519
  outputTokens: stats.tokens.output,
490
520
  cacheReadTokens: stats.tokens.cacheRead,
491
521
  cacheWriteTokens: stats.tokens.cacheWrite,
492
- durationMs: 0, // Not directly available from SessionStats
522
+ // Phase 7: real wallclock duration; pi-ai SessionStats doesn't carry
523
+ // one so we track it on this adapter instance.
524
+ durationMs: Date.now() - this.sessionStartedAt,
493
525
  numTurns: stats.userMessages + stats.assistantMessages,
494
- model: this.agentSession.model?.name ?? this.config.model,
526
+ model: this.reportedModel(),
495
527
  isError: false,
496
528
  provider: "pi",
497
529
  };
@@ -0,0 +1,52 @@
1
+ # Pricing sources
2
+
3
+ This page lists the sources that feed the `pricing` table at server boot.
4
+ Operators bumping a rate by hand should also update this file.
5
+
6
+ ## Primary: vendored models.dev snapshot
7
+
8
+ - **Path**: `ui/src/lib/modelsdev-cache.json`
9
+ - **Loaded by**: `src/be/seed-pricing.ts` → `seedPricingFromModelsDev()`,
10
+ called from `src/server.ts` after `initDb`.
11
+ - **Projection rules** (see the same module for code-level detail):
12
+ - Anthropic models → rows under `provider='claude'` AND `provider='claude-managed'`.
13
+ Shortnames (`opus`, `sonnet`, `haiku`) ALSO get rows keyed by the current
14
+ default full id (e.g. `opus → claude-opus-4-7`). Pi-mono uses the same
15
+ shortname forms, so they're projected under `provider='pi'` as well.
16
+ - OpenAI models → rows under `provider='codex'`.
17
+ - OpenRouter models → rows under `provider='opencode'`. Any `google/...`
18
+ row additionally gets projected under `provider='gemini'` (both the
19
+ stripped name and the full `google/...` id) so internal-ai callers find
20
+ a hit either way.
21
+
22
+ - **Refresh procedure** (the only place to update the snapshot):
23
+ - Run `bun run scripts/refresh-modelsdev-pricing.ts` (Phase 2 — adds the
24
+ script). It fetches the latest snapshot from models.dev, diffs against
25
+ the vendored copy, prints a summary, and writes the new file.
26
+ - Commit the regenerated `modelsdev-cache.json` together with a bump
27
+ note in the PR description.
28
+
29
+ ## Manual overrides
30
+
31
+ Two cost components models.dev doesn't carry are encoded in
32
+ `MANUAL_PRICING_OVERRIDES` inside `src/be/seed-pricing.ts`:
33
+
34
+ | Provider | Model | Token class | Rate | Source | Verified |
35
+ |------------------|-------|----------------|--------------|---------------------------------------------------------------------------------|------------|
36
+ | `claude-managed` | `*` | `runtime_hour` | $0.08 / hour | <https://docs.claude.com/en/api/agent-sdk/managed-runtime#pricing> | 2026-04-28 |
37
+ | `devin` | `*` | `acu` | $2.25 / ACU | <https://devin.ai/pricing> | 2026-04-28 |
38
+
39
+ The `pricePerMillionUsd` column carries these as `rate * 1_000_000` so the
40
+ same schema fits — the adapter scales by the underlying unit (hours / ACUs),
41
+ not by tokens. The unit convention is specific to those `token_class` values.
42
+
43
+ ## When a model is missing
44
+
45
+ If `POST /api/session-costs` arrives with a `(provider, model)` pair that has
46
+ no input/output pricing rows at the lookup time, the row is persisted with
47
+ `costSource='unpriced'` (rather than 'harness'). The UI surfaces this as a
48
+ yellow badge.
49
+
50
+ To fix: either add the model to `modelsdev-cache.json` (preferred — the
51
+ upstream snapshot probably needs refreshing) or add a manual override row via
52
+ the existing admin route `POST /api/pricing`.
@@ -167,8 +167,10 @@ export function createSwarmEventHandler(
167
167
 
168
168
  const progressContextUsage = (event: {
169
169
  contextUsedTokens: number;
170
- contextTotalTokens: number;
171
- contextPercent: number;
170
+ // Migration 063: nullable for adapters that can't resolve a window.
171
+ contextTotalTokens: number | null;
172
+ contextPercent: number | null;
173
+ contextFormula?: string;
172
174
  }): void => {
173
175
  if (opts.taskId && shouldRun("context-progress", CONTEXT_THROTTLE_MS)) {
174
176
  fireAndForget(`${opts.apiUrl}/api/tasks/${encodeURIComponent(opts.taskId)}/context`, {
@@ -178,8 +180,9 @@ export function createSwarmEventHandler(
178
180
  eventType: "progress",
179
181
  sessionId: sessionId ?? `${opts.sessionIdFallbackPrefix ?? "session"}-${opts.taskId}`,
180
182
  contextUsedTokens: event.contextUsedTokens,
181
- contextTotalTokens: event.contextTotalTokens,
182
- contextPercent: event.contextPercent,
183
+ contextTotalTokens: event.contextTotalTokens ?? undefined,
184
+ contextPercent: event.contextPercent ?? undefined,
185
+ contextFormula: event.contextFormula,
183
186
  }),
184
187
  });
185
188
  }
@@ -239,6 +242,7 @@ export function createSwarmEventHandler(
239
242
  contextUsedTokens: event.contextUsedTokens,
240
243
  contextTotalTokens: event.contextTotalTokens,
241
244
  contextPercent: event.contextPercent,
245
+ contextFormula: event.contextFormula,
242
246
  });
243
247
  break;
244
248
  }
@@ -7,18 +7,30 @@ export interface CostData {
7
7
  inputTokens?: number;
8
8
  outputTokens?: number;
9
9
  cacheReadTokens?: number;
10
+ /**
11
+ * Migration 063: undefined means "the harness can't report this" (e.g. the
12
+ * Codex SDK has no cache-write field). Zero is reserved for "really zero".
13
+ */
10
14
  cacheWriteTokens?: number;
15
+ /** Migration 063: codex reasoning_output_tokens (and similar) for reasoning models. */
16
+ reasoningOutputTokens?: number;
17
+ /** Migration 063: claude extended-thinking tokens from CLI's `usage.thinking_input_tokens`. */
18
+ thinkingTokens?: number;
11
19
  durationMs: number;
12
- numTurns: number;
20
+ /**
21
+ * Migration 063: nullable — some adapters (claude when `num_turns` is absent)
22
+ * can't honestly report a turn count; null is preferred over a faked 1.
23
+ */
24
+ numTurns: number | null;
13
25
  model: string;
14
26
  isError: boolean;
15
27
  /**
16
- * Phase 6: tells the API which recompute path to use on
17
- * `POST /api/session-costs`. Codex triggers the pricing-table recompute
18
- * (when DB pricing rows exist for all three token classes); Claude / pi
19
- * always trust the harness-reported `totalCostUsd` as-is.
28
+ * Phase 6 (extended migration 063): tells the API which recompute path to
29
+ * use on `POST /api/session-costs`. After Phase 2 the recompute path runs
30
+ * for every provider with seeded pricing rows, so every adapter should
31
+ * populate this field.
20
32
  */
21
- provider?: "claude" | "codex" | "pi" | "opencode";
33
+ provider?: "claude" | "claude-managed" | "codex" | "pi" | "opencode" | "devin";
22
34
  }
23
35
 
24
36
  import type { ProviderName } from "../types";
@@ -43,14 +55,25 @@ export type ProviderEvent =
43
55
  | {
44
56
  type: "context_usage";
45
57
  contextUsedTokens: number;
46
- contextTotalTokens: number;
47
- contextPercent: number;
48
- outputTokens: number;
58
+ // Migration 063: nullable so adapters (e.g. devin without a context API)
59
+ // can emit a snapshot that records cumulative tokens without faking a window.
60
+ contextTotalTokens: number | null;
61
+ // Migration 063: null if contextTotalTokens is missing (no divide-by-zero).
62
+ contextPercent: number | null;
63
+ // Migration 063: null when the adapter can't honestly report output tokens.
64
+ outputTokens: number | null;
65
+ /**
66
+ * Migration 063 — the formula the adapter used to compute
67
+ * contextUsedTokens. See `ContextFormulaSchema` in `src/types.ts` for the
68
+ * canonical value list. Adapters should always populate this going
69
+ * forward; it powers cross-provider apples-to-apples comparison.
70
+ */
71
+ contextFormula?: string;
49
72
  }
50
73
  | {
51
74
  type: "compaction";
52
75
  preCompactTokens: number;
53
- compactTrigger: "auto" | "manual";
76
+ compactTrigger: "auto" | "manual" | "auto-inferred";
54
77
  contextTotalTokens: number;
55
78
  };
56
79
 
@@ -0,0 +1,23 @@
1
+ import { stdlib } from "./stdlib";
2
+ import type { SwarmConfig } from "./swarm-config";
3
+ import { createSwarmSdk } from "./swarm-sdk";
4
+
5
+ export type RuntimeCtx = {
6
+ swarm: Record<string, unknown> & { config: SwarmConfig };
7
+ stdlib: typeof stdlib;
8
+ logger: {
9
+ log: (...args: unknown[]) => void;
10
+ warn: (...args: unknown[]) => void;
11
+ error: (...args: unknown[]) => void;
12
+ };
13
+ };
14
+
15
+ export function buildCtx({ swarmConfig }: { swarmConfig: SwarmConfig }): RuntimeCtx {
16
+ const swarm = createSwarmSdk(swarmConfig) as Record<string, unknown> & { config: SwarmConfig };
17
+ swarm.config = swarmConfig;
18
+ return {
19
+ swarm,
20
+ stdlib,
21
+ logger: console,
22
+ };
23
+ }
@@ -0,0 +1,39 @@
1
+ import { buildCtx } from "./ctx";
2
+ import type { SwarmConfigPayload } from "./executors/types";
3
+ import { SwarmConfig } from "./swarm-config";
4
+
5
+ function requiredEnv(name: string): string {
6
+ const value = process.env[name];
7
+ if (!value) throw new Error(`Missing required env ${name}`);
8
+ return value;
9
+ }
10
+
11
+ try {
12
+ const stdin = await Bun.stdin.text();
13
+ if (!stdin.trim()) {
14
+ console.error("Swarm script config payload was empty");
15
+ process.exit(2);
16
+ }
17
+
18
+ const payload = JSON.parse(stdin) as SwarmConfigPayload;
19
+ const swarmConfig = new SwarmConfig(payload);
20
+ const rawArgs = JSON.parse(await Bun.file(requiredEnv("SWARM_SCRIPT_ARGS_FILE")).text());
21
+ // Accept both shapes: callers may pass an already-serialized JSON string.
22
+ const parsedArgs = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs;
23
+ const ctx = buildCtx({ swarmConfig });
24
+
25
+ const sourceText = await Bun.file(requiredEnv("SWARM_SCRIPT_SOURCE_FILE")).text();
26
+ const userModulePath = `${requiredEnv("SWARM_SCRIPT_TMPDIR")}/user-script.ts`;
27
+ await Bun.write(userModulePath, sourceText);
28
+
29
+ const mod = await import(userModulePath);
30
+ if (typeof mod.default !== "function") {
31
+ throw new Error("Swarm script must export a default function");
32
+ }
33
+
34
+ const result = await mod.default(parsedArgs, ctx);
35
+ await Bun.write(requiredEnv("SWARM_SCRIPT_RESULT_FILE"), JSON.stringify(result ?? null));
36
+ } catch (error) {
37
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
38
+ process.exit(1);
39
+ }