@desplega.ai/agent-swarm 2.5.3 → 2.5.4

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 (197) hide show
  1. package/dist/{acp-adapter-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
  2. package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
  3. package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
  4. package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
  6. package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
  11. package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
  13. package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
  16. package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
  17. package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
  22. package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
  24. package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
  26. package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
  27. package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
  28. package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
  29. package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
  30. package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
  31. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  32. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  33. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  34. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  35. package/dist/{cli-s53vp709.js → cli-eftzyh9y.js} +1 -1
  36. package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
  37. package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
  38. package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
  39. package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
  40. package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
  41. package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
  42. package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
  43. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  44. package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
  45. package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
  46. package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
  47. package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
  48. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  49. package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
  50. package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
  51. package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
  52. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  53. package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
  54. package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
  55. package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
  56. package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
  57. package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
  58. package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
  59. package/dist/cli.js +18 -18
  60. package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
  61. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  62. package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
  63. package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
  64. package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
  65. package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
  66. package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
  67. package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
  68. package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
  69. package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
  70. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  71. package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
  72. package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
  73. package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
  74. package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
  75. package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
  76. package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
  77. package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
  78. package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
  79. package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
  80. package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
  81. package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
  82. package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
  83. package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
  84. package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
  85. package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
  86. package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
  87. package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  88. package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
  89. package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
  90. package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
  91. package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  92. package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
  93. package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
  94. package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
  95. package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
  96. package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
  97. package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
  98. package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
  99. package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
  100. package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
  101. package/openapi.json +68 -3
  102. package/package.json +5 -5
  103. package/plugin/commands/create-pr.md +2 -2
  104. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  105. package/src/be/db/approvals.ts +48 -0
  106. package/src/be/db/tasks/write.ts +15 -5
  107. package/src/be/db/workflow-idempotency.ts +183 -0
  108. package/src/be/db/workflow-runs.ts +318 -7
  109. package/src/be/db.ts +67 -17
  110. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  111. package/src/be/sealed-json.ts +1 -0
  112. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  113. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  114. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  115. package/src/be/sql-connections.ts +7 -1
  116. package/src/be/swarm-config-guard.ts +2 -0
  117. package/src/be/users.ts +9 -0
  118. package/src/commands/controller.ts +33 -0
  119. package/src/commands/runner.ts +6 -2
  120. package/src/extensions/apply-task-create.ts +57 -11
  121. package/src/extensions/contract-types.generated.ts +1 -1
  122. package/src/extensions/contract.ts +25 -0
  123. package/src/extensions/dispatcher.ts +4 -0
  124. package/src/extensions/post-bridge.ts +8 -0
  125. package/src/hooks/context-bound-note.ts +196 -0
  126. package/src/hooks/hook.ts +12 -0
  127. package/src/http/config.ts +48 -16
  128. package/src/http/idempotency-key.ts +117 -0
  129. package/src/http/workflows.ts +129 -24
  130. package/src/prompts/context-bound-note.ts +48 -0
  131. package/src/prompts/session-templates.ts +25 -1
  132. package/src/providers/amp-adapter.ts +30 -8
  133. package/src/providers/claude-adapter.ts +29 -4
  134. package/src/providers/claude-managed-adapter.ts +26 -0
  135. package/src/providers/claude-sdk-session.ts +10 -0
  136. package/src/providers/claude-session-events.ts +13 -0
  137. package/src/providers/codex-adapter.ts +12 -0
  138. package/src/providers/opencode-adapter.ts +18 -0
  139. package/src/providers/pi-mono-adapter.ts +35 -0
  140. package/src/runtime-providers/docker.ts +6 -2
  141. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  142. package/src/tests/amp-adapter.test.ts +97 -0
  143. package/src/tests/approval-requests.test.ts +10 -7
  144. package/src/tests/approval-sweeps.test.ts +12 -6
  145. package/src/tests/base-prompt.test.ts +1 -1
  146. package/src/tests/claude-hook-output.test.ts +79 -1
  147. package/src/tests/claude-managed-adapter.test.ts +275 -0
  148. package/src/tests/codex-adapter.test.ts +163 -0
  149. package/src/tests/context-bound-note.test.ts +269 -0
  150. package/src/tests/extensions-pre-task.test.ts +273 -0
  151. package/src/tests/fetch-resolved-env.test.ts +17 -0
  152. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  153. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  154. package/src/tests/fixtures/fake-amp.ts +22 -8
  155. package/src/tests/fixtures/held-template-render.ts +42 -0
  156. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  157. package/src/tests/openapi-route-headers.test.ts +2 -0
  158. package/src/tests/opencode-adapter.test.ts +134 -0
  159. package/src/tests/pi-mono-adapter.test.ts +105 -0
  160. package/src/tests/prompt-template-session.test.ts +4 -3
  161. package/src/tests/runner-push-delivery.test.ts +25 -0
  162. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  163. package/src/tests/runtime-providers-docker.test.ts +2 -0
  164. package/src/tests/script-connections.test.ts +3 -0
  165. package/src/tests/script-run-config-scope.test.ts +91 -3
  166. package/src/tests/session-logs.test.ts +70 -0
  167. package/src/tests/sessions-tree.test.ts +37 -0
  168. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  169. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  170. package/src/tests/workflow-idempotency.test.ts +915 -0
  171. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  172. package/src/utils/context-window.ts +8 -6
  173. package/src/workflows/approval-cancel.ts +15 -13
  174. package/src/workflows/approval-notifications.ts +5 -1
  175. package/src/workflows/checkpoint.ts +6 -3
  176. package/src/workflows/engine.ts +30 -8
  177. package/src/workflows/executors/foreach.ts +2 -2
  178. package/src/workflows/foreach-driver.ts +10 -3
  179. package/src/workflows/foreach-join.ts +29 -6
  180. package/src/workflows/recovery.ts +6 -1
  181. package/src/workflows/resume.ts +168 -56
  182. package/src/workflows/retry-poller.ts +49 -3
  183. package/src/workflows/task-step-routing.ts +29 -7
  184. package/templates/ai-toolbox.manifest.json +8 -8
  185. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  186. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  187. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  188. package/templates/skills/agent-browser/SKILL.md +4 -4
  189. package/templates/skills/agent-browser/content.md +4 -4
  190. package/templates/skills/code-quality/config.json +1 -1
  191. package/templates/skills/code-quality/content.md +2 -2
  192. package/templates/skills/delegate-work/content.md +29 -25
  193. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  194. package/templates/skills/qa/content.md +2 -2
  195. package/templates/skills/researching/content.md +2 -0
  196. package/templates/skills/work-on-task/SKILL.md +1 -1
  197. package/templates/skills/work-on-task/content.md +1 -1
@@ -5,6 +5,7 @@ import { resolveHttpAuditUserId } from "../be/audit-user";
5
5
  import {
6
6
  createWorkflow,
7
7
  deleteWorkflow,
8
+ getDbClient,
8
9
  getWorkflow,
9
10
  getWorkflowRun,
10
11
  getWorkflowRunStepsByRunId,
@@ -15,6 +16,13 @@ import {
15
16
  type updateWorkflow,
16
17
  withFavoriteFlags,
17
18
  } from "../be/db";
19
+ import {
20
+ assertWorkflowIdempotencyKeyUnused,
21
+ IdempotencyKeyTakenError,
22
+ recordAcceptedWorkflowOperation,
23
+ type WorkflowIdempotencyClaim,
24
+ workflowRequestFingerprint,
25
+ } from "../be/db/workflow-idempotency";
18
26
  import { getWorkflowRunParent } from "../be/db/workflow-runs";
19
27
  import {
20
28
  AssetKeySchema,
@@ -46,6 +54,11 @@ import { cancelWorkflowRun, retryFailedRun } from "../workflows/resume";
46
54
  import { handleWebhookTrigger, WebhookError } from "../workflows/triggers";
47
55
  import { snapshotAndUpdateWorkflow } from "../workflows/version";
48
56
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
57
+ import {
58
+ idempotencyKeyHeader,
59
+ readIdempotencyKey,
60
+ replayAcceptedOperation,
61
+ } from "./idempotency-key";
49
62
  import { route } from "./route-def";
50
63
  import { jsonError, parseBody, triggerSchemaErrorResponse } from "./utils";
51
64
 
@@ -142,6 +155,7 @@ const createWorkflowRoute = route({
142
155
  pattern: ["api", "workflows"],
143
156
  summary: "Create a new workflow",
144
157
  tags: ["Workflows"],
158
+ headers: idempotencyKeyHeader("creates one workflow"),
145
159
  body: z.object({
146
160
  name: z.string().min(1),
147
161
  key: AssetKeySchema.optional(),
@@ -159,7 +173,9 @@ const createWorkflowRoute = route({
159
173
  }),
160
174
  responses: {
161
175
  201: { description: "Workflow created", schema: WorkflowSaveResponseSchema },
162
- 400: { description: "Invalid definition" },
176
+ 400: { description: "Invalid definition, or a malformed Idempotency-Key" },
177
+ 409: { description: "Idempotency-Key was already used for a different request" },
178
+ 410: { description: "The workflow created under this Idempotency-Key was deleted" },
163
179
  },
164
180
  });
165
181
 
@@ -272,16 +288,21 @@ const triggerWorkflowRoute = route({
272
288
  path: "/api/workflows/{id}/trigger",
273
289
  pattern: ["api", "workflows", null, "trigger"],
274
290
  summary: "Trigger a workflow execution",
291
+ description:
292
+ "With an `Idempotency-Key`, a retry returns the accepted run's `runId` and `skipped` without starting, resuming or waiting for anything, even after the workflow was disabled or edited and whatever state the run is in now.",
275
293
  tags: ["Workflows"],
276
294
  params: z.object({ id: z.string() }),
295
+ headers: idempotencyKeyHeader("starts one run"),
277
296
  responses: {
278
297
  201: {
279
298
  description: "Workflow run started (or skipped if cooldown active)",
280
299
  schema: z.object({ runId: z.string(), skipped: z.boolean() }),
281
300
  },
282
- 400: { description: "Workflow is disabled" },
301
+ 400: { description: "Workflow is disabled, or a malformed Idempotency-Key" },
283
302
  401: { description: "Unauthorized" },
284
303
  404: { description: "Workflow not found" },
304
+ 409: { description: "Idempotency-Key was already used for a different request" },
305
+ 410: { description: "The workflow triggered under this Idempotency-Key was deleted" },
285
306
  },
286
307
  });
287
308
 
@@ -574,8 +595,21 @@ export async function handleWorkflows(
574
595
  }
575
596
 
576
597
  if (createWorkflowRoute.match(req.method, pathSegments)) {
598
+ const idempotency = readIdempotencyKey(req, res, myAgentId);
599
+ if (idempotency === null) return true;
577
600
  const parsed = await createWorkflowRoute.parse(req, res, pathSegments, queryParams);
578
601
  if (!parsed) return true;
602
+ const claim: WorkflowIdempotencyClaim | undefined = idempotency && {
603
+ ...idempotency,
604
+ operation: "workflow.create",
605
+ targetId: "",
606
+ fingerprint: workflowRequestFingerprint("workflow.create", parsed.body),
607
+ };
608
+ const replayCreate = (body: unknown) =>
609
+ createWorkflowRoute.respond(res, 201, body as z.input<typeof WorkflowSaveResponseSchema>);
610
+ // Before validation: a retry gets the original result even if the same
611
+ // definition would no longer validate.
612
+ if (claim && (await replayAcceptedOperation(res, claim, replayCreate))) return true;
579
613
 
580
614
  // Validate definition structure
581
615
  const validation = validateDefinition(parsed.body.definition, getExecutorRegistry());
@@ -603,27 +637,55 @@ export async function handleWorkflows(
603
637
  throw error;
604
638
  }
605
639
 
606
- const workflow = await createWorkflow(
607
- {
608
- key,
609
- name: parsed.body.name,
610
- description: parsed.body.description,
611
- definition: parsed.body.definition,
612
- triggers: parsed.body.triggers,
613
- cooldown: parsed.body.cooldown,
614
- input: parsed.body.input,
615
- triggerSchema: parsed.body.triggerSchema,
616
- params: parsed.body.params,
617
- requiredParams: parsed.body.requiredParams,
618
- requires: parsed.body.requires,
619
- dir: parsed.body.dir,
620
- vcsRepo: parsed.body.vcsRepo,
621
- createdByAgentId: myAgentId ?? undefined,
622
- createdBy: (await resolveHttpAuditUserId(req, myAgentId)) ?? undefined,
623
- },
624
- "api",
625
- );
626
- createWorkflowRoute.respond(res, 201, await withSaveWarnings(workflow));
640
+ const data: Parameters<typeof createWorkflow>[0] = {
641
+ key,
642
+ name: parsed.body.name,
643
+ description: parsed.body.description,
644
+ definition: parsed.body.definition,
645
+ triggers: parsed.body.triggers,
646
+ cooldown: parsed.body.cooldown,
647
+ input: parsed.body.input,
648
+ triggerSchema: parsed.body.triggerSchema,
649
+ params: parsed.body.params,
650
+ requiredParams: parsed.body.requiredParams,
651
+ requires: parsed.body.requires,
652
+ dir: parsed.body.dir,
653
+ vcsRepo: parsed.body.vcsRepo,
654
+ createdByAgentId: myAgentId ?? undefined,
655
+ createdBy: (await resolveHttpAuditUserId(req, myAgentId)) ?? undefined,
656
+ };
657
+ if (!claim) {
658
+ const workflow = await createWorkflow(data, "api");
659
+ createWorkflowRoute.respond(res, 201, await withSaveWarnings(workflow));
660
+ return true;
661
+ }
662
+
663
+ // Warnings are computed before the transaction (a readiness check may
664
+ // call out) and stored with the workflow, so a retry gets this snapshot,
665
+ // not the workflow as it is by then.
666
+ const warnings = await workflowSaveWarnings(parsed.body.definition, getExecutorRegistry());
667
+ let saved: z.input<typeof WorkflowSaveResponseSchema>;
668
+ try {
669
+ saved = await getDbClient().transaction(async () => {
670
+ // Before the insert: a concurrent winner's workflow holds the same name.
671
+ await assertWorkflowIdempotencyKeyUnused(claim);
672
+ const workflow = await createWorkflow(data, "api");
673
+ const body = warnings.length > 0 ? { ...workflow, warnings } : workflow;
674
+ await recordAcceptedWorkflowOperation(claim, {
675
+ workflowId: workflow.id,
676
+ status: 201,
677
+ body,
678
+ });
679
+ return body;
680
+ });
681
+ } catch (err) {
682
+ // A concurrent request with this key committed first; nothing of this one did.
683
+ if (err instanceof IdempotencyKeyTakenError) {
684
+ if (await replayAcceptedOperation(res, claim, replayCreate)) return true;
685
+ }
686
+ throw err;
687
+ }
688
+ createWorkflowRoute.respond(res, 201, saved);
627
689
  return true;
628
690
  }
629
691
 
@@ -858,8 +920,26 @@ export async function handleWorkflows(
858
920
  }
859
921
 
860
922
  if (triggerWorkflowRoute.match(req.method, pathSegments)) {
923
+ const idempotency = readIdempotencyKey(req, res, myAgentId);
924
+ if (idempotency === null) return true;
861
925
  const parsed = await triggerWorkflowRoute.parse(req, res, pathSegments, queryParams);
862
926
  if (!parsed) return true;
927
+ let claim: WorkflowIdempotencyClaim | undefined;
928
+ let keyedBody: Record<string, unknown> | undefined;
929
+ const replayTrigger = (body: unknown) =>
930
+ triggerWorkflowRoute.respond(res, 201, body as { runId: string; skipped: boolean });
931
+ if (idempotency) {
932
+ keyedBody = await parseBody<Record<string, unknown>>(req);
933
+ claim = {
934
+ ...idempotency,
935
+ operation: "workflow.trigger",
936
+ targetId: parsed.params.id,
937
+ fingerprint: workflowRequestFingerprint("workflow.trigger", keyedBody),
938
+ };
939
+ // Before the enabled, cooldown, readiness and trigger-schema checks: an
940
+ // accepted trigger replays even after the workflow changed.
941
+ if (await replayAcceptedOperation(res, claim, replayTrigger)) return true;
942
+ }
863
943
  const workflow = await getWorkflow(parsed.params.id);
864
944
  if (!workflow) {
865
945
  res.writeHead(404);
@@ -870,20 +950,45 @@ export async function handleWorkflows(
870
950
  jsonError(res, "Workflow is disabled", 400);
871
951
  return true;
872
952
  }
873
- const body = await parseBody<Record<string, unknown>>(req);
953
+ // Not `??`: a JSON `null` body is a parsed body too, and reading the
954
+ // already-ended stream again would wait forever.
955
+ const body = idempotency ? keyedBody : await parseBody<Record<string, unknown>>(req);
874
956
  let runId: string;
957
+ let accepted: { runId: string; skipped: boolean } | undefined;
875
958
  try {
876
959
  runId = await startWorkflowExecution(workflow, body, getExecutorRegistry(), {
877
960
  triggerType: "api",
878
961
  requestedByUserId: (await resolveHttpAuditUserId(req, myAgentId)) ?? undefined,
962
+ // Recorded in the run's own transaction, before the walk starts.
963
+ accept: claim
964
+ ? async (run) => {
965
+ const response = { runId: run.runId, skipped: run.skipped };
966
+ await recordAcceptedWorkflowOperation(claim, {
967
+ workflowId: workflow.id,
968
+ runId: run.runId,
969
+ status: 201,
970
+ body: response,
971
+ });
972
+ accepted = response;
973
+ }
974
+ : undefined,
879
975
  });
880
976
  } catch (err) {
881
977
  if (err instanceof TriggerSchemaError) {
882
978
  triggerSchemaErrorResponse(res, err.message, err.validationErrors);
883
979
  return true;
884
980
  }
981
+ // A concurrent request with this key committed its run first; this one
982
+ // created none and walks nothing.
983
+ if (claim && err instanceof IdempotencyKeyTakenError) {
984
+ if (await replayAcceptedOperation(res, claim, replayTrigger)) return true;
985
+ }
885
986
  throw err;
886
987
  }
988
+ if (accepted) {
989
+ triggerWorkflowRoute.respond(res, 201, accepted);
990
+ return true;
991
+ }
887
992
 
888
993
  // Check if skipped due to cooldown
889
994
  const run = await getWorkflowRun(runId);
@@ -0,0 +1,48 @@
1
+ // Worker-side hooks do not load base-prompt, which normally registers these
2
+ // code-default templates.
3
+ import "./session-templates.ts";
4
+ import { resolveTemplateAsync, resolveTemplateFromCode } from "./resolver.ts";
5
+
6
+ const CONTEXT_BOUND_NOTE_TEMPLATE = "system.agent.context.bound_note";
7
+
8
+ /**
9
+ * `contextShare` for the note: " (21% of the 1,000,000-token window)", or ""
10
+ * when the model's window is unknown. Never a percent of the 200k fallback.
11
+ */
12
+ function contextShare(contextTokens: number, model: string | undefined): string {
13
+ if (!model) return "";
14
+ try {
15
+ // Lazy and synchronous: the catalog snapshot is large and the Claude hook
16
+ // process loads this module on every tool call, but renders the note at
17
+ // most once. Sync so in-process sessions render no later than before.
18
+ const { clampContextPercent, getKnownContextWindowSize } =
19
+ require("../utils/context-window.ts") as typeof import("../utils/context-window.ts");
20
+ const window = getKnownContextWindowSize(model);
21
+ const percent = clampContextPercent(contextTokens, window);
22
+ if (!window || percent === null) return "";
23
+ return ` (${Math.round(percent)}% of the ${window.toLocaleString("en-US")}-token window)`;
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Render the one-time context-bound note. `model` is the session's model, for
31
+ * the window share. Returns "" when an operator disabled the template; falls
32
+ * back to the code default if resolution fails.
33
+ */
34
+ export async function renderContextBoundNote(
35
+ contextTokens: number,
36
+ model?: string,
37
+ ): Promise<string> {
38
+ const variables = {
39
+ contextTokens: Math.round(contextTokens).toLocaleString("en-US"),
40
+ contextShare: contextShare(contextTokens, model),
41
+ };
42
+ try {
43
+ const result = await resolveTemplateAsync(CONTEXT_BOUND_NOTE_TEMPLATE, variables);
44
+ return result.skipped ? "" : result.text;
45
+ } catch {
46
+ return resolveTemplateFromCode(CONTEXT_BOUND_NOTE_TEMPLATE, variables).text;
47
+ }
48
+ }
@@ -202,7 +202,7 @@ agent-fs is the shared drive between agents and people. Documents (reports, dige
202
202
  Pages: only for polished artifacts shared with a wider audience or explicitly requested; see \`pages\` skill.
203
203
  A tool a person will use, with data and actions: build an app. See the \`apps\` skill.
204
204
  Link env: \`APP_URL\` for pages, \`MCP_BASE_URL\` for the API, \`AGENT_FS_LIVE_URL\` for files. Report missing variables.
205
- For a file anyone outside the swarm opens, use an \`agent-fs share-create\` link; \`AGENT_FS_LIVE_URL\` links are for signed-in teammates.
205
+ Give people the live app link \`\${AGENT_FS_LIVE_URL}/file/~/<org>/<drive>/<path>\`. Make an \`agent-fs share-create\` link only when the requester asks for one or the reader has no agent-fs login.
206
206
  `,
207
207
  variables: [],
208
208
  category: "system",
@@ -353,6 +353,30 @@ Task: {{task}}{{progressLine}}
353
353
  category: "system",
354
354
  });
355
355
 
356
+ /**
357
+ * One-time note injected into a worker session once its latest main-thread
358
+ * request crosses CONTEXT_BOUND_NOTE_TOKENS (`renderContextBoundNote`, via
359
+ * `src/hooks/context-bound-note.ts`).
360
+ */
361
+ registerTemplate({
362
+ eventType: "system.agent.context.bound_note",
363
+ header: "",
364
+ defaultBody:
365
+ "Context is {{contextTokens}} tokens{{contextShare}}. Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.",
366
+ variables: [
367
+ {
368
+ name: "contextTokens",
369
+ description: "Prompt size of the latest request (input + cache read + cache write tokens)",
370
+ },
371
+ {
372
+ name: "contextShare",
373
+ description:
374
+ 'How full the model\'s context window is, e.g. " (21% of the 1,000,000-token window)". Empty when the window is unknown',
375
+ },
376
+ ],
377
+ category: "system",
378
+ });
379
+
356
380
  /**
357
381
  * First message of a cursor session on the first-message path. Cursor keeps
358
382
  * its own system prompt there, so the swarm's rides on top of the task prompt.
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "nod
2
2
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { contextBoundNoteThreshold, createContextBoundNote } from "../hooks/context-bound-note";
5
6
  import {
6
7
  type AmpMode,
7
8
  type AmpModelSelection,
@@ -44,7 +45,7 @@ import type {
44
45
  * default; the image installs exactly this version and the per-task settings
45
46
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
47
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792756861-g6187a2";
48
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792844927-g360c96";
48
49
 
49
50
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
51
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -442,6 +443,8 @@ interface QueuedSteer {
442
443
  * answer from the model that received it settles the steer.
443
444
  */
444
445
  receiver?: string | null;
446
+ /** The swarm's own note: rides a tool result or is dropped, never replayed as a new turn. */
447
+ internal?: boolean;
445
448
  resolve: (result: SteerDeliveryResult) => void;
446
449
  }
447
450
 
@@ -486,6 +489,8 @@ class AmpSession implements ProviderSession {
486
489
  private steeringSeq = 0;
487
490
  /** Accepted steering not yet consumed, by inbox file name. Each settles exactly once. */
488
491
  private steers = new Map<string, QueuedSteer>();
492
+ /** Fed each top-level request's prompt size; queues the one-time context-bound note. */
493
+ private readonly observeContextBound: (contextTokens: number | undefined) => Promise<void>;
489
494
 
490
495
  constructor(
491
496
  private proc: Bun.Subprocess<"pipe", "pipe", "pipe">,
@@ -523,6 +528,15 @@ class AmpSession implements ProviderSession {
523
528
  });
524
529
  }
525
530
  };
531
+ // The plugin's `tool.result` hook carries queued steering mid-turn; a note
532
+ // no tool result took is dropped at turn end (`onTurnEnd`).
533
+ this.observeContextBound = createContextBoundNote({
534
+ threshold: contextBoundNoteThreshold(run.env),
535
+ // A mode names no model in advance, so only a pinned model gets a window share.
536
+ model: () => run.selection.pin,
537
+ canDeliver: () => !this.inputClosed && !this.aborted,
538
+ inject: (note) => this.queueSteering(note, true),
539
+ });
526
540
  this.completion = this.runSession(prompt);
527
541
  }
528
542
 
@@ -553,13 +567,13 @@ class AmpSession implements ProviderSession {
553
567
  * check and the rename, no turn end can drain the inbox and close input, so
554
568
  * a message is either visible to the next drain or refused.
555
569
  */
556
- private queueSteering(text: string): Promise<SteerDeliveryResult> {
570
+ private queueSteering(text: string, internal = false): Promise<SteerDeliveryResult> {
557
571
  const name = `${String(++this.steeringSeq).padStart(6, "0")}.json`;
558
572
  const temp = join(this.run.steeringInbox, `.${name}.tmp`);
559
573
  writeFileSync(temp, JSON.stringify(text), { mode: 0o600 });
560
574
  renameSync(temp, join(this.run.steeringInbox, name));
561
575
  return new Promise((resolve) => {
562
- this.steers.set(name, { text, via: "inbox", resolve });
576
+ this.steers.set(name, { text, via: "inbox", internal, resolve });
563
577
  });
564
578
  }
565
579
 
@@ -631,8 +645,8 @@ class AmpSession implements ProviderSession {
631
645
  * Claim the messages no tool result carried, with the plugin's rename
632
646
  * protocol (`.json -> .stdin`). They stay open until Amp echoes them.
633
647
  */
634
- private takeSteering(): { name: string; text: string }[] {
635
- const taken: { name: string; text: string }[] = [];
648
+ private takeSteering(): { name: string; text: string; internal?: boolean }[] {
649
+ const taken: { name: string; text: string; internal?: boolean }[] = [];
636
650
  for (const [name, steer] of [...this.steers].sort(([a], [b]) => a.localeCompare(b))) {
637
651
  if (steer.via !== "inbox") continue;
638
652
  try {
@@ -641,7 +655,7 @@ class AmpSession implements ProviderSession {
641
655
  continue; // The plugin took it.
642
656
  }
643
657
  steer.via = "stdin";
644
- taken.push({ name, text: steer.text });
658
+ taken.push({ name, text: steer.text, internal: steer.internal });
645
659
  }
646
660
  return taken;
647
661
  }
@@ -704,11 +718,16 @@ class AmpSession implements ProviderSession {
704
718
  /**
705
719
  * A top-level assistant message with no tool call ends the turn. Steering
706
720
  * that arrived after the last tool result reached no hook, so it goes in as
707
- * the next user message and starts another turn.
721
+ * the next user message and starts another turn. The swarm's own note is
722
+ * withdrawn instead: it must not start a turn.
708
723
  */
709
724
  private onTurnEnd(): void {
710
725
  if (this.inputClosed) return;
711
- for (const { name, text } of this.takeSteering()) {
726
+ for (const { name, text, internal } of this.takeSteering()) {
727
+ if (internal) {
728
+ this.settleSteer(name, { delivered: false, reason: "no tool result carried the note" });
729
+ continue;
730
+ }
712
731
  this.writeInput(text).catch((error) => {
713
732
  this.settleSteer(name, {
714
733
  delivered: false,
@@ -817,6 +836,9 @@ class AmpSession implements ProviderSession {
817
836
  });
818
837
  // The window is not in the stream; the final snapshot after the export carries it.
819
838
  if (used > 0) this.emitContext(used, output, null);
839
+ // Only when a tool result follows to carry the note; the turn end drops
840
+ // a note no tool result carried.
841
+ if (topLevel && calledTool) void this.observeContextBound(input + cacheRead + cacheCreation);
820
842
  }
821
843
  if (topLevel && !calledTool && message.stop_reason !== "tool_use") this.onTurnEnd();
822
844
  }
@@ -17,6 +17,11 @@ import { dirname, join, resolve } from "node:path";
17
17
  import { promisify } from "node:util";
18
18
  import { deriveDefaultRoute, routeCredentialStatus, routeUnsetEnv } from "@desplega/model-routing";
19
19
  import { type Span, trace } from "@opentelemetry/api";
20
+ import {
21
+ contextBoundNoteMarkerPath,
22
+ contextStatePath,
23
+ recordContextState,
24
+ } from "../hooks/context-bound-note";
20
25
  import {
21
26
  type RunStopHookSessionSummaryOpts,
22
27
  runStopHookSessionSummarySubprocess,
@@ -130,10 +135,17 @@ async function writeTaskFile(key: string | number, data: TaskFileData): Promise<
130
135
  }
131
136
 
132
137
  export async function cleanupTaskFile(key: string | number): Promise<void> {
133
- try {
134
- await unlink(getTaskFilePath(key));
135
- } catch {
136
- // File might already be deleted or never created
138
+ const taskFilePath = getTaskFilePath(key);
139
+ for (const path of [
140
+ taskFilePath,
141
+ contextStatePath(taskFilePath),
142
+ contextBoundNoteMarkerPath(taskFilePath),
143
+ ]) {
144
+ try {
145
+ await unlink(path);
146
+ } catch {
147
+ // File might already be deleted or never created
148
+ }
137
149
  }
138
150
  }
139
151
 
@@ -909,6 +921,8 @@ class ClaudeSession implements ProviderSession {
909
921
  private appliedReasoningEffort: ReasoningEffort | null;
910
922
  /** Last non-empty assistant text seen; surfaced as ProviderResult.output — same pattern as pi-mono/claude-managed. */
911
923
  private lastAssistantText = "";
924
+ /** Last value written to the context state file read by the context-bound note hook. */
925
+ private lastContextTokens: number | undefined;
912
926
  /** Per-session stream-json transcript used by the parent-owned session summarizer. */
913
927
  private transcript: string[];
914
928
  /** Keeps the session heartbeat fresh while Claude idles on background tasks. */
@@ -1263,6 +1277,17 @@ class ClaudeSession implements ProviderSession {
1263
1277
  if (normalized.contextWindowSize) this.contextWindowSize = normalized.contextWindowSize;
1264
1278
  if (normalized.cost) setCost(normalized.cost);
1265
1279
  if (normalized.assistantText) this.lastAssistantText = normalized.assistantText;
1280
+ if (
1281
+ normalized.requestContextTokens !== undefined &&
1282
+ normalized.requestContextTokens !== this.lastContextTokens
1283
+ ) {
1284
+ this.lastContextTokens = normalized.requestContextTokens;
1285
+ recordContextState(
1286
+ getTaskFilePath(this.taskFileKey),
1287
+ normalized.requestContextTokens,
1288
+ this.model,
1289
+ );
1290
+ }
1266
1291
  this.transcript.push(...normalized.transcriptEntries);
1267
1292
  for (const event of normalized.events) this.emit(event);
1268
1293
 
@@ -56,6 +56,7 @@ import type {
56
56
  BetaManagedAgentsSessionEvent as SessionEvent,
57
57
  } from "@anthropic-ai/sdk/resources/beta/sessions";
58
58
 
59
+ import { contextBoundNoteThreshold, createContextBoundNote } from "../hooks/context-bound-note";
59
60
  import { checkToolLoop } from "../hooks/tool-loop-detection";
60
61
  import {
61
62
  CONTEXT_FORMULA,
@@ -268,6 +269,12 @@ class ClaudeManagedSession implements ProviderSession {
268
269
  private readonly taskId: string | null;
269
270
  private aborted = false;
270
271
  private settled = false;
272
+ /** Prompt size of the latest model request: input + cache read + cache write. */
273
+ private lastRequestContextTokens: number | undefined;
274
+ /** Fed the latest request's prompt size on each tool call; steers the one-time context-bound note. */
275
+ private readonly observeContextBound: (contextTokens: number | undefined) => Promise<void>;
276
+ /** Tool calls the stream started and has not returned a result for. */
277
+ private readonly openToolCalls = new Set<string>();
271
278
 
272
279
  constructor(
273
280
  client: ManagedAgentsClient,
@@ -286,6 +293,14 @@ class ClaudeManagedSession implements ProviderSession {
286
293
  this.completionPromise = new Promise<ProviderResult>((resolve) => {
287
294
  this.resolveCompletion = resolve;
288
295
  });
296
+ this.observeContextBound = createContextBoundNote({
297
+ threshold: contextBoundNoteThreshold(config.env ?? process.env),
298
+ model: () => this.cost.model,
299
+ // A message with no tool call left to return, or after idle or abort,
300
+ // would start another turn.
301
+ canDeliver: () => !this.settled && !this.aborted && this.openToolCalls.size > 0,
302
+ inject: (note) => this.deliverSteering({ mode: "steer", text: note }),
303
+ });
289
304
 
290
305
  // Phase 5: adapter-side swarm hooks. Lower-latency cancellation poll,
291
306
  // tool-loop detection (the handler also calls `checkToolLoop` on
@@ -497,6 +512,11 @@ class ClaudeManagedSession implements ProviderSession {
497
512
  case "agent.tool_use": {
498
513
  const tu = event as BetaManagedAgentsAgentToolUseEvent;
499
514
  this.runToolLoopCheck(tu.name, tu.input);
515
+ // Only on tool calls: a message after the final reply would start another
516
+ // turn. The stream can send the call before its request ends, so this may
517
+ // read the previous request: at most one request late.
518
+ this.openToolCalls.add(tu.id);
519
+ void this.observeContextBound(this.lastRequestContextTokens);
500
520
  this.emit({
501
521
  type: "tool_start",
502
522
  toolCallId: tu.id,
@@ -509,6 +529,8 @@ class ClaudeManagedSession implements ProviderSession {
509
529
  const tu = event as BetaManagedAgentsAgentMCPToolUseEvent;
510
530
  const fqToolName = `${tu.mcp_server_name}:${tu.name}`;
511
531
  this.runToolLoopCheck(fqToolName, tu.input);
532
+ this.openToolCalls.add(tu.id);
533
+ void this.observeContextBound(this.lastRequestContextTokens);
512
534
  this.emit({
513
535
  type: "tool_start",
514
536
  toolCallId: tu.id,
@@ -519,6 +541,7 @@ class ClaudeManagedSession implements ProviderSession {
519
541
  }
520
542
  case "agent.tool_result": {
521
543
  const tr = event as BetaManagedAgentsAgentToolResultEvent;
544
+ this.openToolCalls.delete(tr.tool_use_id);
522
545
  this.emit({
523
546
  type: "tool_end",
524
547
  toolCallId: tr.tool_use_id,
@@ -533,6 +556,7 @@ class ClaudeManagedSession implements ProviderSession {
533
556
  }
534
557
  case "agent.mcp_tool_result": {
535
558
  const tr = event as BetaManagedAgentsAgentMCPToolResultEvent;
559
+ this.openToolCalls.delete(tr.mcp_tool_use_id);
536
560
  this.emit({
537
561
  type: "tool_end",
538
562
  toolCallId: tr.mcp_tool_use_id,
@@ -571,6 +595,8 @@ class ClaudeManagedSession implements ProviderSession {
571
595
  case "span.model_request_end": {
572
596
  const sp = event as BetaManagedAgentsSpanModelRequestEndEvent;
573
597
  const usage = sp.model_usage;
598
+ this.lastRequestContextTokens =
599
+ usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
574
600
  this.cost.inputTokens = (this.cost.inputTokens ?? 0) + usage.input_tokens;
575
601
  this.cost.outputTokens = (this.cost.outputTokens ?? 0) + usage.output_tokens;
576
602
  this.cost.cacheReadTokens =
@@ -9,6 +9,7 @@ import {
9
9
  type SpawnedProcess,
10
10
  type SpawnOptions,
11
11
  } from "@anthropic-ai/claude-agent-sdk";
12
+ import { recordContextState } from "../hooks/context-bound-note";
12
13
  import type { RunStopHookSessionSummaryOpts } from "../hooks/hook";
13
14
  import { getContextWindowSize } from "../utils/context-window";
14
15
  import {
@@ -316,6 +317,8 @@ class ClaudeSdkSession implements ProviderSession {
316
317
  private contextWindowSize: number;
317
318
  private transcript: string[];
318
319
  private lastAssistantText = "";
320
+ /** Last value written to the context state file read by the context-bound note hook. */
321
+ private lastContextTokens: number | undefined;
319
322
  private lastCost: CostData | undefined;
320
323
  private aborted = false;
321
324
  private abortReason = "cancelled";
@@ -434,6 +437,13 @@ class ClaudeSdkSession implements ProviderSession {
434
437
  if (normalized.contextWindowSize) this.contextWindowSize = normalized.contextWindowSize;
435
438
  if (normalized.cost) this.lastCost = normalized.cost;
436
439
  if (normalized.assistantText) this.lastAssistantText = normalized.assistantText;
440
+ if (
441
+ normalized.requestContextTokens !== undefined &&
442
+ normalized.requestContextTokens !== this.lastContextTokens
443
+ ) {
444
+ this.lastContextTokens = normalized.requestContextTokens;
445
+ recordContextState(this.options.taskFilePath, normalized.requestContextTokens, this.model);
446
+ }
437
447
  this.transcript.push(...normalized.transcriptEntries);
438
448
  for (const event of normalized.events) this.emit(event);
439
449
  trackErrorFromJson(json, this.errorTracker);
@@ -60,6 +60,11 @@ export type NormalizedClaudeMessage = {
60
60
  contextWindowSize?: number;
61
61
  cost?: CostData;
62
62
  assistantText?: string;
63
+ /**
64
+ * Prompt size of the latest main-thread request: input + cache read + cache
65
+ * write tokens. Unset for subagent messages and messages without usage.
66
+ */
67
+ requestContextTokens?: number;
63
68
  isResult: boolean;
64
69
  };
65
70
 
@@ -150,6 +155,7 @@ export function normalizeClaudeMessage(
150
155
  let model: string | undefined;
151
156
  let contextWindowSize: number | undefined;
152
157
  let assistantText: string | undefined;
158
+ let requestContextTokens: number | undefined;
153
159
 
154
160
  if (json.type === "system" && json.subtype === "init" && json.session_id) {
155
161
  sessionId = json.session_id;
@@ -227,6 +233,12 @@ export function normalizeClaudeMessage(
227
233
  outputTokens: usage.output_tokens ?? 0,
228
234
  contextFormula: CONTEXT_FORMULA,
229
235
  });
236
+ if (!json.parent_tool_use_id) {
237
+ requestContextTokens =
238
+ (usage.input_tokens ?? 0) +
239
+ (usage.cache_read_input_tokens ?? 0) +
240
+ (usage.cache_creation_input_tokens ?? 0);
241
+ }
230
242
  }
231
243
  }
232
244
 
@@ -247,6 +259,7 @@ export function normalizeClaudeMessage(
247
259
  contextWindowSize,
248
260
  cost,
249
261
  assistantText,
262
+ requestContextTokens,
250
263
  isResult: json.type === "result",
251
264
  };
252
265
  }