@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
@@ -0,0 +1,381 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { getAgentById } from "../be/db";
4
+ import { createEvent } from "../be/events";
5
+ import { deleteScript, getScript, upsertScriptByName } from "../be/scripts/db";
6
+ import { searchScripts } from "../be/scripts/embeddings";
7
+ import { SCRIPT_SDK_TYPES, SCRIPT_STDLIB_TYPES, typecheckScript } from "../be/scripts/typecheck";
8
+ import { extractScriptSignature } from "../scripts-runtime/extract-signature";
9
+ import { runScript } from "../scripts-runtime/loader";
10
+ import {
11
+ ScriptFsModeSchema,
12
+ type ScriptRecord,
13
+ type ScriptScope,
14
+ ScriptScopeSchema,
15
+ } from "../types";
16
+ import { scrubObject } from "../utils/secret-scrubber";
17
+ import { route } from "./route-def";
18
+ import { json, jsonError } from "./utils";
19
+
20
+ const scriptNameSchema = z.string().min(1).max(200);
21
+
22
+ const upsertBodySchema = z.object({
23
+ name: scriptNameSchema,
24
+ source: z.string().min(1),
25
+ description: z.string().default(""),
26
+ intent: z.string().default(""),
27
+ scope: ScriptScopeSchema.default("agent"),
28
+ fsMode: ScriptFsModeSchema.default("none"),
29
+ });
30
+
31
+ const runBodySchema = z
32
+ .object({
33
+ name: scriptNameSchema.optional(),
34
+ source: z.string().min(1).optional(),
35
+ args: z.unknown().optional(),
36
+ intent: z.string().default(""),
37
+ scope: ScriptScopeSchema.optional(),
38
+ fsMode: ScriptFsModeSchema.default("none"),
39
+ })
40
+ .refine((body) => Boolean(body.name) !== Boolean(body.source), {
41
+ message: "Provide exactly one of name or source",
42
+ });
43
+
44
+ const searchBodySchema = z.object({
45
+ query: z.string().default(""),
46
+ scope: ScriptScopeSchema.optional(),
47
+ limit: z.number().int().min(1).max(100).default(10),
48
+ });
49
+
50
+ const nameParamsSchema = z.object({ name: scriptNameSchema });
51
+ const scopeQuerySchema = z.object({ scope: ScriptScopeSchema.default("agent") });
52
+ const optionalScopeQuerySchema = z.object({ scope: ScriptScopeSchema.optional() });
53
+
54
+ const upsertRoute = route({
55
+ method: "post",
56
+ path: "/api/scripts/upsert",
57
+ pattern: ["api", "scripts", "upsert"],
58
+ operationId: "scripts_upsert",
59
+ summary: "Create or update a reusable script",
60
+ description: "Explicit script upserts run a TypeScript typecheck before writing.",
61
+ tags: ["Scripts"],
62
+ body: upsertBodySchema,
63
+ responses: {
64
+ 200: { description: "Script upserted" },
65
+ 400: { description: "Validation or typecheck failure" },
66
+ 403: { description: "Global write requires lead agent" },
67
+ },
68
+ });
69
+
70
+ const runRoute = route({
71
+ method: "post",
72
+ path: "/api/scripts/run",
73
+ pattern: ["api", "scripts", "run"],
74
+ operationId: "scripts_run",
75
+ summary: "Run a reusable or inline script",
76
+ description:
77
+ "Inline source skips typecheck and is auto-saved as a scratch script only on success.",
78
+ tags: ["Scripts"],
79
+ body: runBodySchema,
80
+ responses: {
81
+ 200: { description: "Script run completed" },
82
+ 400: { description: "Validation error" },
83
+ 404: { description: "Script not found" },
84
+ 501: { description: "workspace-rw scripts are not supported in v1" },
85
+ },
86
+ });
87
+
88
+ const searchRoute = route({
89
+ method: "post",
90
+ path: "/api/scripts/search",
91
+ pattern: ["api", "scripts", "search"],
92
+ operationId: "scripts_search",
93
+ summary: "Search reusable scripts",
94
+ description: "Phase 3 search is substring-only over script name and metadata.",
95
+ tags: ["Scripts"],
96
+ body: searchBodySchema,
97
+ responses: {
98
+ 200: { description: "Matching scripts" },
99
+ 400: { description: "Validation error" },
100
+ },
101
+ });
102
+
103
+ const deleteRoute = route({
104
+ method: "delete",
105
+ path: "/api/scripts/{name}",
106
+ pattern: ["api", "scripts", null],
107
+ operationId: "scripts_delete",
108
+ summary: "Delete a reusable script",
109
+ tags: ["Scripts"],
110
+ params: nameParamsSchema,
111
+ query: scopeQuerySchema,
112
+ responses: {
113
+ 200: { description: "Delete result" },
114
+ 400: { description: "Validation error" },
115
+ 403: { description: "Global delete requires lead agent" },
116
+ },
117
+ });
118
+
119
+ const typesRoute = route({
120
+ method: "get",
121
+ path: "/api/scripts/{name}/types",
122
+ pattern: ["api", "scripts", null, "types"],
123
+ operationId: "scripts_types",
124
+ summary: "Get script signature and authoring types",
125
+ tags: ["Scripts"],
126
+ params: nameParamsSchema,
127
+ query: optionalScopeQuerySchema,
128
+ responses: {
129
+ 200: { description: "Script signature and type blobs" },
130
+ 404: { description: "Script not found" },
131
+ },
132
+ });
133
+
134
+ function requireAgent(res: ServerResponse, agentId: string | undefined) {
135
+ if (!agentId) {
136
+ jsonError(res, "X-Agent-ID required for scripts API", 400);
137
+ return null;
138
+ }
139
+ const agent = getAgentById(agentId);
140
+ if (!agent) {
141
+ jsonError(res, "Agent not found", 404);
142
+ return null;
143
+ }
144
+ return agent;
145
+ }
146
+
147
+ function signatureJsonFor(source: string): string {
148
+ return JSON.stringify(extractScriptSignature(source));
149
+ }
150
+
151
+ function resolveScript(name: string, agentId: string, scope?: ScriptScope): ScriptRecord | null {
152
+ if (scope === "global") return getScript({ name, scope: "global" });
153
+ if (scope === "agent") return getScript({ name, scope: "agent", scopeId: agentId });
154
+ return (
155
+ getScript({ name, scope: "agent", scopeId: agentId }) ?? getScript({ name, scope: "global" })
156
+ );
157
+ }
158
+
159
+ function scratchSlug(intent: string, source: string): string {
160
+ const base = (intent || "inline-script")
161
+ .toLowerCase()
162
+ .replace(/[^a-z0-9]+/g, "-")
163
+ .replace(/^-+|-+$/g, "")
164
+ .slice(0, 48);
165
+ const hash = new Bun.CryptoHasher("sha256").update(source).digest("hex").slice(0, 8);
166
+ return `scratch-${base || "inline-script"}-${hash}`;
167
+ }
168
+
169
+ function emitGlobalUpsertEvent(args: {
170
+ agentId: string;
171
+ script: ScriptRecord;
172
+ isNew: boolean;
173
+ isPromotion: boolean;
174
+ }) {
175
+ createEvent({
176
+ category: "system",
177
+ event: "script.global_upsert",
178
+ source: "api",
179
+ agentId: args.agentId,
180
+ data: {
181
+ scriptId: args.script.id,
182
+ name: args.script.name,
183
+ version: args.script.version,
184
+ contentHash: args.script.contentHash,
185
+ changedByAgentId: args.agentId,
186
+ isNew: args.isNew,
187
+ isPromotion: args.isPromotion,
188
+ },
189
+ });
190
+ }
191
+
192
+ export async function handleScripts(
193
+ req: IncomingMessage,
194
+ res: ServerResponse,
195
+ pathSegments: string[],
196
+ queryParams: URLSearchParams,
197
+ agentId: string | undefined,
198
+ ): Promise<boolean> {
199
+ if (upsertRoute.match(req.method, pathSegments)) {
200
+ const parsed = await upsertRoute.parse(req, res, pathSegments, queryParams);
201
+ if (!parsed) return true;
202
+ const agent = requireAgent(res, agentId);
203
+ if (!agent) return true;
204
+
205
+ if (parsed.body.scope === "global" && !agent.isLead) {
206
+ jsonError(res, "Global scripts require a lead agent", 403);
207
+ return true;
208
+ }
209
+
210
+ const typecheck = typecheckScript(parsed.body.source);
211
+ if (!typecheck.ok) {
212
+ json(res, { error: "typecheck_failed", diagnostics: typecheck.diagnostics }, 400);
213
+ return true;
214
+ }
215
+
216
+ const existingAgentScript =
217
+ parsed.body.scope === "global"
218
+ ? getScript({ name: parsed.body.name, scope: "agent", scopeId: agent.id })
219
+ : null;
220
+ const result = await upsertScriptByName({
221
+ name: parsed.body.name,
222
+ scope: parsed.body.scope,
223
+ scopeId: parsed.body.scope === "agent" ? agent.id : null,
224
+ source: parsed.body.source,
225
+ description: parsed.body.description,
226
+ intent: parsed.body.intent,
227
+ signatureJson: signatureJsonFor(parsed.body.source),
228
+ fsMode: parsed.body.fsMode,
229
+ agentId: agent.id,
230
+ isScratch: false,
231
+ typeChecked: true,
232
+ });
233
+
234
+ if (parsed.body.scope === "global" && !result.contentDeduped) {
235
+ emitGlobalUpsertEvent({
236
+ agentId: agent.id,
237
+ script: result.script,
238
+ isNew: result.isNew,
239
+ isPromotion: Boolean(existingAgentScript),
240
+ });
241
+ }
242
+
243
+ json(res, {
244
+ name: result.script.name,
245
+ version: result.script.version,
246
+ contentDeduped: result.contentDeduped,
247
+ });
248
+ return true;
249
+ }
250
+
251
+ if (runRoute.match(req.method, pathSegments)) {
252
+ const parsed = await runRoute.parse(req, res, pathSegments, queryParams);
253
+ if (!parsed) return true;
254
+ const agent = requireAgent(res, agentId);
255
+ if (!agent) return true;
256
+
257
+ let source = parsed.body.source;
258
+ let fsMode = parsed.body.fsMode;
259
+ if (parsed.body.name) {
260
+ const script = resolveScript(parsed.body.name, agent.id, parsed.body.scope);
261
+ if (!script) {
262
+ jsonError(res, "Script not found", 404);
263
+ return true;
264
+ }
265
+ source = script.source;
266
+ fsMode = script.fsMode;
267
+ }
268
+
269
+ if (fsMode === "workspace-rw") {
270
+ jsonError(res, "workspace-rw scripts are not supported by /api/scripts/run in v1", 501);
271
+ return true;
272
+ }
273
+
274
+ const output = await runScript({
275
+ source: source as string,
276
+ args: parsed.body.args,
277
+ fsMode,
278
+ agentId: agent.id,
279
+ });
280
+
281
+ let autoSaved: { slug: string; reason: string } | undefined;
282
+ if (parsed.body.source && !output.error && output.exitCode === 0) {
283
+ const slug = scratchSlug(parsed.body.intent, parsed.body.source);
284
+ await upsertScriptByName({
285
+ name: slug,
286
+ scope: "agent",
287
+ scopeId: agent.id,
288
+ source: parsed.body.source,
289
+ description: `Scratch script: ${parsed.body.intent || slug}`,
290
+ intent: parsed.body.intent || "Inline script auto-saved after successful run",
291
+ signatureJson: signatureJsonFor(parsed.body.source),
292
+ fsMode: "none",
293
+ agentId: agent.id,
294
+ isScratch: true,
295
+ typeChecked: false,
296
+ changeReason: "Auto-saved successful inline run",
297
+ });
298
+ autoSaved = { slug, reason: "successful_inline_run" };
299
+ }
300
+
301
+ json(
302
+ res,
303
+ scrubObject({
304
+ result: output.result,
305
+ autoSaved,
306
+ truncated: output.truncated,
307
+ durationMs: output.durationMs,
308
+ stdout: output.stdout,
309
+ stderr: output.stderr,
310
+ exitCode: output.exitCode,
311
+ error: output.error,
312
+ }),
313
+ );
314
+ return true;
315
+ }
316
+
317
+ if (searchRoute.match(req.method, pathSegments)) {
318
+ const parsed = await searchRoute.parse(req, res, pathSegments, queryParams);
319
+ if (!parsed) return true;
320
+ const agent = requireAgent(res, agentId);
321
+ if (!agent) return true;
322
+
323
+ const matches = await searchScripts({
324
+ query: parsed.body.query,
325
+ scope: parsed.body.scope,
326
+ scopeId: agent.id,
327
+ limit: parsed.body.limit,
328
+ });
329
+
330
+ json(res, {
331
+ results: matches.map(({ script, score }) => ({
332
+ name: script.name,
333
+ signature: JSON.parse(script.signatureJson),
334
+ description: script.description,
335
+ score,
336
+ })),
337
+ });
338
+ return true;
339
+ }
340
+
341
+ if (typesRoute.match(req.method, pathSegments)) {
342
+ const parsed = await typesRoute.parse(req, res, pathSegments, queryParams);
343
+ if (!parsed) return true;
344
+ const agent = requireAgent(res, agentId);
345
+ if (!agent) return true;
346
+
347
+ const script = resolveScript(parsed.params.name, agent.id, parsed.query.scope);
348
+ if (!script) {
349
+ jsonError(res, "Script not found", 404);
350
+ return true;
351
+ }
352
+ json(res, {
353
+ signature: JSON.parse(script.signatureJson),
354
+ sdkTypes: SCRIPT_SDK_TYPES,
355
+ stdlibTypes: SCRIPT_STDLIB_TYPES,
356
+ });
357
+ return true;
358
+ }
359
+
360
+ if (deleteRoute.match(req.method, pathSegments)) {
361
+ const parsed = await deleteRoute.parse(req, res, pathSegments, queryParams);
362
+ if (!parsed) return true;
363
+ const agent = requireAgent(res, agentId);
364
+ if (!agent) return true;
365
+
366
+ if (parsed.query.scope === "global" && !agent.isLead) {
367
+ jsonError(res, "Global scripts require a lead agent", 403);
368
+ return true;
369
+ }
370
+
371
+ const deleted = deleteScript({
372
+ name: parsed.params.name,
373
+ scope: parsed.query.scope,
374
+ scopeId: parsed.query.scope === "agent" ? agent.id : null,
375
+ });
376
+ json(res, { deleted });
377
+ return true;
378
+ }
379
+
380
+ return false;
381
+ }
@@ -13,6 +13,7 @@ import {
13
13
  getSessionLogsByTaskId,
14
14
  getTaskById,
15
15
  } from "../be/db";
