@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
@@ -0,0 +1,117 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ findAcceptedWorkflowOperation,
5
+ type WorkflowIdempotencyClaim,
6
+ } from "../be/db/workflow-idempotency";
7
+ import { getRequestAuth } from "../utils/request-auth-context";
8
+ import { jsonError } from "./utils";
9
+
10
+ /** Visible ASCII, at most 255 characters: room for a UUID, a ULID or a client's own scheme. */
11
+ const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,255}$/;
12
+
13
+ /** Set on a response answered from an earlier accepted request. */
14
+ export const IDEMPOTENT_REPLAYED_HEADER = "Idempotent-Replayed";
15
+
16
+ /** OpenAPI declaration of the optional `Idempotency-Key` request header. */
17
+ export const idempotencyKeyHeader = (accepted: string) =>
18
+ z.object({
19
+ "Idempotency-Key": z
20
+ .string()
21
+ .regex(IDEMPOTENCY_KEY_PATTERN)
22
+ .optional()
23
+ .describe(
24
+ "Optional client-generated key, 1-255 visible ASCII characters (a UUID works). " +
25
+ `The first request the server accepts under a key ${accepted}; a retry with the same key ` +
26
+ "and an equivalent body gets that request's status and body back (with " +
27
+ `\`${IDEMPOTENT_REPLAYED_HEADER}: true\`) and changes nothing. The key is scoped to the ` +
28
+ "caller's identity and this operation; it is not a credential. The same key with a " +
29
+ "different body is a 409. A request rejected before it is accepted (validation, auth, " +
30
+ "not found, disabled) does not use up the key. Accepted keys do not expire.",
31
+ ),
32
+ });
33
+
34
+ /** A request that sent a usable `Idempotency-Key`. */
35
+ export interface IdempotencyKeyRequest {
36
+ /** Stable identity of the authenticated caller, never a credential value. */
37
+ principal: string;
38
+ key: string;
39
+ }
40
+
41
+ /**
42
+ * The caller identity a key is scoped to. A user or session token names its
43
+ * user or agent; rotating the token keeps the scope. Requests on the swarm API
44
+ * key are the operator, or the agent named by X-Agent-ID: every worker shares
45
+ * that key, so this keeps their keys apart (it is not an access boundary; the
46
+ * swarm key may act as any agent). A guest page session has no identity.
47
+ */
48
+ function idempotencyPrincipal(req: IncomingMessage, myAgentId: string | undefined): string | null {
49
+ const auth = getRequestAuth(req);
50
+ switch (auth?.kind) {
51
+ case "user":
52
+ return `user:${auth.userId}`;
53
+ case "agent":
54
+ return `agent:${auth.agentId}`;
55
+ case "controller":
56
+ return `controller:${auth.controllerId}`;
57
+ case "guest":
58
+ return null;
59
+ default:
60
+ return myAgentId ? `agent:${myAgentId}` : "operator";
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Read the `Idempotency-Key` header. Undefined when none was sent; null when
66
+ * it is malformed or the caller has no identity to scope it to, after writing
67
+ * the 400.
68
+ */
69
+ export function readIdempotencyKey(
70
+ req: IncomingMessage,
71
+ res: ServerResponse,
72
+ myAgentId: string | undefined,
73
+ ): IdempotencyKeyRequest | null | undefined {
74
+ const raw = req.headers["idempotency-key"];
75
+ if (raw === undefined) return undefined;
76
+ const key = Array.isArray(raw) ? raw.join(", ") : raw;
77
+ if (!IDEMPOTENCY_KEY_PATTERN.test(key)) {
78
+ jsonError(res, "Idempotency-Key must be 1-255 visible ASCII characters", 400);
79
+ return null;
80
+ }
81
+ const principal = idempotencyPrincipal(req, myAgentId);
82
+ if (!principal) {
83
+ jsonError(res, "Idempotency-Key needs an identified caller", 400);
84
+ return null;
85
+ }
86
+ return { principal, key };
87
+ }
88
+
89
+ /**
90
+ * Answer from the operation already accepted under this claim's scope, if
91
+ * any. Returns false, writing nothing, when there is none. Otherwise writes a
92
+ * 409 when the stored request differs, a 410 when its workflow was deleted,
93
+ * or hands the stored body to `send` with the replay header set, and returns
94
+ * true. Runs after authentication and before any check that may have changed
95
+ * since the original request.
96
+ */
97
+ export async function replayAcceptedOperation(
98
+ res: ServerResponse,
99
+ claim: WorkflowIdempotencyClaim,
100
+ send: (body: unknown) => void,
101
+ ): Promise<boolean> {
102
+ const accepted = await findAcceptedWorkflowOperation(claim);
103
+ if (!accepted) return false;
104
+ if (accepted.fingerprint !== claim.fingerprint) {
105
+ jsonError(res, "Idempotency-Key was already used for a different request", 409);
106
+ } else if (accepted.resourceDeletedAt !== null) {
107
+ jsonError(
108
+ res,
109
+ "The workflow this Idempotency-Key created or triggered was deleted; send a new key to start over",
110
+ 410,
111
+ );
112
+ } else {
113
+ res.setHeader(IDEMPOTENT_REPLAYED_HEADER, "true");
114
+ send(accepted.body);
115
+ }
116
+ return true;
117
+ }
@@ -502,7 +502,22 @@ const SessionHideSourceSchema = z
502
502
  .filter(Boolean),
503
503
  )
504
504
  .describe(
505
- "Comma-separated root sources to leave out (the sessions v2 source filter), e.g. `slack,chat`.",
505
+ "Comma-separated root sources to leave out, e.g. `slack,chat`. Older sessions v2 clients send this; `showSource` replaces it.",
506
+ );
507
+
508
+ const SessionShowSourceSchema = z
509
+ .string()
510
+ // The same cap as the `showSource` body field of `POST /api/session-triage`.
511
+ .refine((value) => value.split(",").length <= 32, "showSource takes at most 32 sources.")
512
+ .optional()
513
+ .transform((value) =>
514
+ value
515
+ ?.split(",")
516
+ .map((source) => source.trim())
517
+ .filter(Boolean),
518
+ )
519
+ .describe(
520
+ "Comma-separated root sources to keep (the sessions v2 source filter), e.g. `ui,slack`. Present and empty keeps none; omit to keep every source.",
506
521
  );
507
522
 
508
523
  const listSessions = route({
@@ -568,6 +583,8 @@ const listSessions = route({
568
583
  show: SessionShowSchema,
569
584
  /** Root sources to leave out. Must match the tree's `hideSource`. */
570
585
  hideSource: SessionHideSourceSchema,
586
+ /** Root sources to keep. Must match the tree's `showSource`. */
587
+ showSource: SessionShowSourceSchema,
571
588
  }),
572
589
  responses: {
573
590
  200: {
@@ -590,7 +607,11 @@ const sessionTree = route({
590
607
  pattern: ["api", "session-tree"],
591
608
  summary: "Get session groups and triage counts",
592
609
  tags: ["Sessions"],
593
- query: z.object({ show: SessionShowSchema, hideSource: SessionHideSourceSchema }),
610
+ query: z.object({
611
+ show: SessionShowSchema,
612
+ hideSource: SessionHideSourceSchema,
613
+ showSource: SessionShowSourceSchema,
614
+ }),
594
615
  responses: {
595
616
  200: { description: "Visible groups and section counts", schema: SessionTreeResponseSchema },
596
617
  401: { description: "Unauthorized" },
@@ -615,6 +636,8 @@ const triageSessionListRoute = route({
615
636
  groupKey: SessionKeyFilterSchema.optional(),
616
637
  show: SessionShowSchema,
617
638
  hideSource: z.array(z.string().min(1)).max(32).optional(),
639
+ /** Root sources to keep; an empty array keeps none. */
640
+ showSource: z.array(z.string().min(1)).max(32).optional(),
618
641
  })
619
642
  .refine((body) => [body.triage, body.noGroup, body.groupKey].filter(Boolean).length === 1, {
620
643
  message: "Name exactly one list: triage, noGroup, or groupKey",
@@ -941,6 +964,7 @@ export async function handleSessions(
941
964
  const filterOpts = {
942
965
  source: sources,
943
966
  hideSource: parsed.query.hideSource,
967
+ showSource: parsed.query.showSource,
944
968
  q: parsed.query.q,
945
969
  requestedByUserId: parsed.query.requestedByUserId,
946
970
  contextKeyPrefix: parsed.query.contextKeyPrefix,
@@ -994,6 +1018,7 @@ export async function handleSessions(
994
1018
  await getSessionTree(actor, undefined, {
995
1019
  mine: parsed.query.show !== "all",
996
1020
  hideSource: parsed.query.hideSource,
1021
+ showSource: parsed.query.showSource,
997
1022
  }),
998
1023
  );
999
1024
  return true;
@@ -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);
@@ -188,9 +188,10 @@ export async function handleChatInbound(
188
188
  }
189
189
 
190
190
  if (text || attachments.length > 0) {
191
- // Subscribed-thread messages may be dropped by the router at flush time;
192
- // they get typing and a reaction once a task exists (see flushChatThread).
193
- const acknowledge = kind !== "subscribed";
191
+ // An unaddressed subscribed-thread message may be dropped by the router at
192
+ // flush time; it gets typing and a reaction once a task exists (see
193
+ // flushChatThread). A mention, reply to the bot, or DM is acknowledged now.
194
+ const acknowledge = kind !== "subscribed" || isDirectMessage || mentioned || facts.replyToBot;
194
195
  bufferChatMessage(thread.id, {
195
196
  messageId: message.id,
196
197
  channelId: thread.channelId,
@@ -580,7 +581,7 @@ export async function flushChatThread(
580
581
  if (created > 0) {
581
582
  await rememberChatThreadRequester(threadId, requester.userId);
582
583
  await subscribe(thread);
583
- // Subscribed-thread messages start typing only here, once a task exists.
584
+ // Unaddressed subscribed-thread messages start typing only here, once a task exists.
584
585
  startTypingKeepalive(adapter, threadId);
585
586
  await clearChatMessageReactions(
586
587
  adapter,
@@ -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.1792728062-g40cf3f";
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
  }