@desplega.ai/agent-swarm 2.5.2 → 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 (236) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
  2. package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
  4. package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
  6. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
  16. package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
  17. package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
  22. package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
  24. package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  26. package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
  27. package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
  28. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  29. package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
  30. package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
  31. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  32. package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
  33. package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
  34. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  35. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  36. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  37. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  38. package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
  39. package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
  40. package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
  41. package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
  42. package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
  43. package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
  44. package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
  45. package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
  46. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  47. package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
  48. package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
  49. package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
  50. package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
  51. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  52. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  53. package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
  54. package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
  55. package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
  56. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  57. package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
  58. package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
  59. package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
  60. package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
  61. package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
  62. package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
  63. package/dist/cli.js +20 -20
  64. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
  65. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  66. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
  67. package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
  68. package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
  69. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
  70. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
  71. package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
  72. package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
  73. package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
  74. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  75. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
  76. package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
  77. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
  78. package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
  79. package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
  80. package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
  81. package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
  82. package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
  83. package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
  84. package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
  85. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  86. package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
  87. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  88. package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
  89. package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
  90. package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
  91. package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
  92. package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  94. package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
  95. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
  96. package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
  97. package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  98. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
  99. package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
  100. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
  101. package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
  102. package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
  103. package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
  104. package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
  105. package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
  106. package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
  107. package/openapi.json +3078 -2424
  108. package/package.json +7 -7
  109. package/plugin/commands/create-pr.md +2 -2
  110. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  111. package/src/be/approval-config-question.ts +389 -0
  112. package/src/be/automation-preflight.ts +37 -3
  113. package/src/be/db/approvals.ts +48 -0
  114. package/src/be/db/session-tree.ts +73 -15
  115. package/src/be/db/skill-search.ts +87 -0
  116. package/src/be/db/tasks/read.ts +29 -2
  117. package/src/be/db/tasks/write.ts +15 -5
  118. package/src/be/db/workflow-idempotency.ts +183 -0
  119. package/src/be/db/workflow-runs.ts +366 -8
  120. package/src/be/db.ts +92 -22
  121. package/src/be/migrations/239_skills_fts.sql +40 -0
  122. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  123. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  124. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  125. package/src/be/sealed-json.ts +1 -0
  126. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  127. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  128. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  129. package/src/be/sql-connections.ts +7 -1
  130. package/src/be/swarm-config-guard.ts +25 -0
  131. package/src/be/users.ts +9 -0
  132. package/src/commands/controller.ts +33 -0
  133. package/src/commands/runner.ts +6 -2
  134. package/src/extensions/apply-task-create.ts +57 -11
  135. package/src/extensions/catalog.generated.json +4 -4
  136. package/src/extensions/contract-types.generated.ts +1 -1
  137. package/src/extensions/contract.ts +25 -0
  138. package/src/extensions/dispatcher.ts +4 -0
  139. package/src/extensions/post-bridge.ts +8 -0
  140. package/src/hooks/context-bound-note.ts +196 -0
  141. package/src/hooks/hook.ts +12 -0
  142. package/src/http/approval-requests.ts +209 -38
  143. package/src/http/config.ts +70 -53
  144. package/src/http/idempotency-key.ts +117 -0
  145. package/src/http/sessions.ts +27 -2
  146. package/src/http/workflows.ts +129 -24
  147. package/src/integrations/chat-sdk/inbound.ts +5 -4
  148. package/src/prompts/context-bound-note.ts +48 -0
  149. package/src/prompts/session-templates.ts +25 -1
  150. package/src/providers/amp-adapter.ts +30 -8
  151. package/src/providers/claude-adapter.ts +29 -4
  152. package/src/providers/claude-managed-adapter.ts +26 -0
  153. package/src/providers/claude-sdk-session.ts +10 -0
  154. package/src/providers/claude-session-events.ts +13 -0
  155. package/src/providers/codex-adapter.ts +12 -0
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/opencode-adapter.ts +18 -0
  158. package/src/providers/pi-mono-adapter.ts +35 -0
  159. package/src/runtime-providers/docker.ts +6 -2
  160. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  161. package/src/tests/amp-adapter.test.ts +97 -0
  162. package/src/tests/approval-config-question.test.ts +702 -0
  163. package/src/tests/approval-requests.test.ts +10 -7
  164. package/src/tests/approval-sweeps.test.ts +12 -6
  165. package/src/tests/base-prompt.test.ts +1 -1
  166. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  167. package/src/tests/claude-hook-output.test.ts +79 -1
  168. package/src/tests/claude-managed-adapter.test.ts +275 -0
  169. package/src/tests/codex-adapter.test.ts +163 -0
  170. package/src/tests/context-bound-note.test.ts +269 -0
  171. package/src/tests/dsh-adapter.test.ts +1 -1
  172. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  173. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  174. package/src/tests/extensions-pre-task.test.ts +273 -0
  175. package/src/tests/fetch-resolved-env.test.ts +17 -0
  176. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  177. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  178. package/src/tests/fixtures/fake-amp.ts +22 -8
  179. package/src/tests/fixtures/held-template-render.ts +42 -0
  180. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  181. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  182. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  183. package/src/tests/openapi-route-headers.test.ts +2 -0
  184. package/src/tests/opencode-adapter.test.ts +134 -0
  185. package/src/tests/pi-mono-adapter.test.ts +105 -0
  186. package/src/tests/prompt-template-session.test.ts +4 -3
  187. package/src/tests/runner-push-delivery.test.ts +25 -0
  188. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  189. package/src/tests/runtime-providers-docker.test.ts +2 -0
  190. package/src/tests/script-connections.test.ts +9 -0
  191. package/src/tests/script-run-config-scope.test.ts +91 -3
  192. package/src/tests/session-logs.test.ts +70 -0
  193. package/src/tests/sessions-tree.test.ts +205 -1
  194. package/src/tests/skill-search-long-content.test.ts +193 -0
  195. package/src/tests/skill-search.test.ts +173 -22
  196. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  197. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  198. package/src/tests/workflow-idempotency.test.ts +915 -0
  199. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  200. package/src/tools/request-human-input.ts +27 -5
  201. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  202. package/src/types.ts +7 -0
  203. package/src/utils/context-window.ts +8 -6
  204. package/src/workflows/approval-cancel.ts +15 -13
  205. package/src/workflows/approval-notifications.ts +15 -4
  206. package/src/workflows/checkpoint.ts +72 -35
  207. package/src/workflows/engine.ts +262 -93
  208. package/src/workflows/executors/base.ts +6 -0
  209. package/src/workflows/executors/foreach.ts +2 -2
  210. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  211. package/src/workflows/executors/sub-workflow.ts +45 -16
  212. package/src/workflows/foreach-driver.ts +10 -3
  213. package/src/workflows/foreach-join.ts +29 -6
  214. package/src/workflows/recovery.ts +68 -1
  215. package/src/workflows/resume.ts +228 -77
  216. package/src/workflows/retry-poller.ts +74 -7
  217. package/src/workflows/task-step-routing.ts +29 -7
  218. package/templates/ai-toolbox.manifest.json +8 -8
  219. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  220. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  221. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  222. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  223. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  224. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  225. package/templates/skills/agent-browser/SKILL.md +4 -4
  226. package/templates/skills/agent-browser/content.md +4 -4
  227. package/templates/skills/code-quality/config.json +1 -1
  228. package/templates/skills/code-quality/content.md +2 -2
  229. package/templates/skills/delegate-work/content.md +29 -25
  230. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  231. package/templates/skills/qa/content.md +2 -2
  232. package/templates/skills/researching/content.md +2 -0
  233. package/templates/skills/turso-interaction/content.md +1 -1
  234. package/templates/skills/work-on-task/SKILL.md +1 -1
  235. package/templates/skills/work-on-task/content.md +1 -1
  236. package/templates/skills/workflow-iterate/content.md +8 -1
