@desplega.ai/agent-swarm 1.166.0 → 1.167.0

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 (194) hide show
  1. package/dist/acp-adapter-zpvy6321.js +34 -0
  2. package/dist/{actions-qz3m692w.js → actions-fq2zrwtf.js} +25 -25
  3. package/dist/{amp-adapter-j1xbmsqz.js → amp-adapter-5gbk7501.js} +4 -4
  4. package/dist/{anthropic-messages-hst62ws9.js → anthropic-messages-13yn502s.js} +4 -4
  5. package/dist/{app-43vrdmv8.js → app-nctetn1e.js} +10 -9
  6. package/dist/{assistant-ertffmka.js → assistant-0aq8gjvj.js} +26 -26
  7. package/dist/{azure-openai-responses-mh8tvf78.js → azure-openai-responses-9st34pfz.js} +5 -5
  8. package/dist/{boot-reembed-998jtpdg.js → boot-reembed-1ze5853n.js} +9 -8
  9. package/dist/{boot-reembed-s7k7c6v2.js → boot-reembed-fmp5ww69.js} +9 -8
  10. package/dist/{boot-scrub-sweep-nepsk4t1.js → boot-scrub-sweep-cbp8xd4e.js} +10 -9
  11. package/dist/{boot-seeds-vvzs1zhk.js → boot-seeds-22tx8a53.js} +12 -11
  12. package/dist/{claude-adapter-2zvmekrp.js → claude-adapter-0jhgeh1d.js} +24 -22
  13. package/dist/{claude-managed-adapter-s49wbk8t.js → claude-managed-adapter-tzwxy803.js} +4 -4
  14. package/dist/{claude-managed-setup-cvhqq8qn.js → claude-managed-setup-nrhfx5b0.js} +3 -3
  15. package/dist/{claude-sdk-session-kkbxtw5q.js → claude-sdk-session-wh77rh83.js} +22 -22
  16. package/dist/{cli-zsh093hk.js → cli-014vns2y.js} +63 -1
  17. package/dist/{cli-qvfk83fy.js → cli-13xwcqvj.js} +2 -2
  18. package/dist/{cli-dw2q9f7g.js → cli-1yrcfa5s.js} +4 -4
  19. package/dist/{cli-jvj6a0cv.js → cli-577bxkf6.js} +27 -17
  20. package/dist/{cli-zy7vwsne.js → cli-5qkcdv8c.js} +16 -9
  21. package/dist/{cli-cwxhbf43.js → cli-5t5my0h8.js} +85 -2
  22. package/dist/{cli-y59h2pk9.js → cli-69egj654.js} +1 -1
  23. package/dist/{cli-68r1j4sg.js → cli-6bpvv8h4.js} +3 -3
  24. package/dist/{cli-cpfmqz3n.js → cli-6qxmjpem.js} +5 -5
  25. package/dist/{cli-aqcey741.js → cli-6vfz44sr.js} +1 -1
  26. package/dist/{cli-32bca7nr.js → cli-7avy4v8w.js} +1 -1
  27. package/dist/{cli-f2n17zhp.js → cli-7mhy3ths.js} +21 -18
  28. package/dist/{cli-q38n4bq0.js → cli-95zs84qw.js} +3 -3
  29. package/dist/cli-a35hzdve.js +33 -0
  30. package/dist/{cli-x3x0qwky.js → cli-bxqez1p0.js} +51 -23
  31. package/dist/{cli-gt8azyet.js → cli-c2tpw9ck.js} +1 -1
  32. package/dist/{cli-ecknp7p1.js → cli-c8r99akr.js} +10 -10
  33. package/dist/{cli-3qn4r334.js → cli-cexctgn5.js} +20 -14
  34. package/dist/{cli-vzc73wet.js → cli-cf2vgn9d.js} +87 -69
  35. package/dist/{cli-ng97fjq7.js → cli-cmxkbzzs.js} +33 -7
  36. package/dist/{cli-nexwh8zg.js → cli-g7k7zbcz.js} +12 -5
  37. package/dist/{cli-nvvfd8bq.js → cli-jbzbr36n.js} +4091 -3793
  38. package/dist/{cli-3ddybbbx.js → cli-jvmtxqtc.js} +26 -14
  39. package/dist/{cli-pkke6geq.js → cli-km4x66j0.js} +83 -65
  40. package/dist/{cli-j2jbr8af.js → cli-kw0jcfje.js} +1 -1
  41. package/dist/{cli-8t229cmr.js → cli-m7vmeepg.js} +30 -17
  42. package/dist/{cli-yshaazs1.js → cli-mkdsy1nq.js} +62 -20
  43. package/dist/{cli-b0f70z24.js → cli-mn8qkd8v.js} +1 -1
  44. package/dist/{cli-0qjcsxgz.js → cli-mxry91sa.js} +30 -9
  45. package/dist/cli-nkqjj42q.js +104 -0
  46. package/dist/{cli-rggdq8wq.js → cli-p8rt8spp.js} +3 -3
  47. package/dist/{cli-v2a8x45d.js → cli-pkj1w46r.js} +10 -5
  48. package/dist/cli-q1vnz8wv.js +96 -0
  49. package/dist/{cli-fj86xr3c.js → cli-qd97v6z2.js} +2 -2
  50. package/dist/{cli-66zh26sp.js → cli-qzmzwegq.js} +57 -17
  51. package/dist/{cli-p4ztb80h.js → cli-r7eeddkq.js} +13 -8
  52. package/dist/{cli-8ssjez7e.js → cli-rhy7z1rp.js} +22 -11
  53. package/dist/{cli-4mevd1hf.js → cli-s9y81s7j.js} +5 -5
  54. package/dist/{cli-h3x613ze.js → cli-tc5zec7v.js} +6 -6
  55. package/dist/{cli-ttbsse3f.js → cli-tmhsxhb8.js} +1 -1
  56. package/dist/{cli-0nhdwxd5.js → cli-tx2zvn23.js} +3 -3
  57. package/dist/{cli-mwegs4af.js → cli-ty4bzkqr.js} +3 -3
  58. package/dist/{cli-ysnyhws9.js → cli-v1pwxreq.js} +54 -51
  59. package/dist/{cli-feh3dkah.js → cli-v1w7pbd8.js} +2 -1
  60. package/dist/{cli-10zmp1p7.js → cli-vr5avem2.js} +27 -32
  61. package/dist/{cli-bzr0ftht.js → cli-wnb9b2g3.js} +19 -0
  62. package/dist/{cli-df56tqdw.js → cli-wydz3feg.js} +108 -57
  63. package/dist/{cli-krqm5f11.js → cli-xezn9nkh.js} +16 -13
  64. package/dist/{cli-8kt0bmgx.js → cli-xf713zz0.js} +2 -2
  65. package/dist/{cli-x7kpr72a.js → cli-y0kjpfm9.js} +2 -2
  66. package/dist/{cli-6zcfw0x7.js → cli-y195hv1a.js} +1 -1
  67. package/dist/{cli-fn697bnt.js → cli-ykt8f7va.js} +21 -8
  68. package/dist/{cli-4r1sjfc4.js → cli-ymmc1zxg.js} +8 -8
  69. package/dist/{cli-8kgvmkak.js → cli-ypgba1a8.js} +1 -1
  70. package/dist/{cli-rsy933yz.js → cli-z1dqhc9y.js} +30 -19
  71. package/dist/{cli-qbksc292.js → cli-zy39d433.js} +2 -0
  72. package/dist/cli.js +26 -26
  73. package/dist/{cloudflare-workers-ai-system-one-tt484t61.js → cloudflare-workers-ai-system-one-sta45jf9.js} +7 -5
  74. package/dist/{codex-adapter-dw1wxqt8.js → codex-adapter-cgesnjkr.js} +18 -18
  75. package/dist/{codex-login-terbdsqq.js → codex-login-g1cyt5zm.js} +1 -1
  76. package/dist/{codex-session-runner-3cgb8y1g.js → codex-session-runner-8kd7ddhr.js} +18 -18
  77. package/dist/{commands-mhxam6z2.js → commands-h6zaaxym.js} +9 -8
  78. package/dist/{cursor-adapter-2qda3st7.js → cursor-adapter-v8mm6bp5.js} +5 -5
  79. package/dist/{db-sjks418k.js → db-qg3myn53.js} +9 -8
  80. package/dist/{dispatcher-1es759rn.js → dispatcher-7vnwj9qw.js} +24 -24
  81. package/dist/{dsh-adapter-bek4cyw9.js → dsh-adapter-tz6hfp5r.js} +5 -5
  82. package/dist/{easter-egg-3d-mtpnw06s.js → easter-egg-3d-66zxervv.js} +2 -2
  83. package/dist/{execute-s4ccjq3n.js → execute-h4jgevyw.js} +61 -11
  84. package/dist/{google-generative-ai-d6nphg3q.js → google-generative-ai-b8zxrpax.js} +5 -5
  85. package/dist/{google-vertex-cpshkref.js → google-vertex-nrdd697t.js} +5 -5
  86. package/dist/{grok-adapter-ar9cw9jg.js → grok-adapter-jdt2dz2s.js} +18 -8
  87. package/dist/{handlers-1gkbhktr.js → handlers-2qtkpncr.js} +27 -27
  88. package/dist/{hook-ds2zxq9v.js → hook-51yfcgsv.js} +42 -31
  89. package/dist/{hook-vxa3tcsy.js → hook-qnbdgwvd.js} +14 -14
  90. package/dist/{http-3h21z9md.js → http-jsmtag08.js} +320 -98
  91. package/dist/{index-9b65j1m5.js → index-2t9kesn1.js} +9 -8
  92. package/dist/{index-dkfxpkmm.js → index-bt8wkv1w.js} +25 -25
  93. package/dist/{index-f8xnqrye.js → index-cx1sb9bq.js} +33 -24
  94. package/dist/{index-bn6cy8ps.js → index-ekywyv2v.js} +24 -24
  95. package/dist/{index-n99fycq7.js → index-t4g06w93.js} +24 -24
  96. package/dist/{keepalive-3xspa9jw.js → keepalive-4aezx4bj.js} +11 -10
  97. package/dist/{lead-6qphc124.js → lead-r44bkpm5.js} +47 -47
  98. package/dist/{llama-cpp-classify-tgj35k8d.js → llama-cpp-classify-6erb7wwv.js} +3 -1
  99. package/dist/{maintenance-0teem1bb.js → maintenance-grz5jrzh.js} +9 -8
  100. package/dist/{mistral-conversations-8445ddjn.js → mistral-conversations-059y51zt.js} +4 -4
  101. package/dist/{node-9sg7ajby.js → node-qm9eb6z0.js} +1 -1
  102. package/dist/{oauth-refresh-sweep-sh9q25xp.js → oauth-refresh-sweep-wm4ctjfj.js} +10 -9
  103. package/dist/{onboard-54qmnqxe.js → onboard-v60zgqf0.js} +2 -2
  104. package/dist/{openai-codex-responses-9ynm88zy.js → openai-codex-responses-jzxkta9b.js} +8 -7
  105. package/dist/{openai-completions-nhqwjmaw.js → openai-completions-663yncfq.js} +4 -4
  106. package/dist/openai-decisions-vj9nkpf8.js +162 -0
  107. package/dist/{openai-responses-xx1c4kgm.js → openai-responses-m3g43rrt.js} +5 -5
  108. package/dist/{opencode-adapter-wjfa2wvd.js → opencode-adapter-yjyqynzf.js} +7 -7
  109. package/dist/{openrouter-images-8yx3487g.js → openrouter-images-kr60n6b9.js} +1 -1
  110. package/dist/{otel-impl-ycqzp2a1.js → otel-impl-ghh4dyf8.js} +1 -1
  111. package/dist/{pi-messages-vxdt8v7j.js → pi-messages-xszk613n.js} +1 -1
  112. package/dist/{pi-mono-adapter-e77e9zst.js → pi-mono-adapter-0eg8kw2c.js} +68 -51
  113. package/dist/{pricing-refresh-atnkngqs.js → pricing-refresh-mjrqvnvw.js} +11 -10
  114. package/dist/{rbac-roles-j78wdksm.js → rbac-roles-axv4d74x.js} +10 -9
  115. package/dist/{rbac-roles-9pjphh9q.js → rbac-roles-fd5tskj9.js} +9 -8
  116. package/dist/{render-v2-pxjs49fx.js → render-v2-k50j33qd.js} +10 -9
  117. package/dist/{runtime-v2t1r79e.js → runtime-h9ha1mq6.js} +4 -4
  118. package/dist/{setup-ymej0xtx.js → setup-4syh0pd2.js} +2 -2
  119. package/dist/{typesafe-system-one-datfr82p.js → typesafe-system-one-e988vty7.js} +7 -5
  120. package/dist/{virtual-modules-wm8mxdxx.js → virtual-modules-ftr9kwe8.js} +11 -11
  121. package/dist/{work-objects-3nh4ymzb.js → work-objects-jcdbg8rr.js} +27 -27
  122. package/dist/{worker-qa9fkz5s.js → worker-x8j8c9sx.js} +47 -47
  123. package/openapi.json +1 -1
  124. package/package.json +8 -8
  125. package/src/be/memory/providers/openai-embedding.ts +7 -2
  126. package/src/be/memory/raters/llm-summarizer.ts +7 -2
  127. package/src/be/modelsdev-cache.json +61 -0
  128. package/src/be/seed-pricing.ts +84 -53
  129. package/src/be/swarm-config-guard.ts +13 -0
  130. package/src/commands/provider-credentials.ts +28 -5
  131. package/src/commands/runner.ts +7 -12
  132. package/src/heartbeat/heartbeat.ts +91 -19
  133. package/src/hooks/hook.ts +72 -27
  134. package/src/http/mcp.ts +5 -0
  135. package/src/http/onboarding.ts +2 -0
  136. package/src/providers/acp-targets.ts +9 -4
  137. package/src/providers/amp-adapter.ts +1 -1
  138. package/src/providers/claude-adapter.ts +26 -0
  139. package/src/providers/codex-adapter.ts +30 -3
  140. package/src/providers/codex-oauth/auth-json.ts +21 -4
  141. package/src/providers/codex-oauth/types.ts +2 -0
  142. package/src/providers/dsh-adapter.ts +10 -1
  143. package/src/providers/grok-adapter.ts +18 -2
  144. package/src/providers/opencode-adapter.ts +21 -5
  145. package/src/providers/pi-mono-extension.ts +56 -25
  146. package/src/providers/pricing-sources.md +2 -0
  147. package/src/server.ts +63 -3
  148. package/src/tests/acp-adapter.test.ts +152 -12
  149. package/src/tests/ci-unit-test-report.test.ts +73 -0
  150. package/src/tests/claude-default-route.test.ts +75 -0
  151. package/src/tests/claude-hook-output.test.ts +233 -0
  152. package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +4 -2
  153. package/src/tests/codex-adapter.test.ts +166 -26
  154. package/src/tests/codex-oauth-auth-timestamps.test.ts +76 -0
  155. package/src/tests/codex-oauth.test.ts +4 -1
  156. package/src/tests/context-window.test.ts +2 -2
  157. package/src/tests/credential-check.test.ts +51 -1
  158. package/src/tests/dsh-adapter.test.ts +23 -1
  159. package/src/tests/enabled-tools.test.ts +269 -0
  160. package/src/tests/entrypoint-codex-oauth-seed.test.ts +12 -0
  161. package/src/tests/grok-adapter.test.ts +13 -0
  162. package/src/tests/haiku-5-5-pricing.test.ts +38 -0
  163. package/src/tests/heartbeat-checklist.test.ts +158 -1
  164. package/src/tests/onboarding.test.ts +44 -0
  165. package/src/tests/opencode-adapter.test.ts +66 -3
  166. package/src/tests/opencode-plugin.test.ts +37 -0
  167. package/src/tests/openrouter-attribution-call-sites.test.ts +204 -0
  168. package/src/tests/openrouter-base-url.test.ts +30 -39
  169. package/src/tests/pi-mono-extension.test.ts +161 -2
  170. package/src/tests/pricing-refresh.test.ts +70 -1
  171. package/src/tests/reasoning-effort.test.ts +3 -1
  172. package/src/tests/telemetry-trigger.test.ts +12 -1
  173. package/src/tests/workflow-llm.test.ts +14 -0
  174. package/src/tests/workflow-system-one-decision-providers.test.ts +33 -0
  175. package/src/tools/request-human-input.ts +7 -2
  176. package/src/utils/enabled-tools.ts +27 -0
  177. package/src/utils/internal-ai/complete-structured.ts +11 -3
  178. package/src/utils/openrouter-base-url.ts +23 -0
  179. package/src/workflows/executors/raw-llm.ts +1 -0
  180. package/src/workflows/executors/system-one-decision.ts +2 -0
  181. package/src/workflows/executors/validate.ts +1 -0
  182. package/src/workflows/executors/workflow-llm.ts +9 -2
  183. package/templates/skills/agent-browser/SKILL.md +2 -0
  184. package/templates/skills/agent-browser/content.md +2 -0
  185. package/templates/skills/code-quality/content.md +1 -0
  186. package/templates/skills/heartbeat-runbook/content.md +1 -1
  187. package/templates/skills/work-on-task/SKILL.md +4 -0
  188. package/templates/skills/work-on-task/content.md +4 -0
  189. package/templates/skills/workflow-iterate/content.md +9 -0
  190. package/dist/acp-adapter-yzfxn8vj.js +0 -23
  191. package/dist/cli-3dpj7atn.js +0 -11
  192. package/dist/cli-gvcgmx69.js +0 -183
  193. package/dist/{cli-jhm6hzjn.js → cli-mxvnnv5q.js} +7 -7
  194. package/dist/{codex-hook-d2rkvf39.js → codex-hook-15dgc4cb.js} +1 -1
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Unit tests for `summarizeSessionForPi` in `src/providers/pi-mono-extension.ts`.
2
+ * Unit tests for `summarizeSessionForPi` in `src/providers/pi-mono-extension.ts`,
3
+ * plus the extension's advisory nudges (delivered with `pi.sendMessage`).
3
4
  *