16
+ import { normalizeModelKey } from "../be/pricing-normalize";
16
17
  import type { SessionCost, SessionCostSource } from "../types";
17
18
  import { route } from "./route-def";
18
19
  import { json, jsonError } from "./utils";
@@ -65,17 +66,24 @@ const createSessionCostRoute = route({
65
66
  inputTokens: z.number().int().optional(),
66
67
  outputTokens: z.number().int().optional(),
67
68
  cacheReadTokens: z.number().int().optional(),
68
- cacheWriteTokens: z.number().int().optional(),
69
+ // Migration 063: nullable — adapters that can't honestly report cache writes
70
+ // (e.g. Codex SDK) prefer null over a faked 0.
71
+ cacheWriteTokens: z.number().int().nullable().optional(),
72
+ // Migration 063: new token classes previously dropped on the floor.
73
+ reasoningOutputTokens: z.number().int().nonnegative().optional(),
74
+ thinkingTokens: z.number().int().nonnegative().optional(),
69
75
  durationMs: z.number().int().optional(),
70
- numTurns: z.number().int().optional(),
76
+ // Migration 063: nullable for adapters that can't honestly report numTurns.
77
+ numTurns: z.number().int().nullable().optional(),
71
78
  model: z.string().optional(),
72
79
  isError: z.boolean().optional(),
73
80
  /**
74
- * Phase 6: when present, drives the codex pricing-table recompute path.
75
- * Other providers ('claude' / 'pi' / 'opencode') always trust harness-reported USD.
76
- * Optional / undefined keeps back-compat for existing callers.
81
+ * Phase 6 (extended migration 063): drives the API recompute path. After
82
+ * Phase 2 every provider with seeded pricing rows participates.
77
83
  */
78
- provider: z.enum(["claude", "codex", "pi", "opencode"]).optional(),
84
+ provider: z
85
+ .enum(["claude", "claude-managed", "codex", "pi", "opencode", "devin", "gemini"])
86
+ .optional(),
79
87
  /**
80
88
  * Phase 6: epoch-ms timestamp used as the "active price at time T" lookup
81
89
  * basis. Defaults to `Date.now()` when omitted. Including it lets
@@ -185,35 +193,75 @@ export async function handleSessionData(
185
193
  try {
186
194
  const inputTokens = parsed.body.inputTokens ?? 0;
187
195
  const cachedInputTokens = parsed.body.cacheReadTokens ?? 0;
196
+ const cacheWriteTokens = parsed.body.cacheWriteTokens ?? 0;
188
197
  const outputTokens = parsed.body.outputTokens ?? 0;
189
- const model = parsed.body.model || "opus";
198
+ // Phase 2: don't paper over a missing model with a fake default — that
199
+ // poisoned the pricing-table lookup against the wrong rate. Only the
200
+ // back-compat case (no provider tag) keeps "opus" so old callers don't
201
+ // explode.
202
+ const model = parsed.body.model || (parsed.body.provider ? "" : "opus");
190
203
 
191
- // Phase 6: Codex USD recompute. When the worker reports `provider='codex'`
192
- // and DB pricing rows exist for ALL three token classes at the lookup
193
- // time, recompute `totalCostUsd` from tokens × DB prices and tag the
194
- // row as 'pricing-table'. If any class has no row, fall back to the
195
- // worker-reported value with `costSource='harness'` (back-compat for
196
- // unseeded models). Claude / pi / opencode paths always use 'harness'.
204
+ // Phase 2: widen the recompute branch beyond codex. For any provider
205
+ // with a known model and seeded pricing rows, recompute `totalCostUsd`
206
+ // from tokens × DB prices and tag the row 'pricing-table'. When the
207
+ // (provider, model) pair has no pricing rows at all, tag 'unpriced' so
208
+ // the UI can flag it. When the provider isn't set, fall through with
209
+ // 'harness' (back-compat for older callers).
197
210
  let totalCostUsd = parsed.body.totalCostUsd;
198
211
  let costSource: SessionCostSource = "harness";
199
212
 
200
- if (parsed.body.provider === "codex") {
213
+ if (parsed.body.provider && model) {
201
214
  const lookupTime = parsed.body.createdAt ?? Date.now();
202
- const inputRow = getActivePricingRow("codex", model, "input", lookupTime);
203
- const cachedRow = getActivePricingRow("codex", model, "cached_input", lookupTime);
204
- const outputRow = getActivePricingRow("codex", model, "output", lookupTime);
215
+ // Phase 2 fix different harnesses prepend routing prefixes
216
+ // (`openrouter/`, `github-copilot/`, …) to the same underlying model
217
+ // id. The pricing seed stores canonical (un-prefixed) keys, so we
218
+ // strip the prefix here before lookup. The original adapter-emitted
219
+ // string is still persisted to `session_costs.model` for debugging.
220
+ const lookupModel = normalizeModelKey(parsed.body.provider, model);
221
+ const inputRow = getActivePricingRow(
222
+ parsed.body.provider,
223
+ lookupModel,
224
+ "input",
225
+ lookupTime,
226
+ );
227
+ const cachedRow = getActivePricingRow(
228
+ parsed.body.provider,
229
+ lookupModel,
230
+ "cached_input",
231
+ lookupTime,
232
+ );
233
+ const outputRow = getActivePricingRow(
234
+ parsed.body.provider,
235
+ lookupModel,
236
+ "output",
237
+ lookupTime,
238
+ );
239
+ const cacheWriteRow = getActivePricingRow(
240
+ parsed.body.provider,
241
+ lookupModel,
242
+ "cache_write",
243
+ lookupTime,
244
+ );
205
245
 
206
- if (inputRow && cachedRow && outputRow) {
207
- // Mirror the existing computeCodexCostUsd logic: subtract cached
208
- // tokens from input before billing the uncached portion at the full
209
- // rate (Codex SDK reports input_tokens as TOTAL across the turn).
246
+ if (inputRow && outputRow) {
247
+ // Mirror the legacy codex semantic: uncached input is billed at the
248
+ // full rate, cached input at the discounted rate. Cache writes are
249
+ // billed separately when the provider's pricing table carries that
250
+ // class (anthropic) and the adapter reports a non-zero value.
210
251
  const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens);
252
+ const cachedRate = cachedRow?.pricePerMillionUsd ?? 0;
253
+ const cacheWriteRate = cacheWriteRow?.pricePerMillionUsd ?? 0;
211
254
  totalCostUsd =
212
255
  (uncachedInputTokens * inputRow.pricePerMillionUsd +
213
- cachedInputTokens * cachedRow.pricePerMillionUsd +
256
+ cachedInputTokens * cachedRate +
257
+ cacheWriteTokens * cacheWriteRate +
214
258
  outputTokens * outputRow.pricePerMillionUsd) /
215
259
  1_000_000;
216
260
  costSource = "pricing-table";
261
+ } else {
262
+ // Provider was tagged but we have no pricing rows for it; flag the
263
+ // row so the UI can show an "unpriced" badge instead of pretending.
264
+ costSource = "unpriced";
217
265
  }
218
266
  }
219
267
 
@@ -226,8 +274,11 @@ export async function handleSessionData(
226
274
  outputTokens,
227
275
  cacheReadTokens: cachedInputTokens,
228
276
  cacheWriteTokens: parsed.body.cacheWriteTokens ?? 0,
277
+ reasoningOutputTokens: parsed.body.reasoningOutputTokens ?? 0,
278
+ thinkingTokens: parsed.body.thinkingTokens ?? 0,
229
279
  durationMs: parsed.body.durationMs ?? 0,
230
- numTurns: parsed.body.numTurns ?? 1,
280
+ // Migration 063: pass null through honestly instead of faking a 1.
281
+ numTurns: parsed.body.numTurns ?? null,
231
282
  model,
232
283
  isError: parsed.body.isError ?? false,
233
284
  costSource,
@@ -49,6 +49,10 @@ async function handleTaskCreated(data: unknown): Promise<void> {
49
49
  );
50
50
  }
51
51
 
52
+ // Cap parameter length to avoid oversized Linear GraphQL payloads. Linear renders this in the
53
+ // AgentSession panel; 2000 chars is plenty for a progress update.
54
+ const PROGRESS_PARAMETER_MAX = 2000;
55
+
52
56
  async function handleTaskProgress(data: unknown): Promise<void> {
53
57
  const { taskId, progress } = data as { taskId: string; progress?: string };
54
58
  if (!taskId || !progress) return;
@@ -56,8 +60,11 @@ async function handleTaskProgress(data: unknown): Promise<void> {
56
60
  const sessionId = taskSessionMap.get(taskId);
57
61
  if (!sessionId) return;
58
62
 
59
- // Use 'action' activity type — Linear renders it as a structured tool invocation card
60
- postAgentSessionAction(sessionId, progress).catch((err) => {
63
+ // Post as `action` activity (renders as a structured card in Linear's AgentSession panel).
64
+ // Per Linear's agentActivityCreate spec, `action` requires BOTH `action` AND `parameter`;
65
+ // the original bug here was passing `progress` as `action` with `parameter` undefined.
66
+ const parameter = progress.slice(0, PROGRESS_PARAMETER_MAX);
67
+ postAgentSessionAction(sessionId, "Progress update", parameter).catch((err) => {
61
68
  console.error(`[Linear Outbound] Failed to post progress action for task ${taskId}:`, err);
62
69
  });
63
70
  }