@desplega.ai/agent-swarm 1.166.0 → 1.168.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 (221) hide show
  1. package/dist/acp-adapter-zhejfecx.js +34 -0
  2. package/dist/{actions-qz3m692w.js → actions-7jg840nr.js} +25 -25
  3. package/dist/{amp-adapter-j1xbmsqz.js → amp-adapter-ye6rnetm.js} +4 -4
  4. package/dist/{anthropic-messages-hst62ws9.js → anthropic-messages-13yn502s.js} +4 -4
  5. package/dist/{app-43vrdmv8.js → app-ffes2kyf.js} +10 -9
  6. package/dist/{assistant-ertffmka.js → assistant-fhm5smyz.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-20q3nzvg.js} +9 -8
  9. package/dist/{boot-reembed-s7k7c6v2.js → boot-reembed-jvfadhsk.js} +9 -8
  10. package/dist/{boot-scrub-sweep-nepsk4t1.js → boot-scrub-sweep-jtfajzve.js} +10 -9
  11. package/dist/{boot-seeds-vvzs1zhk.js → boot-seeds-9qt0j8j2.js} +12 -11
  12. package/dist/{claude-adapter-2zvmekrp.js → claude-adapter-a8znhthz.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-7x7chdrs.js} +7608 -7596
  16. package/dist/{cli-zsh093hk.js → cli-014vns2y.js} +63 -1
  17. package/dist/{cli-8kgvmkak.js → cli-0jngabtb.js} +1 -1
  18. package/dist/{cli-qvfk83fy.js → cli-13xwcqvj.js} +2 -2
  19. package/dist/{cli-0nhdwxd5.js → cli-43jg9xdw.js} +3 -3
  20. package/dist/{cli-cpfmqz3n.js → cli-47zfzshm.js} +5 -5
  21. package/dist/{cli-zy7vwsne.js → cli-5qkcdv8c.js} +16 -9
  22. package/dist/{cli-cwxhbf43.js → cli-5t5my0h8.js} +85 -2
  23. package/dist/{cli-y59h2pk9.js → cli-69egj654.js} +1 -1
  24. package/dist/{cli-68r1j4sg.js → cli-6bpvv8h4.js} +3 -3
  25. package/dist/{cli-yshaazs1.js → cli-74ez9b66.js} +63 -20
  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-fn697bnt.js → cli-9q0bh411.js} +21 -8
  30. package/dist/{cli-rggdq8wq.js → cli-9q24yyvs.js} +3 -3
  31. package/dist/cli-a35hzdve.js +33 -0
  32. package/dist/{cli-3qn4r334.js → cli-aj1e0h0d.js} +20 -14
  33. package/dist/{cli-rsy933yz.js → cli-bcspch6h.js} +30 -19
  34. package/dist/{cli-x3x0qwky.js → cli-bxqez1p0.js} +51 -23
  35. package/dist/{cli-ng97fjq7.js → cli-cmxkbzzs.js} +33 -7
  36. package/dist/{cli-df56tqdw.js → cli-ee0402n1.js} +198 -70
  37. package/dist/{cli-mwegs4af.js → cli-ew0c3a1j.js} +3 -3
  38. package/dist/{cli-ysnyhws9.js → cli-ewhwpyrb.js} +54 -51
  39. package/dist/{cli-j2jbr8af.js → cli-fhj4y16f.js} +1 -1
  40. package/dist/{cli-8t229cmr.js → cli-fqq06kkb.js} +30 -17
  41. package/dist/{cli-jvj6a0cv.js → cli-g6gx2dn1.js} +54 -19
  42. package/dist/{cli-nexwh8zg.js → cli-g7k7zbcz.js} +12 -5
  43. package/dist/{cli-ecknp7p1.js → cli-j05x7pnb.js} +10 -10
  44. package/dist/{cli-gt8azyet.js → cli-jb931ba2.js} +1 -1
  45. package/dist/{cli-nvvfd8bq.js → cli-jbzbr36n.js} +4091 -3793
  46. package/dist/{cli-3ddybbbx.js → cli-jvmtxqtc.js} +26 -14
  47. package/dist/{cli-pkke6geq.js → cli-km4x66j0.js} +83 -65
  48. package/dist/{cli-b0f70z24.js → cli-mn8qkd8v.js} +1 -1
  49. package/dist/{cli-0qjcsxgz.js → cli-nctfv2yy.js} +99 -9
  50. package/dist/{cli-4r1sjfc4.js → cli-ng1pmdx6.js} +10 -10
  51. package/dist/cli-nkqjj42q.js +104 -0
  52. package/dist/{cli-aqcey741.js → cli-p5jqk8fr.js} +1 -1
  53. package/dist/{cli-66zh26sp.js → cli-pgehz8e1.js} +57 -17
  54. package/dist/cli-q1vnz8wv.js +96 -0
  55. package/dist/{cli-fj86xr3c.js → cli-qd97v6z2.js} +2 -2
  56. package/dist/{cli-p4ztb80h.js → cli-r7eeddkq.js} +13 -8
  57. package/dist/{cli-8ssjez7e.js → cli-rhy7z1rp.js} +22 -11
  58. package/dist/{cli-vzc73wet.js → cli-rr5z1j4p.js} +87 -69
  59. package/dist/{cli-4mevd1hf.js → cli-s9y81s7j.js} +5 -5
  60. package/dist/{cli-dw2q9f7g.js → cli-svah6b6w.js} +4 -4
  61. package/dist/{cli-h3x613ze.js → cli-tc5zec7v.js} +6 -6
  62. package/dist/{cli-ttbsse3f.js → cli-tmhsxhb8.js} +1 -1
  63. package/dist/{cli-feh3dkah.js → cli-v1w7pbd8.js} +2 -1
  64. package/dist/{cli-10zmp1p7.js → cli-vr5avem2.js} +27 -32
  65. package/dist/{cli-bzr0ftht.js → cli-wnb9b2g3.js} +19 -0
  66. package/dist/{cli-krqm5f11.js → cli-xezn9nkh.js} +16 -13
  67. package/dist/{cli-8kt0bmgx.js → cli-xf713zz0.js} +2 -2
  68. package/dist/{cli-6zcfw0x7.js → cli-y195hv1a.js} +1 -1
  69. package/dist/{cli-x7kpr72a.js → cli-ytmmyj7f.js} +2 -2
  70. package/dist/{cli-v2a8x45d.js → cli-z48ggfyq.js} +11 -6
  71. package/dist/{cli-qbksc292.js → cli-zy39d433.js} +2 -0
  72. package/dist/cli.js +28 -28
  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-n63k33d3.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-xkxfd81a.js} +18 -18
  77. package/dist/{commands-mhxam6z2.js → commands-q4mfhn51.js} +9 -8
  78. package/dist/{cursor-adapter-2qda3st7.js → cursor-adapter-v8mm6bp5.js} +5 -5
  79. package/dist/{db-sjks418k.js → db-1t0bj3pc.js} +9 -8
  80. package/dist/{dispatcher-1es759rn.js → dispatcher-k1fhavcf.js} +24 -24
  81. package/dist/{dsh-adapter-bek4cyw9.js → dsh-adapter-5423p70m.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-phrndeb8.js} +18 -8
  87. package/dist/{handlers-1gkbhktr.js → handlers-59ywqwzh.js} +27 -27
  88. package/dist/{hook-vxa3tcsy.js → hook-atj0swc2.js} +14 -14
  89. package/dist/{hook-ds2zxq9v.js → hook-mew1chq9.js} +42 -31
  90. package/dist/{http-3h21z9md.js → http-ef2fnn9v.js} +953 -98
  91. package/dist/{index-n99fycq7.js → index-48pb9erw.js} +24 -24
  92. package/dist/{index-bn6cy8ps.js → index-9fc2h7ww.js} +24 -24
  93. package/dist/{index-9b65j1m5.js → index-gr69c43q.js} +9 -8
  94. package/dist/{index-f8xnqrye.js → index-h26wjcfy.js} +43 -24
  95. package/dist/{index-dkfxpkmm.js → index-w2yhye23.js} +25 -25
  96. package/dist/{keepalive-3xspa9jw.js → keepalive-qhcqz56v.js} +11 -10
  97. package/dist/{lead-6qphc124.js → lead-hpk5r3b1.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-nvkyp2xe.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-ngskthwh.js} +10 -9
  103. package/dist/{onboard-54qmnqxe.js → onboard-yfnkqhxc.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-hhm64r6m.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-35f91nx2.js} +11 -10
  114. package/dist/{rbac-roles-j78wdksm.js → rbac-roles-97w370qj.js} +10 -9
  115. package/dist/{rbac-roles-9pjphh9q.js → rbac-roles-z6sje3gy.js} +9 -8
  116. package/dist/{render-v2-pxjs49fx.js → render-v2-n4v66bj3.js} +10 -9
  117. package/dist/{runtime-v2t1r79e.js → runtime-h9ha1mq6.js} +4 -4
  118. package/dist/{setup-ymej0xtx.js → setup-m3hhyv5f.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-mxf2c842.js} +27 -27
  122. package/dist/{worker-qa9fkz5s.js → worker-9t07z1gq.js} +47 -47
  123. package/openapi.json +72 -1
  124. package/package.json +10 -10
  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/script-connections.ts +40 -6
  129. package/src/be/seed-pricing.ts +84 -53
  130. package/src/be/swarm-config-guard.ts +14 -0
  131. package/src/commands/provider-credentials.ts +28 -5
  132. package/src/commands/runner.ts +7 -12
  133. package/src/extensions/catalog.generated.json +17 -0
  134. package/src/heartbeat/heartbeat.ts +91 -19
  135. package/src/hooks/hook.ts +72 -27
  136. package/src/http/mcp.ts +5 -0
  137. package/src/http/onboarding.ts +2 -0
  138. package/src/http/script-connections.ts +35 -0
  139. package/src/providers/acp-targets.ts +10 -5
  140. package/src/providers/amp-adapter.ts +1 -1
  141. package/src/providers/claude-adapter.ts +32 -0
  142. package/src/providers/claude-background-keepalive.ts +100 -0
  143. package/src/providers/claude-sdk-session.ts +6 -0
  144. package/src/providers/codex-adapter.ts +30 -3
  145. package/src/providers/codex-oauth/auth-json.ts +21 -4
  146. package/src/providers/codex-oauth/types.ts +2 -0
  147. package/src/providers/dsh-adapter.ts +11 -2
  148. package/src/providers/grok-adapter.ts +18 -2
  149. package/src/providers/opencode-adapter.ts +21 -5
  150. package/src/providers/pi-mono-extension.ts +56 -25
  151. package/src/providers/pricing-sources.md +2 -0
  152. package/src/scheduler/scheduler.ts +82 -13
  153. package/src/scripts-runtime/executors/registry.ts +1 -1
  154. package/src/server.ts +63 -3
  155. package/src/tests/acp-adapter.test.ts +152 -12
  156. package/src/tests/ci-unit-test-report.test.ts +73 -0
  157. package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
  158. package/src/tests/claude-background-keepalive.fixture.ts +162 -0
  159. package/src/tests/claude-background-keepalive.test.ts +139 -0
  160. package/src/tests/claude-default-route.test.ts +75 -0
  161. package/src/tests/claude-hook-output.test.ts +233 -0
  162. package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +4 -2
  163. package/src/tests/codex-adapter.test.ts +166 -26
  164. package/src/tests/codex-oauth-auth-timestamps.test.ts +76 -0
  165. package/src/tests/codex-oauth.test.ts +4 -1
  166. package/src/tests/connection-embedded-auth.test.ts +51 -0
  167. package/src/tests/context-window.test.ts +2 -2
  168. package/src/tests/credential-check.test.ts +51 -1
  169. package/src/tests/docs-index-footprint.test.js +3 -3
  170. package/src/tests/dsh-adapter.test.ts +24 -2
  171. package/src/tests/enabled-tools.test.ts +269 -0
  172. package/src/tests/entrypoint-codex-oauth-seed.test.ts +12 -0
  173. package/src/tests/extensions-catalog.test.ts +1 -0
  174. package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
  175. package/src/tests/grok-adapter.test.ts +13 -0
  176. package/src/tests/haiku-5-5-pricing.test.ts +38 -0
  177. package/src/tests/heartbeat-checklist.test.ts +158 -1
  178. package/src/tests/onboarding.test.ts +44 -0
  179. package/src/tests/opencode-adapter.test.ts +66 -3
  180. package/src/tests/opencode-plugin.test.ts +37 -0
  181. package/src/tests/openrouter-attribution-call-sites.test.ts +204 -0
  182. package/src/tests/openrouter-base-url.test.ts +30 -39
  183. package/src/tests/pi-mono-extension.test.ts +161 -2
  184. package/src/tests/pricing-refresh.test.ts +70 -1
  185. package/src/tests/reasoning-effort.test.ts +3 -1
  186. package/src/tests/schedule-target-type.test.ts +136 -0
  187. package/src/tests/script-connections-http.test.ts +45 -0
  188. package/src/tests/script-connections-mcp.test.ts +41 -0
  189. package/src/tests/script-executor-registry.test.ts +18 -0
  190. package/src/tests/send-task-self-send.test.ts +107 -0
  191. package/src/tests/telemetry-trigger.test.ts +12 -1
  192. package/src/tests/workflow-llm.test.ts +14 -0
  193. package/src/tests/workflow-system-one-decision-providers.test.ts +33 -0
  194. package/src/tools/request-human-input.ts +7 -2
  195. package/src/tools/script-connections/tool.ts +35 -3
  196. package/src/tools/send-task.ts +9 -1
  197. package/src/utils/enabled-tools.ts +27 -0
  198. package/src/utils/internal-ai/complete-structured.ts +11 -3
  199. package/src/utils/openrouter-base-url.ts +23 -0
  200. package/src/workflows/executors/raw-llm.ts +1 -0
  201. package/src/workflows/executors/system-one-decision.ts +2 -0
  202. package/src/workflows/executors/validate.ts +1 -0
  203. package/src/workflows/executors/workflow-llm.ts +9 -2
  204. package/templates/extensions/README.md +1 -0
  205. package/templates/extensions/slack-reply-gate/README.md +98 -0
  206. package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
  207. package/templates/extensions/slack-reply-gate/manifest.json +8 -0
  208. package/templates/skills/agent-browser/SKILL.md +2 -0
  209. package/templates/skills/agent-browser/content.md +2 -0
  210. package/templates/skills/code-quality/content.md +1 -0
  211. package/templates/skills/heartbeat-runbook/content.md +1 -1
  212. package/templates/skills/swarm-scripts/SKILL.md +10 -0
  213. package/templates/skills/swarm-scripts/content.md +10 -0
  214. package/templates/skills/work-on-task/SKILL.md +4 -0
  215. package/templates/skills/work-on-task/content.md +4 -0
  216. package/templates/skills/workflow-iterate/content.md +9 -0
  217. package/dist/acp-adapter-yzfxn8vj.js +0 -23
  218. package/dist/cli-3dpj7atn.js +0 -11
  219. package/dist/cli-gvcgmx69.js +0 -183
  220. package/dist/{cli-jhm6hzjn.js → cli-mxvnnv5q.js} +7 -7
  221. package/dist/{codex-hook-d2rkvf39.js → codex-hook-15dgc4cb.js} +1 -1