4
5
  * Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
5
6
  * → Phase 1 § "Test coverage"
@@ -19,8 +20,14 @@
19
20
  */
20
21
 
21
22
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
23
+ import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
24
+ import { clearToolHistory } from "../hooks/tool-loop-detection";
22
25
  import type { SummarizeSessionForPiDeps, SwarmHooksConfig } from "../providers/pi-mono-extension";
23
- import { summarizeSessionForPi } from "../providers/pi-mono-extension";
26
+ import {
27
+ createSwarmHooksExtension,
28
+ SWARM_NUDGE_CUSTOM_TYPE,
29
+ summarizeSessionForPi,
30
+ } from "../providers/pi-mono-extension";
24
31
 
25
32
  // ── helpers ───────────────────────────────────────────────────────────────────
26
33
 
@@ -345,3 +352,155 @@ describe("summarizeSessionForPi", () => {
345
352
  expect(consoleErrors.length).toBe(0);
346
353
  });
347
354
  });
355
+
356
+ describe("pi provider header attribution", () => {
357
+ test("uses request model URL and task env, replacing pi attribution", async () => {
358
+ let handler:
359
+ | ((event: { headers: Record<string, string> }, ctx: { model: { baseUrl: string } }) => void)
360
+ | undefined;
361
+ const pi = {
362
+ on: (event: string, callback: unknown) => {
363
+ if (event === "before_provider_headers") handler = callback as NonNullable<typeof handler>;
364
+ },
365
+ } as unknown as ExtensionAPI;
366
+ const config = { ...makeConfig(), env: {} as Record<string, string | undefined> };
367
+ await createSwarmHooksExtension(config)(pi);
368
+ expect(handler).toBeDefined();
369
+ const headers = {
370
+ "HTTP-Referer": "https://pi.dev",
371
+ "X-OpenRouter-Title": "pi",
372
+ "X-Custom": "keep",
373
+ };
374
+ handler!({ headers }, { model: { baseUrl: "https://openrouter.ai/api" } });
375
+ expect(headers).toEqual({
376
+ "HTTP-Referer": "https://agent-swarm.dev",
377
+ "X-OpenRouter-Title": "Agent Swarm",
378
+ "X-OpenRouter-Categories": "personal-agent,cloud-agent",
379
+ "X-Custom": "keep",
380
+ });
381
+ config.env.OPENROUTER_APP_ATTRIBUTION = "false";
382
+ const optedOut = { "X-Custom": "keep" };
383
+ handler!({ headers: optedOut }, { model: { baseUrl: "https://openrouter.ai/api" } });
384
+ expect(optedOut).toEqual({ "X-Custom": "keep" });
385
+ config.env = {};
386
+ const gateway = {};
387
+ handler!({ headers: gateway }, { model: { baseUrl: "http://localhost:1234/v1" } });
388
+ expect(gateway).toEqual({});
389
+ });
390
+ });
391
+
392
+ // ── advisory nudges reach the model via pi.sendMessage ───────────────────────
393
+
394
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
395
+ type SentMessage = { customType: string; content: Array<{ type: string; text: string }> };
396
+
397
+ /** Minimal stand-in for pi's ExtensionAPI: records handlers and sent messages. */
398
+ function loadExtension(config: SwarmHooksConfig) {
399
+ const handlers = new Map<string, Handler>();
400
+ const sent: SentMessage[] = [];
401
+ const pi = {
402
+ on: (event: string, handler: Handler) => {
403
+ handlers.set(event, handler);
404
+ },
405
+ sendMessage: (message: SentMessage) => {
406
+ sent.push(message);
407
+ },
408
+ registerMcpServer: () => {},
409
+ };
410
+ createSwarmHooksExtension(config)(pi as unknown as Parameters<ExtensionFactory>[0]);
411
+ const ctx = { getContextUsage: () => undefined };
412
+ const fire = (event: string, payload: unknown = {}) => {
413
+ const handler = handlers.get(event);
414
+ if (!handler) throw new Error(`no handler for ${event}`);
415
+ return handler({ type: event, ...(payload as object) }, ctx);
416
+ };
417
+ const texts = () =>
418
+ sent.map((m) => {
419
+ expect(m.customType).toBe(SWARM_NUDGE_CUSTOM_TYPE);
420
+ return m.content.map((c) => c.text).join("");
421
+ });
422
+ return { fire, texts };
423
+ }
424
+
425
+ function jsonResponse(body: unknown): FetchHandlerResp {
426
+ return { ok: true, status: 200, text: async () => "", json: async () => body };
427
+ }
428
+
429
+ describe("createSwarmHooksExtension nudges", () => {
430
+ test("loop warning on tool_call is sent to the model", async () => {
431
+ const config = { ...makeConfig(), taskId: `task-pi-loop-${crypto.randomUUID()}` };
432
+ const { fire, texts } = loadExtension(config);
433
+ try {
434
+ for (let i = 0; i < 8; i++) {
435
+ const result = await fire("tool_call", { toolName: "bash", input: { command: "ls" } });
436
+ expect(result).toBeUndefined();
437
+ }
438
+ const warnings = texts().filter((t) => t.startsWith("Warning: "));
439
+ expect(warnings.length).toBe(1);
440
+ expect(warnings[0]).toContain("bash");
441
+ } finally {
442
+ await clearToolHistory(config.taskId);
443
+ }
444
+ });
445
+
446
+ test("lead send-task reminder on tool_result is sent to the model", async () => {
447
+ const { fire, texts } = loadExtension({ ...makeConfig(), isLead: true });
448
+ await fire("tool_result", { toolName: "mcp__agent-swarm__send-task", input: {} });
449
+ expect(texts()).toEqual([
450
+ "Task sent successfully. Monitor progress using the get-task-details tool periodically.",
451
+ ]);
452
+ });
453
+
454
+ test("lead concurrent context on session_start is sent to the model", async () => {
455
+ fetchHandler = async (url) =>
456
+ url.includes("/api/concurrent-context")
457
+ ? jsonResponse({
458
+ processingInboxMessages: [
459
+ { content: "deploy the thing", source: "slack", createdAt: "now" },
460
+ ],
461
+ recentTaskDelegations: [],
462
+ activeSwarmTasks: [],
463
+ })
464
+ : jsonResponse({});
465
+ const { fire, texts } = loadExtension({ ...makeConfig(), isLead: true });
466
+ await fire("session_start");
467
+ expect(texts().length).toBe(1);
468
+ expect(texts()[0]).toContain("=== CONCURRENT SESSION AWARENESS ===");
469
+ expect(texts()[0]).toContain("deploy the thing");
470
+ });
471
+
472
+ test("goal reminder is sent after compaction, not before every LLM call", async () => {
473
+ fetchHandler = async (url) =>
474
+ url.endsWith("/api/tasks/task-pi-1")
475
+ ? jsonResponse({ id: "task-pi-1", task: "ship the fix", progress: "tests green" })
476
+ : jsonResponse({});
477
+ const { fire, texts } = loadExtension(makeConfig());
478
+
479
+ await fire("context", { messages: [] });
480
+ expect(texts()).toEqual([]);
481
+
482
+ await fire("session_compact");
483
+ expect(texts()).toEqual([
484
+ [
485
+ "=== GOAL REMINDER (injected before context compaction) ===",
486
+ "Task ID: task-pi-1",
487
+ "Task: ship the fix",
488
+ "Current Progress: tests green",
489
+ "=== Continue working on this task after compaction ===",
490
+ ].join("\n"),
491
+ ]);
492
+ });
493
+
494
+ test("cancelled task on input skips the turn and leaves the nudge in the session", async () => {
495
+ fetchHandler = async (url) =>
496
+ url.includes("/cancelled-tasks")
497
+ ? jsonResponse({ cancelled: [{ id: "task-pi-1", failureReason: "lead stopped it" }] })
498
+ : jsonResponse({});
499
+ const { fire, texts } = loadExtension(makeConfig());
500
+ const result = await fire("input", { text: "go", source: "rpc" });
501
+ expect(result).toEqual({ action: "handled" });
502
+ expect(texts()).toEqual([
503
+ "🛑 TASK CANCELLED: lead stopped it. Stop working and use store-progress to acknowledge.",
504
+ ]);
505
+ });
506
+ });
@@ -2,8 +2,9 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, getActivePricingRow, getDbClient, getLogsByEventType, initDb } from "../be/db";
4
4
  import { getModelsCatalog, resetModelsCatalogForTests } from "../be/models-catalog";