@@ -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
  }
@@ -31,6 +31,7 @@
31
31
  import { existsSync as nodeExistsSync } from "node:fs";
32
32
  import { join } from "node:path";
33
33
  import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/memory/raters/llm";
34
+ import { contextBoundNoteThreshold, createContextBoundNote } from "../hooks/context-bound-note";
34
35
  import { getApiKey } from "../utils/api-key";
35
36
  import {
36
37
  CONTEXT_FORMULA,
@@ -995,6 +996,8 @@ export class CodexSession implements ProviderSession {
995
996
  * summary path which materially relies on this ordering for testability.
996
997
  */
997
998
  private pendingResult: ProviderResult | null = null;
999
+ /** Fed each main-thread request's prompt size; steers the one-time context-bound note. */
1000
+ private readonly observeContextBound: (contextTokens: number | undefined) => Promise<void>;
998
1001
 
999
1002
  constructor(
1000
1003
  thread: CodexThread,
@@ -1015,6 +1018,13 @@ export class CodexSession implements ProviderSession {
1015
1018
  skillsDir ?? process.env.CODEX_SKILLS_DIR ?? join(codexUserHome(), ".codex", "skills");
1016
1019
  this.summarizeDeps = summarizeDeps;
1017
1020
  this.logFileHandle = Bun.file(config.logFile).writer();
1021
+ this.observeContextBound = createContextBoundNote({
1022
+ threshold: contextBoundNoteThreshold(config.env ?? process.env),
1023
+ model: () => this.resolvedModel,
1024
+ // `turn/steer` only joins the active turn, so a late note never starts one.
1025
+ canDeliver: () => this.acceptsSteering && !this.settled && !this.aborted,
1026
+ inject: (note) => this.deliverSteering({ mode: "steer", text: note }),
1027
+ });
1018
1028
  // Mirrors the resolution `buildCodexConfig` already applied when
1019
1029
  // constructing `mergedConfig` — cheap, pure, and keeps the applied-level
1020
1030
  // feedback co-located with `settle()` rather than threading an extra
@@ -1425,6 +1435,8 @@ export class CodexSession implements ProviderSession {
1425
1435
  break;
1426
1436
  case "context.updated":
1427
1437
  this.emitContextUsage(event.usage, event.contextWindow ?? this.contextWindow);
1438
+ // Codex input tokens already include the cached read and write subsets.
1439
+ void this.observeContextBound(event.usage.input_tokens);
1428
1440
  break;
1429
1441
  case "error": {
1430
1442
  const { message } = this.formatTerminalError(event.message, event.codexErrorInfo);
@@ -37,7 +37,7 @@ import type {
37
37
  } from "./types";
38
38
 
39
39
  // npm's `latest` is older and lacks the JSON/stdin contract used here.
40
- export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-rc.2";
40
+ export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-rc.3";
41
41
 
42
42
  export function checkDshCredentials(env: Record<string, string | undefined>): CredStatus {
43
43
  return env.DEEPSEEK_API_KEY?.trim() || env.OPENROUTER_API_KEY?.trim()
@@ -21,6 +21,7 @@ import {
21
21
  providerKeyVars,
22
22
  } from "@desplega/model-catalog";
23
23
  import type { OpenCode, OpenCodeEvent } from "@opencode/sdk";
24
+ import { contextBoundNoteThreshold, createContextBoundNote } from "../hooks/context-bound-note";
24
25
  import {
25
26
  CONTEXT_FORMULA,
26
27
  clampContextPercent,
@@ -534,6 +535,8 @@ export class OpencodeSession implements ProviderSession {
534
535
  // Assistant text by message, in part order; the runner keeps the last emit.
535
536
  private textByMessage = new Map<string, string[]>();
536
537
  private transcript: string[] = [];
538
+ /** Fed each step's prompt size; steers the one-time context-bound note. */
539
+ private readonly observeContextBound: (contextTokens: number | undefined) => Promise<void>;
537
540
 
538
541
  constructor(
539
542
  private readonly host: OpenCode.Interface,
@@ -547,6 +550,20 @@ export class OpencodeSession implements ProviderSession {
547
550
  this.completionPromise = new Promise<ProviderResult>((resolve) => {
548
551
  this.completionResolve = resolve;
549
552
  });
553
+ this.observeContextBound = createContextBoundNote({
554
+ threshold: contextBoundNoteThreshold(swarm.env ?? process.env),
555
+ model: () => this.model,
556
+ canDeliver: () => !this.completed && !this.aborted,
557
+ // A steer is promoted at the next step boundary mid-work; `resume: false`
558
+ // leaves an idle session idle. Same route as OpenCode's own Plan reminder.
559
+ inject: (note) =>
560
+ host.sessions.synthetic({
561
+ sessionID: _sessionId,
562
+ text: note,
563
+ delivery: "steer",
564
+ resume: false,
565
+ }),
566
+ });
550
567
  }
551
568
 
552
569
  get sessionId(): string {
@@ -685,6 +702,7 @@ export class OpencodeSession implements ProviderSession {
685
702
  // Per-step usage drives the dashboard's context gauge.
686
703
  const t = ev.data.tokens;
687
704
  const contextUsed = t.input + t.cache.read + t.cache.write + t.output;
705
+ void this.observeContextBound(t.input + t.cache.read + t.cache.write);
688
706
  const contextTotal = getContextWindowSize(this.model || "default");
689
707
  if (contextTotal > 0 && contextUsed > 0) {
690
708
  this.emit({
@@ -61,6 +61,7 @@ import { type TSchema, Type } from "typebox";
61
61
  import "./pi-codemode-runtime";
62
62
  // Registers the reprompt template in the code registry (the runner loads it too).
63
63
  import "../commands/templates";
64
+ import { contextBoundNoteThreshold, createContextBoundNote } from "../hooks/context-bound-note";
64
65
  import { resolveTemplateAsync } from "../prompts/resolver";
65
66
  import { CORE_TOOLS } from "../tools/tool-config";
66
67
  import { classifyAwsSdkError } from "../utils/aws-error-classifier";
@@ -907,6 +908,17 @@ function safeLabel(value: unknown): string {
907
908
  return typeof value === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(value) ? value : "other";
908
909
  }
909
910
 
911
+ /** Prompt size of the request behind an assistant message: input + cache read + cache write. */
912
+ function requestContextTokens(message: unknown): number | undefined {
913
+ const usage = (
914
+ message as { usage?: { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown } }
915
+ )?.usage;
916
+ if (typeof usage?.input !== "number") return undefined;
917
+ const cacheRead = typeof usage.cacheRead === "number" ? usage.cacheRead : 0;
918
+ const cacheWrite = typeof usage.cacheWrite === "number" ? usage.cacheWrite : 0;
919
+ return usage.input + cacheRead + cacheWrite;
920
+ }
921
+
910
922
  function describeAssistantTurn(message: unknown): AssistantTurnShape {
911
923
  const msg = (message ?? {}) as {
912
924
  content?: unknown;
@@ -1008,6 +1020,14 @@ export class PiMonoSession implements ProviderSession {
1008
1020
  private abortRequested = false;
1009
1021
  /** Removes this session's own models.json once the session ends. */
1010
1022
  private disposeModels: () => Promise<void>;
1023
+ /** Fed each assistant request's prompt size; steers the one-time context-bound note. */
1024
+ private readonly observeContextBound: (contextTokens: number | undefined) => Promise<void>;
1025
+ /**
1026
+ * From a tool-call reply until its turn ends. Pi reads steering after the
1027
+ * turn's tool results; a steer queued later waits for the next reply, and
1028
+ * when that is the final one it starts another turn.
1029
+ */
1030
+ private toolTurnOpen = false;
1011
1031
 
1012
1032
  constructor(
1013
1033
  agentSession: AgentSession,
@@ -1024,6 +1044,12 @@ export class PiMonoSession implements ProviderSession {
1024
1044
  this.logFileHandle = Bun.file(config.logFile).writer();
1025
1045
  this._sessionId = agentSession.sessionId;
1026
1046
  this.sessionStartedAt = Date.now();
1047
+ this.observeContextBound = createContextBoundNote({
1048
+ threshold: contextBoundNoteThreshold(config.env ?? process.env),
1049
+ model: () => this.reportedModel(),
1050
+ canDeliver: () => this.toolTurnOpen && !this.sessionEnded && !this.abortRequested,
1051
+ inject: (note) => this.deliverSteering({ mode: "steer", text: note }),
1052
+ });
1027
1053
 
1028
1054
  // Emit session_init immediately
1029
1055
  const piVersion = readPkgVersion("@earendil-works/pi-coding-agent");
@@ -1098,6 +1124,12 @@ export class PiMonoSession implements ProviderSession {
1098
1124
  }
1099
1125
  // A successful assistant turn means any prior error has recovered.
1100
1126
  this.terminalError = null;
1127
+ // Only on tool-call turns: a steer after the final reply would start
1128
+ // another turn.
1129
+ if (turn.hasToolCall) {
1130
+ this.toolTurnOpen = true;
1131
+ void this.observeContextBound(requestContextTokens(event.message));
1132
+ }
1101
1133
  if (!turn.hasText && !turn.hasToolCall) {
1102
1134
  // Nothing else records these turns: no text and no tool call leaves
1103
1135
  // no trace in session logs. Block types only, never content.
@@ -1152,6 +1184,9 @@ export class PiMonoSession implements ProviderSession {
1152
1184
  }
1153
1185
  break;
1154
1186
  }
1187
+ case "turn_end":
1188
+ this.toolTurnOpen = false;
1189
+ break;
1155
1190
  case "tool_execution_start": {
1156
1191
  const status = (event.args as { status?: unknown } | null)?.status;
1157
1192
  if (
@@ -177,7 +177,9 @@ export class DockerRuntimeProvider implements RuntimeProvider {
177
177
  try {
178
178
  await dockerApi(target, "POST", `/containers/${created.Id}/start`);
179
179
  } catch (err) {
180
- await dockerApi(target, "DELETE", `/containers/${created.Id}?force=true`).catch(() => {});
180
+ await dockerApi(target, "DELETE", `/containers/${created.Id}?force=true&v=true`).catch(
181
+ () => {},
182
+ );
181
183
  throw err;
182
184
  }
183
185
  return { kind: "docker", externalId: created.Id };
@@ -188,7 +190,9 @@ export class DockerRuntimeProvider implements RuntimeProvider {
188
190
 
189
191
  async terminate(target: RuntimeTargetConfig, handle: RuntimeHandle): Promise<void> {
190
192
  try {
191
- await dockerApi(target, "DELETE", `/containers/${handle.externalId}?force=true`);
193
+ // v=true also removes the container's anonymous volumes (the worker image declares
194
+ // VOLUME /logs); named volumes are never removed by it.
195
+ await dockerApi(target, "DELETE", `/containers/${handle.externalId}?force=true&v=true`);
192
196
  } catch (err) {
193
197
  if (!(err instanceof DockerApiError) || err.statusCode !== 404) throw err;
194
198
  }
@@ -402,6 +402,36 @@ declare module "swarm-extension" {
402
402
  origin: TaskCreateOrigin;
403
403
  requestInfo?: RequestInfo;
404
404
  }
405
+ /** A pre.task.create hook blocked a task before it was created. */
406
+ export interface TaskBlockedEvent {
407
+ /** The extension that blocked, or whose model/agent rewrite was rejected. */
408
+ extension: {
409
+ id: string;
410
+ name: string;
411
+ };
412
+ /** The scrubbed block reason. */
413
+ reason: string;
414
+ origin: TaskCreateOrigin;
415
+ source?: AgentTask["source"];
416
+ requester: {
417
+ userId?: string;
418
+ agentId?: string;
419
+ slackUserId?: string;
420
+ };
421
+ context?: {
422
+ contextKey?: string;
423
+ scheduleId?: string;
424
+ workflowRunId?: string;
425
+ workflowRunStepId?: string;
426
+ parentTaskId?: string;
427
+ vcsRepo?: string;
428
+ vcsNumber?: number;
429
+ slackChannelId?: string;
430
+ slackThreadTs?: string;
431
+ };
432
+ /** The original task description, scrubbed and cut to 500 characters. */
433
+ description: string;
434
+ }
405
435
  export type TaskCreateModify = Partial<
406
436
  Pick<
407
437
  CreateTaskOptions,
@@ -704,6 +734,11 @@ declare module "swarm-extension" {
704
734
  modify: never;
705
735
  result: void;
706
736
  };
737
+ "post.task.blocked": {
738
+ event: TaskBlockedEvent;
739
+ modify: never;
740
+ result: void;
741
+ };
707
742
  "post.task.completed": {
708
743
  event: TaskCompletedEvent;
709
744
  modify: never;
@@ -8,6 +8,7 @@ import {
8
8
  validateProviderCredentials,
9
9
  } from "../commands/provider-credentials";
10
10
  import { recomputeSessionCost } from "../http/session-cost-recompute";
11
+ import { renderContextBoundNote } from "../prompts/context-bound-note";
11
12
  import { createProviderAdapter } from "../providers";
12
13
  import {
13
14
  AMP_PACKAGE,
@@ -31,6 +32,7 @@ import { getModelAwareCredentialVars } from "../utils/credentials";
31
32
  import { resolveHarnessProvider } from "../utils/harness-provider";
32
33
  import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
33
34
  import { ampPricingLookup } from "./amp-pricing-helpers";
35
+ import { holdTemplateRender } from "./fixtures/held-template-render";
34
36
 
35
37
  const KEY = "sgamp_test_credential_value";
36
38
 
@@ -769,6 +771,101 @@ describe("amp session", () => {
769
771
  }, 20_000);
770
772
  });
771
773
 
774
+ describe("amp context-bound note", () => {
775
+ // Each fake request is 4 input + 200 cache read + 1000 cache write = 1204 tokens.
776
+ async function toolResults(
777
+ mode: string,
778
+ env: Record<string, string>,
779
+ overrides: Partial<ProviderSessionConfig> = {},
780
+ ) {
781
+ const { config } = await fixture(mode, overrides, env);
782
+ const { events, result } = await runToCompletion(config);
783
+ const results = events.flatMap((e) => (e.type === "tool_end" ? [e.result] : []));
784
+ return { results, result };
785
+ }
786
+
787
+ test("queues the note into the first tool result at the threshold, then never again", async () => {
788
+ const { results, result } = await toolResults("steer-twice", {
789
+ CONTEXT_BOUND_NOTE_TOKENS: "1204",
790
+ });
791
+ expect(results).toEqual([`2\n\n${await renderContextBoundNote(1204)}`, "2"]);
792
+ // Carried inside the turn: no extra turn replays it.
793
+ expect(result).toMatchObject({ isError: false, output: "DONE1" });
794
+ expect(result.cost?.numTurns).toBe(1);
795
+ });
796
+
797
+ test("a pinned model gets the window share; a mode names no model and gets none", async () => {
798
+ const pin = "anthropic/claude-sonnet-4-6";
799
+ const pinned = await toolResults(
800
+ "steer-twice",
801
+ { CONTEXT_BOUND_NOTE_TOKENS: "1204" },
802
+ { model: pin },
803
+ );
804
+ const note = await renderContextBoundNote(1204, pin);
805
+ expect(note).toContain("of the 1,000,000-token window)");
806
+ expect(pinned.results).toEqual([`2\n\n${note}`, "2"]);
807
+ const mode = await toolResults("steer-twice", { CONTEXT_BOUND_NOTE_TOKENS: "1204" });
808
+ expect(mode.results[0]).not.toContain("token window");
809
+ });
810
+
811
+ test("no-op below the threshold and when disabled at 0", async () => {
812
+ for (const threshold of ["1205", "0"]) {
813
+ const { results } = await toolResults("steer-twice", {
814
+ CONTEXT_BOUND_NOTE_TOKENS: threshold,
815
+ });
816
+ expect(results).toEqual(["2", "2"]);
817
+ }
818
+ });
819
+
820
+ test("fails open on missing or corrupt request usage", async () => {
821
+ for (const usage of ["missing", "corrupt"]) {
822
+ const { results, result } = await toolResults("steer", {
823
+ CONTEXT_BOUND_NOTE_TOKENS: "1",
824
+ AMP_TEST_REQUEST_USAGE: usage,
825
+ });
826
+ expect(results).toEqual(["2"]);
827
+ expect(result).toMatchObject({ isError: false, output: "DONE1" });
828
+ }
829
+ });
830
+
831
+ test("a final reply with no tool call never queues it, so no new turn starts", async () => {
832
+ const { results, result } = await toolResults("steer-no-tool", {
833
+ CONTEXT_BOUND_NOTE_TOKENS: "1",
834
+ });
835
+ expect(results).toEqual([]);
836
+ expect(result).toMatchObject({ isError: false, output: "DONE1" });
837
+ expect(result.cost?.numTurns).toBe(1);
838
+ });
839
+
840
+ test("a note rendered after the last tool result is dropped, not replayed as a new turn", async () => {
841
+ const held = holdTemplateRender();
842
+ try {
843
+ const { config } = await fixture(
844
+ "steer",
845
+ {},
846
+ {
847
+ CONTEXT_BOUND_NOTE_TOKENS: "1204",
848
+ AMP_TEST_POST_RESULT_DELAY_MS: "150",
849
+ },
850
+ );
851
+ const session = await new AmpAdapter().createSession(config);
852
+ const results: unknown[] = [];
853
+ session.onEvent((event) => {
854
+ if (event.type !== "tool_end") return;
855
+ results.push(event.result);
856
+ // Ready while the final reply is pending.
857
+ held.release();
858
+ });
859
+ const result = await session.waitForCompletion();
860
+ expect(results).toEqual(["2"]);
861
+ expect(result).toMatchObject({ isError: false, output: "DONE1" });
862
+ expect(result.cost?.numTurns).toBe(1);
863
+ } finally {
864
+ held.stop();
865
+ }
866
+ });
867
+ });
868
+
772
869
  describe("amp live test", () => {
773
870
  const env = async (mode: string) => {
774
871
  const { config } = await fixture(mode);