@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
@@ -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);
@@ -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);
@@ -418,7 +418,7 @@ describe("Approval Requests", () => {
418
418
  expect(result).toBeNull();
419
419
  });
420
420
 
421
- test("does not resolve a workflow approval after its run and HITL step are cancelled", async () => {
421
+ test("does not resolve a workflow approval after its run is cancelled", async () => {
422
422
  const workflow = await createWorkflow({
423
423
  name: `approval-cancel-${crypto.randomUUID()}`,
424
424
  definition: { nodes: [] },
@@ -432,11 +432,12 @@ describe("Approval Requests", () => {
432
432
  nodeId: "approval",
433
433
  nodeType: "human-in-the-loop",
434
434
  });
435
- await updateWorkflowRun(runId, { status: "cancelled" });
436
- await updateWorkflowRunStep(stepId, { status: "cancelled" });
435
+ // Asked while the run was live; the run was cancelled after, leaving
436
+ // the request pending (a cancelled step's trigger would close it).
437
437
  const approval = await createApprovalRequest(
438
438
  makeApprovalData({ workflowRunId: runId, workflowRunStepId: stepId }),
439
439
  );
440
+ await updateWorkflowRun(runId, { status: "cancelled" });
440
441
 
441
442
  const result = await resolveApprovalRequest(
442
443
  approval.id,
@@ -1864,15 +1865,17 @@ describe("Approval Requests", () => {
1864
1865
 
1865
1866
  test("cancel on a waiting run cancels the run, its step, and its task", async () => {
1866
1867
  const { runId, stepId, approvalId } = await makeRun("waiting");
1868
+ // Linked directly: the engine binds a step's task while the step is
1869
+ // still running, and this step is already parked.
1867
1870
  const task = await createTaskExtended("step task", {
1868
1871
  agentId: owner.id,
1869
1872
  source: "mcp",
1870
1873
  workflowRunId: runId,
1871
- workflowRunStepId: stepId,
1872
1874
  });
1873
- await getDbClient().run("UPDATE agent_tasks SET status = 'in_progress' WHERE id = ?", [
1874
- task.id,
1875
- ]);
1875
+ await getDbClient().run(
1876
+ "UPDATE agent_tasks SET status = 'in_progress', workflowRunStepId = ? WHERE id = ?",
1877
+ [stepId, task.id],
1878
+ );
1876
1879
 
1877
1880
  const res = await post(
1878
1881
  `/api/approval-requests/${approvalId}/cancel`,
@@ -223,15 +223,17 @@ describe("approval sweeps", () => {
223
223
 
224
224
  test("cancels the waiting run, its step, and its linked task", async () => {
225
225
  const { runId, stepId } = await makeRun("waiting");
226
+ // Linked directly: the engine binds a step's task while the step is
227
+ // still running, and this step is already parked.
226
228
  const task = await createTaskExtended("step task", {
227
229
  agentId,
228
230
  source: "mcp",
229
231
  workflowRunId: runId,
230
- workflowRunStepId: stepId,
231
232
  });
232
- await getDbClient().run("UPDATE agent_tasks SET status = 'in_progress' WHERE id = ?", [
233
- task.id,
234
- ]);
233
+ await getDbClient().run(
234
+ "UPDATE agent_tasks SET status = 'in_progress', workflowRunStepId = ? WHERE id = ?",
235
+ [stepId, task.id],
236
+ );
235
237
  const id = await makeRequest({ ageDays: 8, workflowRunId: runId, workflowRunStepId: stepId });
236
238
 
237
239
  const result = await autoCancelStaleApprovalRequests();
@@ -300,8 +302,10 @@ describe("approval sweeps", () => {
300
302
  });
301
303
 
302
304
  test("leaves a failed run as it is", async () => {
303
- const { runId, stepId } = await makeRun("failed");
305
+ // The request was asked while the run was live; the run failed after.
306
+ const { runId, stepId } = await makeRun("waiting");
304
307
  const id = await makeRequest({ ageDays: 8, workflowRunId: runId, workflowRunStepId: stepId });
308
+ await updateWorkflowRun(runId, { status: "failed" });
305
309
 
306
310
  const result = await autoCancelStaleApprovalRequests();
307
311
 
@@ -415,13 +419,15 @@ describe("approval sweeps", () => {
415
419
  });
416
420
 
417
421
  test("times out a request on a completed run", async () => {
418
- const { runId, stepId } = await makeRun("completed");
422
+ // The request was asked while the run was live; the run completed after.
423
+ const { runId, stepId } = await makeRun("waiting");
419
424
  const id = await makeRequest({
420
425
  ageDays: 0,
421
426
  expiresInMs: -60_000,
422
427
  workflowRunId: runId,
423
428
  workflowRunStepId: stepId,
424
429
  });
430
+ await updateWorkflowRun(runId, { status: "completed" });
425
431
  await timeoutExpiredApprovalRequests();
426
432
  expect((await getApprovalRequestById(id))!.status).toBe("timeout");
427
433
  });
@@ -490,7 +490,7 @@ describe("getBasePrompt: outputs section", () => {
490
490
  expect(result).toContain("## Outputs");
491
491
  expect(result).toContain(AGENT_FS_LINE);
492
492
  expect(result).toContain(
493
- "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.",
493
+ `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.`,
494
494
  );
495
495
  expect(result).not.toContain(NO_AGENT_FS_LINE);
496
496
  });
@@ -82,7 +82,11 @@ afterAll(async () => {
82
82
  await rm(projectDir, { recursive: true, force: true });
83
83
  });
84
84
 
85
- async function runHook(event: Record<string, unknown>, taskId: string) {
85
+ async function runHook(
86
+ event: Record<string, unknown>,
87
+ taskId: string,
88
+ extraEnv: Record<string, string> = {},
89
+ ) {
86
90
  const taskFile = join(projectDir, `task-${taskId}.json`);
87
91
  await Bun.write(
88
92
  taskFile,
@@ -92,6 +96,8 @@ async function runHook(event: Record<string, unknown>, taskId: string) {
92
96
  ...process.env,
93
97
  CLAUDE_PROJECT_DIR: projectDir,
94
98
  TASK_FILE: taskFile,
99
+ CONTEXT_BOUND_NOTE_TOKENS: undefined,
100
+ ...extraEnv,
95
101
  };
96
102
  return runChild(["bun", HOOK_PATH], {
97
103
  cwd: projectDir,
@@ -107,6 +113,9 @@ function parseSoleJson(stdout: string): Record<string, unknown> {
107
113
  return JSON.parse(trimmed) as Record<string, unknown>;
108
114
  }
109
115
 
116
+ /** The note is off unless CONTEXT_BOUND_NOTE_TOKENS is set. */
117
+ const noteOn = { CONTEXT_BOUND_NOTE_TOKENS: "200000" };
118
+
110
119
  const bashTouch = {
111
120
  tool_name: "Bash",
112
121
  tool_input: { command: "touch /tmp/probe" },
@@ -230,4 +239,73 @@ describe("claude hook stdout", () => {
230
239
  },
231
240
  CHILD_PROCESS_TEST_BUDGET_MS,
232
241
  );
242
+
243
+ test(
244
+ "worker context-bound note fires once as PostToolUse additionalContext",
245
+ async () => {
246
+ Object.assign(state, { isLead: false, cancelledTaskId: null, shouldBlockPolling: false });
247
+ const taskId = crypto.randomUUID();
248
+ await Bun.write(
249
+ join(projectDir, `task-${taskId}.json.context.json`),
250
+ JSON.stringify({ contextTokens: 250_000, model: "claude-opus-5-5" }),
251
+ );
252
+ const event = { hook_event_name: "PostToolUse", ...bashTouch, tool_response: {} };
253
+
254
+ const first = await runHook(event, taskId, noteOn);
255
+ const out = parseSoleJson(first.stdout) as { hookSpecificOutput: Record<string, string> };
256
+ expect(out.hookSpecificOutput.hookEventName).toBe("PostToolUse");
257
+ expect(out.hookSpecificOutput.additionalContext).toStartWith(
258
+ "Context is 250,000 tokens (25% of the 1,000,000-token window). Keep this task bounded",
259
+ );
260
+
261
+ const second = await runHook(event, taskId, noteOn);
262
+ expect(second.exitCode).toBe(0);
263
+ expect(second.stdout.trim()).toBe("");
264
+ },
265
+ CHILD_PROCESS_TEST_BUDGET_MS * 2,
266
+ );
267
+
268
+ test(
269
+ "a subagent tool call does not consume the context-bound note",
270
+ async () => {
271
+ Object.assign(state, { isLead: false, cancelledTaskId: null, shouldBlockPolling: false });
272
+ const taskId = crypto.randomUUID();
273
+ await Bun.write(
274
+ join(projectDir, `task-${taskId}.json.context.json`),
275
+ JSON.stringify({ contextTokens: 250_000, model: "made-up-model" }),
276
+ );
277
+ const event = { hook_event_name: "PostToolUse", ...bashTouch, tool_response: {} };
278
+
279
+ const child = await runHook({ ...event, agent_id: "child" }, taskId, noteOn);
280
+ expect(child.exitCode).toBe(0);
281
+ expect(child.stdout.trim()).toBe("");
282
+
283
+ // Unknown model: no window share rather than a percent of the 200k fallback.
284
+ const main = await runHook(event, taskId, noteOn);
285
+ const out = parseSoleJson(main.stdout) as { hookSpecificOutput: Record<string, string> };
286
+ expect(out.hookSpecificOutput.additionalContext).toStartWith(
287
+ "Context is 250,000 tokens. Keep this task bounded",
288
+ );
289
+ },
290
+ CHILD_PROCESS_TEST_BUDGET_MS * 2,
291
+ );
292
+
293
+ test(
294
+ "the context-bound note is off when CONTEXT_BOUND_NOTE_TOKENS is unset",
295
+ async () => {
296
+ Object.assign(state, { isLead: false, cancelledTaskId: null, shouldBlockPolling: false });
297
+ const taskId = crypto.randomUUID();
298
+ await Bun.write(
299
+ join(projectDir, `task-${taskId}.json.context.json`),
300
+ JSON.stringify({ contextTokens: 900_000, model: "claude-opus-5-5" }),
301
+ );
302
+ const result = await runHook(
303
+ { hook_event_name: "PostToolUse", ...bashTouch, tool_response: {} },
304
+ taskId,
305
+ );
306
+ expect(result.exitCode).toBe(0);
307
+ expect(result.stdout.trim()).toBe("");
308
+ },
309
+ CHILD_PROCESS_TEST_BUDGET_MS,
310
+ );
233
311
  });