5
- import type { ModelsDevCache } from "../be/modelsdev-cache";
5
+ import { loadModelsDevCache, type ModelsDevCache } from "../be/modelsdev-cache";
6
6
  import { refreshPricingFromModelsDev } from "../be/pricing-refresh";
7
+ import { buildModelsDevSeedRows, buildPricingSeedRows } from "../be/seed-pricing";
7
8
 
8
9
  const TEST_DB_PATH = "./test-pricing-refresh.sqlite";
9
10
 
@@ -201,6 +202,74 @@ describe("models.dev runtime pricing refresh", () => {
201
202
  expect(rows.map((row) => row.effective_from)).toEqual([2_000, 3_000]);
202
203
  });
203
204
 
205
+ test("refreshes when dated snapshots collapse onto one amp key at different prices", async () => {
206
+ // models.dev's live gpt-4o family. Amp strips the dated suffix, so all four
207
+ // land on amp|gpt-4o; the 2024-05-13 snapshot is priced higher. Its key
208
+ // comes first, as upstream serves it, so a first-wins builder would pick it.
209
+ const cache: ModelsDevCache = {
210
+ openai: {
211
+ models: {
212
+ "gpt-4o-2024-05-13": { cost: { input: 5, output: 15 } },
213
+ "gpt-4o": { cost: { input: 2.5, output: 10, cache_read: 1.25 } },
214
+ "gpt-4o-2024-08-06": { cost: { input: 2.5, output: 10, cache_read: 1.25 } },
215
+ "gpt-4o-2024-11-20": { cost: { input: 2.5, output: 10, cache_read: 1.25 } },
216
+ },
217
+ },
218
+ };
219
+
220
+ const result = await refreshPricingFromModelsDev({
221
+ now: 1_000,
222
+ fetchImpl: async () => responseFor(cache, '"etag-collapse"'),
223
+ });
224
+
225
+ expect(result.status).toBe("refreshed");
226
+ expect(await getLogsByEventType("pricing.refresh.failed")).toHaveLength(0);
227
+ // The undated alias wins every token class.
228
+ expect((await getActivePricingRow("amp", "gpt-4o", "input", 1_000))?.pricePerMillionUsd).toBe(
229
+ 2.5,
230
+ );
231
+ expect((await getActivePricingRow("amp", "gpt-4o", "output", 1_000))?.pricePerMillionUsd).toBe(
232
+ 10,
233
+ );
234
+ expect(
235
+ (await getActivePricingRow("amp", "gpt-4o", "cached_input", 1_000))?.pricePerMillionUsd,
236
+ ).toBe(1.25);
237
+ // Providers that keep the dated id still price each snapshot on its own.
238
+ expect(
239
+ (await getActivePricingRow("codex", "gpt-4o-2024-05-13", "input", 1_000))?.pricePerMillionUsd,
240
+ ).toBe(5);
241
+ });
242
+
243
+ test("picks the newest dated snapshot when no undated alias exists", () => {
244
+ const rows = buildModelsDevSeedRows({
245
+ openai: {
246
+ models: {
247
+ "gpt-x-2025-08-07": { cost: { input: 2, output: 8 } },
248
+ "gpt-x-2025-01-01": { cost: { input: 1, output: 4 } },
249
+ },
250
+ },
251
+ }).filter((row) => row.provider === "amp");
252
+
253
+ expect(rows).toEqual([
254
+ { provider: "amp", model: "gpt-x", tokenClass: "input", pricePerMillionUsd: 2 },
255
+ { provider: "amp", model: "gpt-x", tokenClass: "output", pricePerMillionUsd: 8 },
256
+ ]);
257
+ });
258
+
259
+ test("the vendored models.dev snapshot yields one row per (provider, model, tokenClass)", () => {
260
+ const cache = loadModelsDevCache();
261
+ expect(cache).not.toBeNull();
262
+ const rows = buildPricingSeedRows(cache);
263
+ const seen = new Set<string>();
264
+ const duplicates: string[] = [];
265
+ for (const row of rows) {
266
+ const key = `${row.provider}|${row.model}|${row.tokenClass}`;
267
+ if (seen.has(key)) duplicates.push(key);
268
+ seen.add(key);
269
+ }
270
+ expect(duplicates).toEqual([]);
271
+ });
272
+
204
273
  test("writes scrubbed audit log entries for successful refreshes", async () => {
205
274
  await refreshPricingFromModelsDev({
206
275
  now: 1_000,
@@ -305,7 +305,9 @@ describe("Claude CLI shortnames (the tier defaults) take effort like the model t
305
305
  expect(reasoningCapability("claude", "sonnet")).toEqual(
306
306
  reasoningCapability("claude", "claude-sonnet-5-5"),
307
307
  );
308
- expect(reasoningCapability("claude", "haiku").levels).toContain("off");
308
+ expect(reasoningCapability("claude", "haiku")).toEqual(
309
+ reasoningCapability("claude", "claude-haiku-5-5"),
310
+ );
309
311
  expect(reasoningCapability("claude", "fable").supported).toBe(true);
310
312
  });
311
313
 
@@ -127,7 +127,18 @@ describe("trigger_surface on task telemetry", () => {
127
127
  // so each one is run for real and judged on the row it persists and the
128
128
  // task.created event it emits.
129
129
  const heartbeatCreators: Array<{ taskType: string; create: () => Promise<void> }> = [
130
- { taskType: "boot-triage", create: createBootTriageTask },
130
+ {
131
+ taskType: "boot-triage",
132
+ create: async () => {
133
+ // A clean boot creates no task; force one to judge its source.
134
+ process.env.HEARTBEAT_BOOT_TRIAGE_ALWAYS = "true";
135
+ try {
136
+ await createBootTriageTask();
137
+ } finally {
138
+ delete process.env.HEARTBEAT_BOOT_TRIAGE_ALWAYS;
139
+ }
140
+ },
141
+ },
131
142
  {
132
143
  taskType: "heartbeat-checklist",
133
144
  create: async () => {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { DEFAULT_MODEL } from "../utils/internal-ai/models";
3
+ import { OPENROUTER_APP_ATTRIBUTION_HEADERS } from "../utils/openrouter-base-url";
3
4
  import { resolveWorkflowLlmConfig } from "../workflows/executors/workflow-llm";
4
5
 
5
6
  describe("resolveWorkflowLlmConfig", () => {
@@ -12,10 +13,22 @@ describe("resolveWorkflowLlmConfig", () => {
12
13
  expect(config).toEqual({
13
14
  apiKey: "sk-or-test",
14
15
  baseURL: "https://gateway.example.test/v1",
16
+ headers: {},
15
17
  model: "deepseek/deepseek-v4.1-flash",
16
18
  });
17
19
  });
18
20
 
21
+ test("attributes direct openrouter.ai calls unless OPENROUTER_APP_ATTRIBUTION is off", async () => {
22
+ const direct = await resolveWorkflowLlmConfig(undefined, { OPENROUTER_API_KEY: "sk-or-test" });
23
+ expect(direct.headers).toEqual(OPENROUTER_APP_ATTRIBUTION_HEADERS);
24
+
25
+ const optedOut = await resolveWorkflowLlmConfig(undefined, {
26
+ OPENROUTER_API_KEY: "sk-or-test",
27
+ OPENROUTER_APP_ATTRIBUTION: "false",
28
+ });
29
+ expect(optedOut.headers).toEqual({});
30
+ });
31
+
19
32
  test("routes OpenAI credentials to the SDK default endpoint", async () => {
20
33
  const config = await resolveWorkflowLlmConfig(undefined, {
21
34
  OPENAI_API_KEY: "example-sk-openai-test",
@@ -24,6 +37,7 @@ describe("resolveWorkflowLlmConfig", () => {
24
37
  expect(config).toEqual({
25
38
  apiKey: "example-sk-openai-test",
26
39
  baseURL: undefined,
40
+ headers: {},
27
41
  model: "gpt-6-luna",
28
42
  });
29
43
  });
@@ -734,4 +734,37 @@ describe("system-one-decision SystemOne wire is unchanged", () => {
734
734
  expect(calls[0]?.raw).toBe(expected);
735
735
  }
736
736
  });
737
+
738
+ test("only the openrouter.ai provider carries OpenRouter app attribution", async () => {
739
+ const referer = async (provider: string, env: Record<string, string>) => {
740
+ const { executor, calls } = makeExecutor(
741
+ [
742
+ jsonResponse({
743
+ model: "jev",
744
+ answers: systemOneAnswers,
745
+ usage: { input_tokens: 1, output_tokens: 0 },
746
+ }),
747
+ ],
748
+ {
749
+ getApiKey: async () => OPENAI_KEY,
750
+ getServerUrl: async () => "https://laya.example.test",
751
+ env: { OPENROUTER_API_KEY: OPENAI_KEY, ...env },
752
+ },
753
+ );
754
+ await run(executor, { ...config({ model: "jev-1.13.0" }), provider });
755
+ const headers = new Headers(calls[0]?.init.headers);
756
+ return [headers.get("http-referer"), headers.get("x-openrouter-categories")];
757
+ };
758
+ expect(await referer("openrouter", {})).toEqual([
759
+ "https://agent-swarm.dev",
760
+ "personal-agent,cloud-agent",
761
+ ]);
762
+ expect(await referer("openrouter", { OPENROUTER_APP_ATTRIBUTION: "false" })).toEqual([
763
+ null,
764
+ null,
765
+ ]);
766
+ for (const provider of ["typesafe", "laya"]) {
767
+ expect(await referer(provider, {})).toEqual([null, null]);
768
+ }
769
+ });
737
770
  });
@@ -12,7 +12,10 @@ const QuestionSchema = z.object({
12
12
  .describe("Question type"),
13
13
  label: z.string().describe("The question text displayed to the user"),
14
14
  required: z.boolean().optional().describe("Whether this question is required (default: true)"),
15
- description: z.string().optional().describe("Optional help text"),
15
+ description: z
16
+ .string()
17
+ .optional()
18
+ .describe("Optional help text, rendered as markdown (code fences, images)"),
16
19
  placeholder: z.string().optional().describe("Placeholder text (for text type)"),
17
20
  multiline: z.boolean().optional().describe("Use textarea instead of input (for text type)"),
18
21
  options: z
@@ -39,7 +42,9 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
39
42
  description:
40
43
  "Create an approval request and return at once with the request id and URL. The answer arrives later as a hitl-follow-up task. " +
41
44
  "Supports multiple question types: approval (yes/no), text, single-select, " +
42
- "multi-select, and boolean. Returns the request ID and URL for the human to respond.",
45
+ "multi-select, and boolean. Returns the request ID and URL for the human to respond. " +
46
+ "The dashboard renders each label and description as markdown: put long content in description, fence code with a language, and put an image URL alone on its own line to show it. " +
47
+ "No Slack message is sent; share the URL yourself.",
43
48
  inputSchema: z.object({
44
49
  title: z.string().min(1).describe("Title of the approval request"),
45
50
  questions: z.array(QuestionSchema).min(1).describe("Questions to ask the human"),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Parse the `SWARM_ENABLED_TOOLS` MCP tool allowlist.
3
+ *
4
+ * Accepts a comma-separated list (`get-tasks, store-progress`) or a JSON array
5
+ * of strings. Returns `undefined` when the value is unset, blank, or holds no
6
+ * entries, so callers keep the capability-driven surface. Throws on a JSON
7
+ * array that does not parse to strings.
8
+ */
9
+ export function parseEnabledTools(value: string | undefined): string[] | undefined {
10
+ if (value === undefined) return undefined;
11
+ const trimmed = value.trim();
12
+ if (!trimmed) return undefined;
13
+
14
+ let entries: string[];
15
+ if (trimmed.startsWith("[")) {
16
+ const parsed: unknown = JSON.parse(trimmed);
17
+ if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
18
+ throw new Error("Expected a JSON array of tool names");
19
+ }
20
+ entries = parsed;
21
+ } else {
22
+ entries = trimmed.split(",");
23
+ }
24
+
25
+ const tools = [...new Set(entries.map((entry) => entry.trim()).filter(Boolean))];
26
+ return tools.length > 0 ? tools : undefined;
27
+ }
@@ -20,7 +20,11 @@ import { complete } from "@earendil-works/pi-ai/compat";
20
20
  import { getBuiltinModel as getModel } from "@earendil-works/pi-ai/providers/all";
21
21
  import type { TSchema } from "typebox";
22
22
  import { z } from "zod";
23
- import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../openrouter-base-url.js";
23
+ import {
24
+ DEFAULT_OPENROUTER_BASE_URL,
25
+ getOpenRouterAttributionHeaders,
26
+ getOpenRouterBaseUrl,
27
+ } from "../openrouter-base-url.js";
24
28
  import { scrubSecrets } from "../secret-scrubber.js";
25
29
  import { type ResolvedCredential, resolveCredential } from "./credentials.js";
26
30
  import { parseModelStr } from "./models.js";
@@ -319,10 +323,14 @@ export async function completeStructuredWithModel<TZod extends z.ZodTypeAny>(
319
323
  // through a gateway (pi-ai's stream path uses `model.baseUrl` verbatim).
320
324
  // Resolved from the caller's session env when provided — swarm_config
321
325
  // overlays don't reach `process.env`.
322
- const openRouterBaseUrl = getOpenRouterBaseUrl((opts.env ?? process.env) as NodeJS.ProcessEnv);
326
+ const env = opts.env ?? process.env;
327
+ const openRouterBaseUrl = getOpenRouterBaseUrl(env as NodeJS.ProcessEnv);
323
328
  if (provider === "openrouter" && openRouterBaseUrl !== DEFAULT_OPENROUTER_BASE_URL) {
324
329
  model = { ...model, baseUrl: openRouterBaseUrl };
325
330
  }
331
+ // pi-ai merges `options.headers` over the provider defaults.
332
+ const headers =
333
+ provider === "openrouter" ? getOpenRouterAttributionHeaders(model.baseUrl, env) : {};
326
334
 
327
335
  const completeFn = opts._complete ?? complete;
328
336
  const toolChoice =
@@ -348,7 +356,7 @@ export async function completeStructuredWithModel<TZod extends z.ZodTypeAny>(
348
356
  },
349
357
  // pi-ai's ProviderStreamOptions type only allows known providers;
350
358
  // we pass the validated apiKey through verbatim.
351
- { apiKey: cred.apiKey, signal: opts.signal, toolChoice } as Parameters<
359
+ { apiKey: cred.apiKey, signal: opts.signal, toolChoice, headers } as Parameters<
352
360
  typeof completeFn
353
361
  >[2],
354
362
  );
@@ -26,3 +26,26 @@ export function getOpenRouterBaseUrl(env: NodeJS.ProcessEnv = process.env): stri
26
26
  }
27
27
  return DEFAULT_OPENROUTER_BASE_URL;
28
28
  }
29
+
30
+ export const OPENROUTER_APP_ATTRIBUTION_HEADERS = {
31
+ "HTTP-Referer": "https://agent-swarm.dev",
32
+ "X-OpenRouter-Title": "Agent Swarm",
33
+ "X-OpenRouter-Categories": "personal-agent,cloud-agent",
34
+ } as const;
35
+
36
+ /** Attribute direct OpenRouter requests unless the task opts out. */
37
+ export function getOpenRouterAttributionHeaders(
38
+ url: string,
39
+ env: Record<string, string | undefined> = process.env,
40
+ ): Record<string, string> {
41
+ const enabled = env.OPENROUTER_APP_ATTRIBUTION?.toLowerCase();
42
+ if (enabled === "false" || enabled === "0") return {};
43
+ try {
44
+ const host = new URL(url).hostname;
45
+ return host === "openrouter.ai" || host.endsWith(".openrouter.ai")
46
+ ? { ...OPENROUTER_APP_ATTRIBUTION_HEADERS }
47
+ : {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
@@ -32,6 +32,7 @@ export async function executeRawLlm(
32
32
  const provider = createOpenAI({
33
33
  baseURL: llmConfig.baseURL,
34
34
  apiKey: llmConfig.apiKey,
35
+ headers: llmConfig.headers,
35
36
  });
36
37
  const model = provider(llmConfig.model);
37
38
 
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { ExecutorMeta } from "../../types";
3
+ import { getOpenRouterAttributionHeaders } from "../../utils/openrouter-base-url";
3
4
  import { scrubSecrets } from "../../utils/secret-scrubber";
4
5
  import {
5
6
  type ApprovalOutcome,
@@ -1170,6 +1171,7 @@ export class SystemOneDecisionExecutor extends BaseExecutor<
1170
1171
  res = await doFetch(endpoint, {
1171
1172
  method: "POST",
1172
1173
  headers: {
1174
+ ...getOpenRouterAttributionHeaders(endpoint, this.providerContext().env),
1173
1175
  "content-type": "application/json",
1174
1176
  accept: "application/json",
1175
1177
  authorization: `Bearer ${apiKey}`,
@@ -119,6 +119,7 @@ export class ValidateExecutor extends BaseExecutor<
119
119
  const provider = createOpenAI({
120
120
  baseURL: llmConfig.baseURL,
121
121
  apiKey: llmConfig.apiKey,
122
+ headers: llmConfig.headers,
122
123
  });
123
124
 
124
125
  const { object } = await generateObject({
@@ -1,9 +1,14 @@
1
1
  import { DEFAULT_MODEL, resolveCredential } from "../../utils/internal-ai/credentials";
2
- import { getOpenRouterBaseUrl } from "../../utils/openrouter-base-url";
2
+ import {
3
+ getOpenRouterAttributionHeaders,
4
+ getOpenRouterBaseUrl,
5
+ } from "../../utils/openrouter-base-url";
3
6
 
4
7
  export interface WorkflowLlmConfig {
5
8
  apiKey: string;
6
9
  baseURL?: string;
10
+ /** OpenRouter app attribution; empty for OpenAI and for gateway base URLs. */
11
+ headers: Record<string, string>;
7
12
  model: string;
8
13
  }
9
14
 
@@ -39,9 +44,11 @@ export async function resolveWorkflowLlmConfig(
39
44
  // Workflow defaults are provider-owned; MEMORY_RATER_MODEL only configures memory work.
40
45
  const model = requestedModel ?? DEFAULT_MODEL[credential.kind];
41
46
 
47
+ const baseURL = credential.kind === "openrouter" ? getOpenRouterBaseUrl(env) : undefined;
42
48
  return {
43
49
  apiKey: credential.apiKey,
44
- baseURL: credential.kind === "openrouter" ? getOpenRouterBaseUrl(env) : undefined,
50
+ baseURL,
51
+ headers: baseURL ? getOpenRouterAttributionHeaders(baseURL, env) : {},
45
52
  model: model.startsWith(providerPrefix) ? model.slice(providerPrefix.length) : model,
46
53
  };
47
54
  }
@@ -47,6 +47,8 @@ agent-browser close
47
47
 
48
48
  ## Recording
49
49
 
50
+ Pass `record start` an absolute path (`/tmp/<topic>/raw.webm`). A relative path resolves against the agent-browser daemon's cwd, not yours, and fails with `ffmpeg write failed: Broken pipe` while `doctor` stays green.
51
+
50
52
  Default: record with `--cursor`, drive every supported pointer action (`click`, `mouse move`, `drag`) with `--human`, and ship the **1.5x video**. Keep the 1x original only if a reviewer asks for it. Human pointer movement follows an eased curve; use `--seed <n>` on `mouse move` to make the take reproducible instead of using a bare move.
51
53
 
52
54
  Open the page first, then record the interaction:
@@ -42,6 +42,8 @@ agent-browser close
42
42
 
43
43
  ## Recording
44
44
 
45
+ Pass `record start` an absolute path (`/tmp/<topic>/raw.webm`). A relative path resolves against the agent-browser daemon's cwd, not yours, and fails with `ffmpeg write failed: Broken pipe` while `doctor` stays green.
46
+
45
47
  Default: record with `--cursor`, drive every supported pointer action (`click`, `mouse move`, `drag`) with `--human`, and ship the **1.5x video**. Keep the 1x original only if a reviewer asks for it. Human pointer movement follows an eased curve; use `--seed <n>` on `mouse move` to make the take reproducible instead of using a bare move.
46
48
 
47
49
  Open the page first, then record the interaction:
@@ -38,6 +38,7 @@ The Repository Guidelines carry `allowMerge` and `mergeChecks`.
38
38
 
39
39
  ## Review a PR
40
40
 
41
+ 0. Re-review check. If you reviewed this PR before, compare `gh pr view <number> --json headRefOid --jq .headRefOid` with the `commit_id` of your last review (`gh api repos/<owner>/<repo>/pulls/<number>/reviews`). Same SHA: reuse that verdict, link it and stop. New SHA: review the diff since that commit, then run the full list below. If the new head merged or rebased main, also read every conflict resolution (files both sides touched), check migration numbering and order against main, grep for stale migration numbers, and confirm CI is green at the exact head you review.
41
42
  1. CI status first, including the fork checks below before treating CI as evidence. Failing CI is a REQUEST_CHANGES. Name the failing checks in the review.
42
43
  2. Detect a GitHub fork PR with `gh pr view <number> --json isCrossRepository --jq .isCrossRepository`.
43
44
  3. For a GitHub fork PR, inspect held runs with `SHA=$(gh pr view <number> --json headRefOid --jq .headRefOid); gh api --paginate "repos/<owner>/<repo>/actions/runs?head_sha=$SHA" --jq '.workflow_runs[] | "\(.conclusion // .status)|\(.name)"'`; `gh pr checks` and `statusCheckRollup` can omit them.
@@ -27,7 +27,7 @@ Two kinds of sections:
27
27
 
28
28
  ## Handle a heartbeat-checklist task
29
29
 
30
- 0. Quiet check. Take the fast path when three things hold: the system status shows no failed, stalled or reboot-interrupted task; no mention has gone unanswered since the last heartbeat; and no tracked item in the task's runbook snapshot is at or past its date. On the fast path, run the two step 3 scripts. If both are clean, complete with one line that names the zero counts and skip steps 1, 2 and 4-7. Anything non-zero means continue at step 1.
30
+ 0. Quiet check. Take the fast path when three things hold: every failed, stalled or reboot-interrupted task in the system status was already named in the previous heartbeat-checklist task's output (newest completed one via `get-tasks`); no mention has gone unanswered since the last heartbeat; and no tracked item in the task's runbook snapshot is at or past its date. On the fast path, run the two step 3 scripts. If they report nothing the previous heartbeat had not already triaged, complete with one line citing the previous heartbeat task id and the counts, and skip steps 1, 2 and 4-7. Anything new means continue at step 1.
31
31
  1. Read the latest runbook. The snapshot in the task may be stale.
32
32
  2. Prune first. Check every tracked item against its lift trigger. Remove resolved, stale, and past-date items.
33
33
  3. Run the seeded scripts: `script-run` with name `schedule-health`, then `task-failure-audit`, both with `args: { days: 1, publishPage: false }`. They report failing schedules and failure clusters. Check any other standing order in the runbook yourself.
@@ -13,4 +13,8 @@ When the task names a skill (`researching`, `planning`, `implementing`), use it.
13
13
 
14
14
  Finish the task with one of the four endings in your operating contract: `completed`, `defer-task`, `request-human-input`, or `failed`. Then stop.
15
15
 
16
+ Resuming (a resume task, or work cut by a graceful shutdown): read the parent's last progress line, then re-read live state once (PR state and head SHA, branch, `git status`) before new work. Re-run controls only if the head moved. Never redo a pushed commit or open a second PR for the same branch. Store a progress line with branch, PR number and head SHA each time you push.
17
+
18
+ Write times in replies, outputs and defer notes in the requester's timezone when the Requester Profile names one, never raw UTC.
19
+
16
20
  If the user interrupts, follow their instructions. To resume, call `/work-on-task <taskId>` again.
@@ -8,4 +8,8 @@ When the task names a skill (`researching`, `planning`, `implementing`), use it.
8
8
 
9
9
  Finish the task with one of the four endings in your operating contract: `completed`, `defer-task`, `request-human-input`, or `failed`. Then stop.
10
10
 
11
+ Resuming (a resume task, or work cut by a graceful shutdown): read the parent's last progress line, then re-read live state once (PR state and head SHA, branch, `git status`) before new work. Re-run controls only if the head moved. Never redo a pushed commit or open a second PR for the same branch. Store a progress line with branch, PR number and head SHA each time you push.
12
+
13
+ Write times in replies, outputs and defer notes in the requester's timezone when the Requester Profile names one, never raw UTC.
14
+
11
15
  If the user interrupts, follow their instructions. To resume, call `/work-on-task <taskId>` again.
@@ -173,6 +173,15 @@ Afterwards, a `warnings` entry on `create-workflow` or a patch tool means the ke
173
173
  - Do not set `retry` or `validation.retry` on a `system-one-decision` node. It retries connection errors, 408, 429, and 5xx itself (`config.maxRetries`, 0 to 3, default 2) inside `config.timeoutMs` (default 30000). A 401, a 422, or an answer that fails validation ends after one attempt.
174
174
  - The node reads its provider's key (`TYPESAFE_API_KEY`, `OPENROUTER_API_KEY`, `LAYA_API_KEY`, `OPENAI_DECISIONS_API_KEY`, or `CLOUDFLARE_API_TOKEN`, plus `LAYA_URL` for `laya` and `CLOUDFLARE_ACCOUNT_ID` for `cloudflare`) server-side. The key never appears in the definition, the step output, or an error. A run whose key or `LAYA_URL` is missing fails before any node executes, and a 401 or 403 fails the step as `<KEY> was rejected`. An unresolved `{{token}}` fails the step before any request.
175
175
 
176
+ ### `human-in-the-loop` (how approvals render)
177
+
178
+ The same rules apply to a standalone `request-human-input` call.
179
+
180
+ - **Dashboard card.** `title` is plain text. Each question's `label` is its heading and renders as markdown. `description` renders as full markdown and is the only field that shows images, so put drafts, diffs, and logs there.
181
+ - **Code.** Fence code, logs, and JSON with a language (```` ```json ````): the block keeps its lines and scrolls. Fence a prose draft with no language: it wraps. A single newline outside a fence becomes a paragraph break.
182
+ - **Images.** Put an `http(s)` URL ending in `.png`, `.jpg`, `.jpeg`, `.gif`, or `.webp` alone on its own line in `description`, or use `![alt](url)`. The link must outlive the request: an expired presigned URL shows an "image unavailable" note.
183
+ - **Slack.** A node's `notifications` post the title, the question labels (each collapsed to one line, capped at 3000 characters in total), a Review button, and the timeout. Descriptions, options, code, and images are not shown, so the title and labels must make sense alone. `request-human-input` posts nothing to Slack: share the returned URL yourself.
184
+
176
185
  ### `swarm-script`
177
186
 
178
187
  ```json