@@ -0,0 +1,107 @@
1
+ // send-task refuses an agent session that targets itself, but a script runs as its owner (a
2
+ // scheduled script as the schedule's creator), so a Lead-owned script must be able to file
3
+ // work for the Lead. Script calls reach the tool through /api/mcp-bridge as `script-sdk`.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { unlinkSync } from "node:fs";
7
+ import {
8
+ createServer as createHttpServer,
9
+ type IncomingMessage,
10
+ type Server,
11
+ type ServerResponse,
12
+ } from "node:http";
13
+ import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
14
+ import { handleMcpBridge } from "../http/mcp-bridge";
15
+ import { getPathSegments } from "../http/utils";
16
+ import { sendTaskHandler } from "../tools/send-task";
17
+ import { ownerCtx } from "../tools/task-tool-ctx";
18
+ import { getRequestInfo } from "../tools/utils";
19
+
20
+ const TEST_DB_PATH = "./test-send-task-self-send.sqlite";
21
+
22
+ let server: Server;
23
+ let baseUrl: string;
24
+ let leadId = "";
25
+
26
+ const selfSendArgs = (agentId: string) => ({
27
+ task: "x mention to triage",
28
+ agentId,
29
+ routingReason: "skill" as const,
30
+ routingNote: "Lead triages mentions itself",
31
+ offerMode: false,
32
+ allowDuplicate: true,
33
+ });
34
+
35
+ async function tasksFor(agentId: string): Promise<number> {
36
+ return (await getDbClient().get<{ c: number }>(
37
+ "SELECT COUNT(*) AS c FROM agent_tasks WHERE agentId = ?",
38
+ [agentId],
39
+ ))!.c;
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ initDb(TEST_DB_PATH);
44
+ leadId = (
45
+ await createAgent({ name: "self-send-lead", isLead: true, status: "idle", maxTasks: 5 })
46
+ ).id;
47
+ server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
48
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
49
+ const ok = await handleMcpBridge(
50
+ req,
51
+ res,
52
+ getPathSegments(req.url || ""),
53
+ undefined,
54
+ myAgentId,
55
+ );
56
+ if (!ok) {
57
+ res.writeHead(404);
58
+ res.end("Not Found");
59
+ }
60
+ });
61
+ await new Promise<void>((resolve) => server.listen(0, resolve));
62
+ const addr = server.address();
63
+ if (!addr || typeof addr === "string") throw new Error("no port");
64
+ baseUrl = `http://127.0.0.1:${addr.port}`;
65
+ });
66
+
67
+ afterAll(() => {
68
+ server.close();
69
+ closeDb();
70
+ for (const suffix of ["", "-wal", "-shm"]) {
71
+ try {
72
+ unlinkSync(TEST_DB_PATH + suffix);
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
77
+ });
78
+
79
+ describe("send-task self-send guard", () => {
80
+ test("a script sending a task to its own agent succeeds", async () => {
81
+ const before = await tasksFor(leadId);
82
+ const res = await fetch(`${baseUrl}/api/mcp-bridge`, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json", "x-agent-id": leadId },
85
+ body: JSON.stringify({ tool: "send-task", args: selfSendArgs(leadId) }),
86
+ });
87
+ expect(res.status).toBe(200);
88
+ const body = (await res.json()) as { success?: boolean; message?: string };
89
+ expect(body.message).not.toContain("Cannot send a task to yourself");
90
+ expect(body.success).toBe(true);
91
+ expect(await tasksFor(leadId)).toBe(before + 1);
92
+ });
93
+
94
+ test("an agent session sending a task to itself is still refused", async () => {
95
+ const before = await tasksFor(leadId);
96
+ // An MCP session's meta carries no origin mark, so getRequestInfo reports `mcp`.
97
+ const info = getRequestInfo({
98
+ sessionId: "session-1",
99
+ requestInfo: { headers: { "x-agent-id": leadId } },
100
+ } as unknown as Parameters<typeof getRequestInfo>[0]);
101
+ expect(info.callOrigin).toBe("mcp");
102
+ const result = await sendTaskHandler(ownerCtx(info), selfSendArgs(leadId));
103
+ expect(result.ok).toBe(false);
104
+ expect(result.message).toContain("Cannot send a task to yourself");
105
+ expect(await tasksFor(leadId)).toBe(before);
106
+ });
107
+ });
@@ -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"),
@@ -4,6 +4,7 @@ import { getAgentById } from "@/be/db";
4
4
  import {
5
5
  ConnectionAuthInputSchema,
6
6
  connectionAuthInputFromFlat,
7
+ deleteScriptConnection,
7
8
  getScriptConnectionById,
8
9
  listScriptConnections,
9
10
  refreshScriptConnection,
@@ -16,13 +17,23 @@ import { resolveScopedResourceId, scopedResourceScopeIdSchema } from "@/utils/sc
16
17
 
17
18
  const scriptConnectionsInputSchema = z.object({
18
19
  action: z
19
- .enum(["list", "upsert-openapi", "upsert-mcp", "upsert-graphql", "refresh", "disable"])
20
- .describe("List, create/update, refresh, or disable a script connection."),
20
+ .enum([
21
+ "list",
22
+ "upsert-openapi",
23
+ "upsert-mcp",
24
+ "upsert-graphql",
25
+ "refresh",
26
+ "disable",
27
+ "delete",
28
+ ])
29
+ .describe(
30
+ "List, create/update, refresh, disable, or delete a script connection. delete is permanent: it removes the connection, its managed credential binding, and any inline secret it owns.",
31
+ ),
21
32
  id: z
22
33
  .string()
23
34
  .uuid()
24
35
  .optional()
25
- .describe("Existing connection ID for update, refresh, or disable."),
36
+ .describe("Existing connection ID for update, refresh, disable, or delete."),
26
37
  slug: z
27
38
  .string()
28
39
  .min(1)
@@ -193,6 +204,27 @@ export const registerScriptConnectionsTool = (server: McpServer) => {
193
204
  });
194
205
  }
195
206
 
207
+ if (args.action === "delete") {
208
+ if (!args.id) {
209
+ return toolErr("id is required for delete.", {
210
+ data: {
211
+ yourAgentId: requestInfo.agentId,
212
+ connections: listScriptConnections({ includeDisabled: true, allScopes: true }),
213
+ },
214
+ });
215
+ }
216
+ const deleted = await deleteScriptConnection(args.id);
217
+ const connections = listScriptConnections({ includeDisabled: true, allScopes: true });
218
+ if (!deleted) {
219
+ return toolErr(`Script connection ${args.id} not found.`, {
220
+ data: { yourAgentId: requestInfo.agentId, connections },
221
+ });
222
+ }
223
+ return toolOk(`Script connection ${deleted.slug} deleted.`, {
224
+ data: { yourAgentId: requestInfo.agentId, connections },
225
+ });
226
+ }
227
+
196
228
  if (args.action === "refresh") {
197
229
  if (!args.id) {
198
230
  return toolErr("id is required for refresh.", {
@@ -355,7 +355,15 @@ export async function sendTaskHandler(
355
355
  });
356
356
  }
357
357
 
358
- if (ctx.kind === "owner" && requestedAgentId === ctx.agentId) {
358
+ // The guard stops an agent session from handing work back to itself. A script runs as its
359
+ // owner (a scheduled script as the schedule's creator), so a Lead-owned poller that files
360
+ // work for the Lead is legitimate. The bridge marks script calls `script-sdk`; a caller that
361
+ // spoofs it can only send itself a task, which grants nothing it could not already do.
362
+ if (
363
+ ctx.kind === "owner" &&
364
+ requestedAgentId === ctx.agentId &&
365
+ ctx.requestInfo?.callOrigin !== "script-sdk"
366
+ ) {
359
367
  return toolErr("Cannot send a task to yourself, are you drunk?", {
360
368
  data: { yourAgentId: ctx.agentId },
361
369
  });
@@ -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
  }
@@ -13,6 +13,7 @@ Each folder holds exactly one `manifest.yaml`, `manifest.yml`, or `manifest.json
13
13
  | `require-ticket-ref` | `pre.task.create` | Blocks REST, MCP, and Slack tasks that do not name a ticket |
14
14
  | `notify-on-complete` | `post.task.completed`, `post.task.failed` | Posts a summary to a Slack channel |
15
15
  | `deploy-awareness` | `pre.task.create` | Appends a one-line note to new tasks while a Dokploy deploy is in progress |
16
+ | `slack-reply-gate` | `pre.task.create` | Asks Jev (TypeSafe or OpenRouter) whether Lead should answer a Slack thread follow-up; in `enforce` drops the wake-up and marks the message :mute: |
16
17
  | `require-verification-note` | `pre.tool.call` | Refuses completion without a `Verified:` line in the output |
17
18
  | `task-digest` | `post.task.completed`, `post.task.failed` | Ships the `task-digest-collect` script, the `task-digest-daily` schedule (09:00 UTC), the `task-digest-report` workflow, and the `task-digest-guide` skill |
18
19
 
@@ -0,0 +1,98 @@
1
+ # slack-reply-gate
2
+
3
+ Lead is woken by every human message in a Slack thread the swarm takes part in, including "lol", an emoji, or two people chatting with each other. This extension asks Jev, [TypeSafe](https://typesafe.ai)'s decision model, whether Lead should answer a new thread message, and drops the wake-up when it should not.
4
+
5
+ Install config:
6
+
7
+ ```json
8
+ { "mode": "shadow" }
9
+ ```
10
+
11
+ Start in `shadow`, read the decisions in KV for a few days, then switch to `enforce`.
12
+
13
+ ## Modes
14
+
15
+ | `mode` | Effect |
16
+ |---|---|
17
+ | `off` | Never evaluates. |
18
+ | `shadow` (default) | Asks Jev and records the verdict. Never blocks. |
19
+ | `enforce` | Asks Jev, records the verdict, and blocks the Lead task when the answer is no. The message gets :mute: instead: no Lead task, no reply, no outcome card. |
20
+
21
+ ## What it gates
22
+
23
+ Hook: `pre.task.create`, for tasks with origin `slack` whose description carries the thread buffer's header `[Thread follow-up — N message(s) buffered]`. Those are the non-mention thread replies that `src/slack/thread-buffer.ts` batches into a Lead task. A direct @mention of the bot creates a task without that header and is never gated.
24
+
25
+ The header counts only at the start of the description or right after the closing `</thread_context>`. Thread context quotes old messages verbatim, and an older Lead reply can quote the header itself, so a header inside the context is ignored. Only the text after the real header is treated as new.
26
+
27
+ ## Decision
28
+
29
+ 1. A follow-up with downloaded files (status `draft`) passes without a Jev call.
30
+ 2. **@mention pass-through.** A new message that @mentions Lead passes without a Jev call. A mention is the bot mention the engine rewrites to `<@U…> (that's you)`, a resolved `<@U…|lead>`, or a literal `@lead` (any name in `leadAliases`). Only the new messages are scanned, never the thread context, so an old mention earlier in the thread does not pass later banter.
31
+ 3. Otherwise one Jev call (one `choice` question, see [Jev transport](#jev-transport)) with the new message(s), the sender kind, and the last `contextMessages` thread messages. Labels: `direct_ask`, `followup_to_lead`, `instruction_to_lead`, `reaction_only`, `banter_between_others`, `not_addressed_to_lead`, `other`.
32
+ 4. The answer is no only when the top label is `reaction_only`, `banter_between_others`, or `not_addressed_to_lead`, and the summed probability of those three labels is at least `minConfidence`. A split between two skip labels is still a skip. An unsure verdict replies.
33
+
34
+ Several buffered messages are judged together. One mention in any of them passes the batch.
35
+
36
+ ## Jev transport
37
+
38
+ Jev is reachable two ways. Both take the same request and return the same answer, so the decision is identical whichever one runs.
39
+
40
+ | `provider` | Endpoint | Key | Model |
41
+ |---|---|---|---|
42
+ | `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |
43
+ | `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |
44
+ | `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |
45
+
46
+ Under `auto`, `TYPESAFE_API_KEY` wins when both keys are set. With neither key, every message fails open and the KV row names both secrets. Each decision row records the `provider` that answered.
47
+
48
+ ## Fail open
49
+
50
+ Every error lets the task through: a missing key, a TypeSafe HTTP error, a network error, a timeout, a malformed answer, or an unknown label. The handler never throws, so an outage cannot auto-disable the extension. Each failure is recorded as `fail-open` in KV and logged as a warning.
51
+
52
+ ## :mute: on drop (enforce)
53
+
54
+ No swarm tool or extension `ctx` method adds a Slack reaction, so the hook calls the Slack Web API with the bot token. It needs the `reactions:write` scope, which the Slack app already uses for its acceptance reactions.
55
+
56
+ 1. `reactions.remove` for each acceptance reaction the engine may have put on the triggering message: `eyes`, `heavy_plus_sign`, `zap`, `speech_balloon`, or their `SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, and `SLACK_REACTION_STEERED` overrides. Slack only removes the bot's own reaction, so a human's :eyes: stays.
57
+ 2. `reactions.add` with `muteReaction` (default `mute`). `already_reacted` counts as success. Skipped when `muteReaction` is `""`.
58
+
59
+ The block returns first and the Slack calls run after it. A slow or failing Slack call never delays the drop or turns it back into a task. Set `muteReaction` to `""` for a true no-op: step 1 still clears the bot's acceptance reaction(s), and nothing is added.
60
+
61
+ ## Config
62
+
63
+ | Key | Default | Notes |
64
+ |---|---|---|
65
+ | `mode` | `shadow` | `off`, `shadow`, or `enforce` |
66
+ | `timeoutMs` | `3000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |
67
+ | `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |
68
+ | `model` | `jev-latest` | Jev model on TypeSafe |
69
+ | `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |
70
+ | `minConfidence` | `0.55` | Minimum summed skip probability to skip |
71
+ | `contextMessages` | `6` | Thread messages sent to Jev, 0 to 20 |
72
+ | `leadAliases` | `["lead"]` | A literal `@alias` in a new message counts as a mention |
73
+ | `botSlackUserIds` | `[]` | Slack user ids labelled `bot` as the sender for Jev |
74
+ | `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |
75
+ | `muteReaction` | `mute` | Reaction for a dropped message; `""` only removes the acceptance reaction(s) |
76
+ | `slackTimeoutMs` | `5000` | Timeout per Slack call |
77
+
78
+ ## Required secrets
79
+
80
+ All are read from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.
81
+
82
+ - `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every message fails open.
83
+ - `SLACK_BOT_TOKEN`: the Slack app's bot token. A swarm with Slack connected already has it. Without it, `enforce` still drops the message but cannot touch its reactions, and logs a `mute-error`.
84
+
85
+ No key is written to config, KV, logs, or a description.
86
+
87
+ ## KV namespace `ext:slack-reply-gate`
88
+
89
+ - `d:<epochMs>:<channel>_<threadTs>`: one row per decision, with `action` (`pass`, `shadow-skip`, `skipped`, `fail-open`), `provider`, `reason`, `confidence`, `skipP`, `latencyMs`, `sender`, and a 300-character `preview`.
90
+ - `m:<epochMs>:<channel>_<threadTs>`: one row per mute attempt, with `action` (`muted`, `unreacted` when `muteReaction` is `""`, `mute-error`), `removed`, `added`, and `errors`.
91
+ - `last`: the latest decision. Mute rows never overwrite it.
92
+ - Counters: `total`, `mention`, `files`, `reply`, `shadow-skip`, `skipped`, `error`, `muted`, `unreacted`, `mute-error`, and `reason:<label>`.
93
+
94
+ ## Known limits
95
+
96
+ - When Lead already has an active task in the thread, the buffer steers that task instead of creating one, so `pre.task.create` does not fire and the gate does not run.
97
+ - A blocked flush makes the thread buffer log a `TaskCreationBlockedError`. Nothing is lost: the next follow-up's `<thread_context>` carries the earlier messages.
98
+ - A flush of several messages mutes only the triggering (last) message. The others keep their acceptance reaction.