@desplega.ai/agent-swarm 1.167.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 (100) hide show
  1. package/dist/{acp-adapter-zpvy6321.js → acp-adapter-zhejfecx.js} +2 -2
  2. package/dist/{actions-fq2zrwtf.js → actions-7jg840nr.js} +11 -11
  3. package/dist/{amp-adapter-5gbk7501.js → amp-adapter-ye6rnetm.js} +1 -1
  4. package/dist/{app-nctetn1e.js → app-ffes2kyf.js} +3 -3
  5. package/dist/{assistant-0aq8gjvj.js → assistant-fhm5smyz.js} +12 -12
  6. package/dist/{boot-reembed-1ze5853n.js → boot-reembed-20q3nzvg.js} +2 -2
  7. package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-jvfadhsk.js} +2 -2
  8. package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-jtfajzve.js} +3 -3
  9. package/dist/{boot-seeds-22tx8a53.js → boot-seeds-9qt0j8j2.js} +5 -5
  10. package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-a8znhthz.js} +3 -3
  11. package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-7x7chdrs.js} +7590 -7578
  12. package/dist/{cli-ypgba1a8.js → cli-0jngabtb.js} +1 -1
  13. package/dist/{cli-tx2zvn23.js → cli-43jg9xdw.js} +3 -3
  14. package/dist/{cli-6qxmjpem.js → cli-47zfzshm.js} +5 -5
  15. package/dist/{cli-mkdsy1nq.js → cli-74ez9b66.js} +3 -2
  16. package/dist/{cli-ykt8f7va.js → cli-9q0bh411.js} +1 -1
  17. package/dist/{cli-p8rt8spp.js → cli-9q24yyvs.js} +2 -2
  18. package/dist/{cli-cexctgn5.js → cli-aj1e0h0d.js} +1 -1
  19. package/dist/{cli-z1dqhc9y.js → cli-bcspch6h.js} +1 -1
  20. package/dist/{cli-wydz3feg.js → cli-ee0402n1.js} +107 -30
  21. package/dist/{cli-ty4bzkqr.js → cli-ew0c3a1j.js} +1 -1
  22. package/dist/{cli-v1pwxreq.js → cli-ewhwpyrb.js} +1 -1
  23. package/dist/{cli-kw0jcfje.js → cli-fhj4y16f.js} +1 -1
  24. package/dist/{cli-m7vmeepg.js → cli-fqq06kkb.js} +1 -1
  25. package/dist/{cli-577bxkf6.js → cli-g6gx2dn1.js} +31 -6
  26. package/dist/{cli-c8r99akr.js → cli-j05x7pnb.js} +8 -8
  27. package/dist/{cli-c2tpw9ck.js → cli-jb931ba2.js} +1 -1
  28. package/dist/{cli-mxry91sa.js → cli-nctfv2yy.js} +72 -3
  29. package/dist/{cli-ymmc1zxg.js → cli-ng1pmdx6.js} +3 -3
  30. package/dist/{cli-6vfz44sr.js → cli-p5jqk8fr.js} +1 -1
  31. package/dist/{cli-qzmzwegq.js → cli-pgehz8e1.js} +4 -4
  32. package/dist/{cli-cf2vgn9d.js → cli-rr5z1j4p.js} +14 -14
  33. package/dist/{cli-1yrcfa5s.js → cli-svah6b6w.js} +4 -4
  34. package/dist/{cli-y0kjpfm9.js → cli-ytmmyj7f.js} +2 -2
  35. package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
  36. package/dist/cli.js +13 -13
  37. package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-n63k33d3.js} +2 -2
  38. package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-xkxfd81a.js} +2 -2
  39. package/dist/{commands-h6zaaxym.js → commands-q4mfhn51.js} +2 -2
  40. package/dist/{db-qg3myn53.js → db-1t0bj3pc.js} +2 -2
  41. package/dist/{dispatcher-7vnwj9qw.js → dispatcher-k1fhavcf.js} +10 -10
  42. package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
  43. package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-phrndeb8.js} +3 -3
  44. package/dist/{handlers-2qtkpncr.js → handlers-59ywqwzh.js} +13 -13
  45. package/dist/{hook-qnbdgwvd.js → hook-atj0swc2.js} +2 -2
  46. package/dist/{hook-51yfcgsv.js → hook-mew1chq9.js} +1 -1
  47. package/dist/{http-jsmtag08.js → http-ef2fnn9v.js} +659 -26
  48. package/dist/{index-t4g06w93.js → index-48pb9erw.js} +10 -10
  49. package/dist/{index-ekywyv2v.js → index-9fc2h7ww.js} +10 -10
  50. package/dist/{index-2t9kesn1.js → index-gr69c43q.js} +2 -2
  51. package/dist/{index-cx1sb9bq.js → index-h26wjcfy.js} +16 -6
  52. package/dist/{index-bt8wkv1w.js → index-w2yhye23.js} +11 -11
  53. package/dist/{keepalive-4aezx4bj.js → keepalive-qhcqz56v.js} +4 -4
  54. package/dist/{lead-r44bkpm5.js → lead-hpk5r3b1.js} +18 -18
  55. package/dist/{maintenance-grz5jrzh.js → maintenance-nvkyp2xe.js} +2 -2
  56. package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-ngskthwh.js} +3 -3
  57. package/dist/{onboard-v60zgqf0.js → onboard-yfnkqhxc.js} +2 -2
  58. package/dist/{otel-impl-ghh4dyf8.js → otel-impl-hhm64r6m.js} +1 -1
  59. package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-35f91nx2.js} +4 -4
  60. package/dist/{rbac-roles-axv4d74x.js → rbac-roles-97w370qj.js} +3 -3
  61. package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-z6sje3gy.js} +2 -2
  62. package/dist/{render-v2-k50j33qd.js → render-v2-n4v66bj3.js} +3 -3
  63. package/dist/{setup-4syh0pd2.js → setup-m3hhyv5f.js} +2 -2
  64. package/dist/{work-objects-jcdbg8rr.js → work-objects-mxf2c842.js} +13 -13
  65. package/dist/{worker-x8j8c9sx.js → worker-9t07z1gq.js} +18 -18
  66. package/openapi.json +72 -1
  67. package/package.json +3 -3
  68. package/src/be/script-connections.ts +40 -6
  69. package/src/be/swarm-config-guard.ts +1 -0
  70. package/src/extensions/catalog.generated.json +17 -0
  71. package/src/http/script-connections.ts +35 -0
  72. package/src/providers/acp-targets.ts +1 -1
  73. package/src/providers/amp-adapter.ts +1 -1
  74. package/src/providers/claude-adapter.ts +6 -0
  75. package/src/providers/claude-background-keepalive.ts +100 -0
  76. package/src/providers/claude-sdk-session.ts +6 -0
  77. package/src/providers/dsh-adapter.ts +1 -1
  78. package/src/scheduler/scheduler.ts +82 -13
  79. package/src/scripts-runtime/executors/registry.ts +1 -1
  80. package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
  81. package/src/tests/claude-background-keepalive.fixture.ts +162 -0
  82. package/src/tests/claude-background-keepalive.test.ts +139 -0
  83. package/src/tests/connection-embedded-auth.test.ts +51 -0
  84. package/src/tests/docs-index-footprint.test.js +3 -3
  85. package/src/tests/dsh-adapter.test.ts +1 -1
  86. package/src/tests/extensions-catalog.test.ts +1 -0
  87. package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
  88. package/src/tests/schedule-target-type.test.ts +136 -0
  89. package/src/tests/script-connections-http.test.ts +45 -0
  90. package/src/tests/script-connections-mcp.test.ts +41 -0
  91. package/src/tests/script-executor-registry.test.ts +18 -0
  92. package/src/tests/send-task-self-send.test.ts +107 -0
  93. package/src/tools/script-connections/tool.ts +35 -3
  94. package/src/tools/send-task.ts +9 -1
  95. package/templates/extensions/README.md +1 -0
  96. package/templates/extensions/slack-reply-gate/README.md +98 -0
  97. package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
  98. package/templates/extensions/slack-reply-gate/manifest.json +8 -0
  99. package/templates/skills/swarm-scripts/SKILL.md +10 -0
  100. package/templates/skills/swarm-scripts/content.md +10 -0
@@ -1,25 +1,25 @@
1
1
  import {
2
2
  runAgent
3
- } from "./cli-cf2vgn9d.js";
3
+ } from "./cli-rr5z1j4p.js";
4
4
  import"./cli-9q3wt23f.js";
5
5
  import"./cli-6bpvv8h4.js";
6
- import"./cli-ykt8f7va.js";
7
- import"./cli-cexctgn5.js";
6
+ import"./cli-9q0bh411.js";
7
+ import"./cli-aj1e0h0d.js";
8
8
  import"./cli-9k7mqz00.js";
9
- import"./cli-m7vmeepg.js";
9
+ import"./cli-fqq06kkb.js";
10
10
  import"./cli-tc5zec7v.js";
11
11
  import"./cli-hsxj0yar.js";
12
12
  import"./cli-hf5s9pgf.js";
13
13
  import"./cli-jvmtxqtc.js";
14
14
  import"./cli-gt4hxb8p.js";
15
- import"./cli-pkj1w46r.js";
16
- import"./cli-ty4bzkqr.js";
15
+ import"./cli-z48ggfyq.js";
16
+ import"./cli-ew0c3a1j.js";
17
17
  import"./cli-5bfdm1ah.js";
18
18
  import"./cli-efnd2kce.js";
19
19
  import"./cli-zp67qg5x.js";
20
20
  import"./cli-bqjfjex4.js";
21
- import"./cli-mxry91sa.js";
22
- import"./cli-z1dqhc9y.js";
21
+ import"./cli-nctfv2yy.js";
22
+ import"./cli-bcspch6h.js";
23
23
  import"./cli-69egj654.js";
24
24
  import"./cli-v6vv7dy8.js";
25
25
  import"./cli-q2j38vre.js";
@@ -36,15 +36,15 @@ import"./cli-5qkcdv8c.js";
36
36
  import"./cli-dpvkp2et.js";
37
37
  import {
38
38
  getEnabledCapabilities
39
- } from "./cli-wydz3feg.js";
39
+ } from "./cli-ee0402n1.js";
40
40
  import"./cli-xvc3q9br.js";
41
41
  import"./cli-mv2520nm.js";
42
42
  import"./cli-w0bwte75.js";
43
43
  import"./cli-re8z5p8p.js";
44
- import"./cli-tx2zvn23.js";
45
- import"./cli-p8rt8spp.js";
46
- import"./cli-v1pwxreq.js";
47
- import"./cli-577bxkf6.js";
44
+ import"./cli-43jg9xdw.js";
45
+ import"./cli-9q24yyvs.js";
46
+ import"./cli-ewhwpyrb.js";
47
+ import"./cli-g6gx2dn1.js";
48
48
  import"./cli-epan13p1.js";
49
49
  import"./cli-f14fvzag.js";
50
50
  import"./cli-7mhy3ths.js";
@@ -53,14 +53,14 @@ import"./cli-zt3w1cn1.js";
53
53
  import"./cli-km4x66j0.js";
54
54
  import"./cli-rb9m6rpe.js";
55
55
  import"./cli-2x0e561b.js";
56
- import"./cli-kw0jcfje.js";
56
+ import"./cli-fhj4y16f.js";
57
57
  import"./cli-mzfth0bp.js";
58
58
  import"./cli-fad8m16k.js";
59
59
  import"./cli-bsakbbkm.js";
60
60
  import"./cli-zxg80zs9.js";
61
61
  import"./cli-51tf7ac3.js";
62
62
  import"./cli-ydt3zkwv.js";
63
- import"./cli-c8r99akr.js";
63
+ import"./cli-j05x7pnb.js";
64
64
  import"./cli-q51yprfq.js";
65
65
  import"./cli-3er6gcsn.js";
66
66
  import"./cli-yatxqynm.js";
@@ -71,9 +71,9 @@ import"./cli-1m2e086m.js";
71
71
  import"./cli-wnb9b2g3.js";
72
72
  import"./cli-bd957dgb.js";
73
73
  import"./cli-pzxba7gw.js";
74
- import"./cli-ypgba1a8.js";
74
+ import"./cli-0jngabtb.js";
75
75
  import"./cli-rfphkzh5.js";
76
- import"./cli-mkdsy1nq.js";
76
+ import"./cli-74ez9b66.js";
77
77
  import"./cli-3qhyh31j.js";
78
78
  import"./cli-xvdgasgy.js";
79
79
  import"./cli-mxvnnv5q.js";
@@ -92,7 +92,7 @@ import"./cli-ej6gbxfb.js";
92
92
  import"./cli-014vns2y.js";
93
93
  import"./cli-sd0mxa3c.js";
94
94
  import"./cli-w5e1d1vv.js";
95
- import"./cli-ymmc1zxg.js";
95
+ import"./cli-ng1pmdx6.js";
96
96
  import"./cli-mb75wpxf.js";
97
97
  import"./cli-zf8jjwex.js";
98
98
  import"./cli-wt7ccy4r.js";
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.167.0",
5
+ "version": "1.168.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -37248,6 +37248,77 @@
37248
37248
  }
37249
37249
  }
37250
37250
  }
37251
+ },
37252
+ "delete": {
37253
+ "operationId": "script_connections_delete",
37254
+ "summary": "Delete a script connection",
37255
+ "description": "Hard-deletes the connection, its auto-managed credential binding, and any derived inline secret it owns.",
37256
+ "tags": [
37257
+ "Script Connections"
37258
+ ],
37259
+ "security": [
37260
+ {
37261
+ "bearerAuth": []
37262
+ }
37263
+ ],
37264
+ "parameters": [
37265
+ {
37266
+ "schema": {
37267
+ "type": "string",
37268
+ "format": "uuid"
37269
+ },
37270
+ "required": true,
37271
+ "name": "id",
37272
+ "in": "path"
37273
+ }
37274
+ ],
37275
+ "responses": {
37276
+ "200": {
37277
+ "description": "Script connection deleted",
37278
+ "content": {
37279
+ "application/json": {
37280
+ "schema": {
37281
+ "type": "object",
37282
+ "properties": {
37283
+ "deleted": {
37284
+ "type": "boolean",
37285
+ "enum": [
37286
+ true
37287
+ ]
37288
+ },
37289
+ "id": {
37290
+ "type": "string"
37291
+ }
37292
+ },
37293
+ "required": [
37294
+ "deleted",
37295
+ "id"
37296
+ ]
37297
+ }
37298
+ }
37299
+ }
37300
+ },
37301
+ "403": {
37302
+ "description": "Only the lead agent can manage script connections",
37303
+ "content": {
37304
+ "application/json": {
37305
+ "schema": {
37306
+ "$ref": "#/components/schemas/ErrorResponse"
37307
+ }
37308
+ }
37309
+ }
37310
+ },
37311
+ "404": {
37312
+ "description": "Script connection not found",
37313
+ "content": {
37314
+ "application/json": {
37315
+ "schema": {
37316
+ "$ref": "#/components/schemas/ErrorResponse"
37317
+ }
37318
+ }
37319
+ }
37320
+ }
37321
+ }
37251
37322
  }
37252
37323
  },
37253
37324
  "/api/script-connections/{id}/refresh": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.167.0",
3
+ "version": "1.168.0",
4
4
  "description": "agent-swarm.dev — an open-source operating system for AI work: a lead agent breaks goals into tasks, routes them to specialized workers such as Claude Code or Codex, runs each worker in an isolated container, and preserves shared memory, tools, schedules, and review gates so delegated work compounds across sessions.",
5
5
  "keywords": [
6
6
  "agent-swarm.dev",
@@ -202,7 +202,7 @@
202
202
  "dependencies": {
203
203
  "@agentclientprotocol/sdk": "1.7.0",
204
204
  "@ai-sdk/openai": "^3.0.41",
205
- "@anthropic-ai/claude-agent-sdk": "0.3.293",
205
+ "@anthropic-ai/claude-agent-sdk": "0.3.295",
206
206
  "@anthropic-ai/sdk": "^0.132.1",
207
207
  "@asteasolutions/zod-to-openapi": "^8.0.0",
208
208
  "@aws-sdk/client-bedrock": "3.1048.0",
@@ -216,7 +216,7 @@
216
216
  "@jitl/quickjs-singlefile-mjs-release-sync": "0.32.0",
217
217
  "@linear/sdk": "^77.0.0",
218
218
  "@modelcontextprotocol/sdk": "1.31.0",
219
- "@openai/codex": "0.161.0",
219
+ "@openai/codex": "0.162.0",
220
220
  "@opencode-ai/sdk": "^1.18.35",
221
221
  "@openfort/openfort-node": "^0.9.1",
222
222
  "@opentelemetry/api": "^1.9.1",
@@ -1137,8 +1137,8 @@ async function deriveConnectionBinding(
1137
1137
  // connection references this key, so every path that supersedes or removes
1138
1138
  // the connection's inline-secret auth MUST delete this swarm_config row via
1139
1139
  // deleteManagedConnectionSecret() (the managed binding CASCADEs, this does
1140
- // not). upsertScriptConnection handles supersession; a future
1141
- // connection-delete path must handle deletion.
1140
+ // not). upsertScriptConnection handles supersession; deleteScriptConnection
1141
+ // handles deletion.
1142
1142
  configKey = `connection.${ctx.slug}.secret`;
1143
1143
  secretWrite = { scope: ctx.scope, scopeId: ctx.scopeId, key: configKey, value: auth.secret };
1144
1144
  } else if (auth.configKey) {
@@ -1308,10 +1308,8 @@ async function deleteManagedBindingForConnection(connectionId: string): Promise<
1308
1308
  * 1. upsertScriptConnection, when an upsert SUPERSEDES a previously-derived
1309
1309
  * inline secret (slug change, or the new auth no longer uses that derived
1310
1310
  * key). Handled below.
1311
- * 2. FUTURE connection-delete path: there is no deleteScriptConnection today
1312
- * (only mcp_server_id CASCADE), but any such path MUST call this for the
1313
- * connection's derived `connection.<slug>.secret` key (when auth_type used a
1314
- * derived inline secret) so the secret is not orphaned.
1311
+ * 2. deleteScriptConnection, for the connection's derived
1312
+ * `connection.<slug>.secret` key (when auth used a derived inline secret).
1315
1313
  */
1316
1314
  async function deleteManagedConnectionSecret(input: {
1317
1315
  scope: ScriptConnectionScope;
@@ -2079,6 +2077,42 @@ export async function setScriptConnectionEnabled(
2079
2077
  return row ? connectionFromRow(row) : null;
2080
2078
  }
2081
2079
 
2080
+ /**
2081
+ * Hard-delete a script connection. Returns the deleted record, or null when no
2082
+ * connection has that id.
2083
+ *
2084
+ * The auto-managed credential binding CASCADEs (and is deleted explicitly too);
2085
+ * a derived inline secret (`connection.<slug>.secret`) is deleted here because
2086
+ * nothing CASCADEs it. Concurrent upserts read the row's `version` and update
2087
+ * with a `version` predicate, so they fail with ScriptConnectionConflictError
2088
+ * once the row is gone instead of resurrecting it.
2089
+ */
2090
+ export async function deleteScriptConnection(id: string): Promise<ScriptConnectionRecord | null> {
2091
+ const deleted = await getDbClient().transaction(
2092
+ async (tx): Promise<ScriptConnectionRecord | null> => {
2093
+ await tx.run("PRAGMA defer_foreign_keys = ON");
2094
+ const row = await tx.get<ConnectionRow>("SELECT * FROM script_connections WHERE id = ?", [
2095
+ id,
2096
+ ]);
2097
+ if (!row) return null;
2098
+ const connection = connectionFromRow(row);
2099
+ await deleteManagedBindingForConnection(id);
2100
+ await tx.run("DELETE FROM script_connections WHERE id = ?", [id]);
2101
+ const secretKey = derivedInlineSecretKey(connection);
2102
+ if (secretKey) {
2103
+ await deleteManagedConnectionSecret({
2104
+ scope: connection.scope,
2105
+ scopeId: connection.scopeId,
2106
+ key: secretKey,
2107
+ });
2108
+ }
2109
+ return connection;
2110
+ },
2111
+ );
2112
+ if (deleted && derivedInlineSecretKey(deleted)) refreshSecretScrubberCache();
2113
+ return deleted;
2114
+ }
2115
+
2082
2116
  export function getScriptApiConnectionDescriptors(
2083
2117
  context: { agentId?: string; repoId?: string } = {},
2084
2118
  ): ScriptApiConnectionDescriptor[] {
@@ -263,6 +263,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
263
263
  return `Invalid HARNESS_PROVIDER value (must be one of: ${ProviderNameSchema.options.join(", ")})`;
264
264
  },
265
265
  ...enumValidator("CLAUDE_TRANSPORT", ["cli", "sdk"]),
266
+ ...enumValidator("SCRIPT_EXECUTOR", ["native", "quickjs"]),
266
267
  // fail: worker fails a task fast when every key has exhausted the task
267
268
  // model's weekly window (Fable/Opus/Sonnet). fallback: legacy random pick.
268
269
  ...enumValidator("MODEL_WINDOW_EXHAUSTED_POLICY", ["fail", "fallback"]),
@@ -84,6 +84,23 @@
84
84
  },
85
85
  "readme": "# require-verification-note\n\nGuards `store-progress` with `status: \"completed\"`. The call is refused until the `output` contains the configured marker (default `Verified:`). The agent sees the reason as a tool error and can retry with a corrected output.\n\nConfig:\n\n```json\n{ \"marker\": \"Verified:\" }\n```\n\nThe guard covers agent MCP calls only. REST and dashboard completions are not tool calls and pass through.\n"
86
86
  },
87
+ "slack-reply-gate": {
88
+ "manifestFile": "manifest.json",
89
+ "manifest": {
90
+ "$schema": "../manifest.schema.json",
91
+ "name": "slack-reply-gate",
92
+ "description": "Asks Jev (TypeSafe or OpenRouter) whether Lead should answer a buffered Slack thread follow-up, and drops the wake-up when it should not",
93
+ "version": "1.2.0",
94
+ "runtime": "api",
95
+ "assets": {
96
+ "hooks": "hooks.ts"
97
+ }
98
+ },
99
+ "files": {
100
+ "hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * slack-reply-gate\n *\n * Every human message in a Slack thread the swarm takes part in is buffered and\n * flushed into a Lead task (\"[Thread follow-up — N message(s) buffered]\"), even\n * \"lol\", \":joy:\" or two people bantering. This hook asks TypeSafe Jev whether\n * Lead should answer, and drops the wake-up when the answer is no.\n *\n * Hook: `pre.task.create`, origin \"slack\", description carrying the\n * thread-buffer header. `pre.slack.route` cannot be used: the buffered thread\n * path returns before that hook is dispatched, and its result can only\n * re-target a message, never drop it.\n *\n * Rules:\n * - A message that @mentions Lead passes without a Jev call.\n * - Otherwise one Jev `choice` question classifies the message into a reason;\n * reply=false only for reaction_only | banter_between_others |\n * not_addressed_to_lead, and only when the summed probability of those\n * three labels is >= minConfidence (a banter/reaction split is still a skip).\n * - Fails OPEN on every error, timeout or missing key.\n *\n * Transport (config.provider): Jev is served by TypeSafe directly and by\n * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}\n * body and return the same `answers` shape. `auto` uses TYPESAFE_API_KEY when\n * set, else OPENROUTER_API_KEY.\n *\n * modes (config.mode):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, block the task when reply=false, and mark the\n * triggering Slack message with :mute: (config.muteReaction)\n *\n * Muting: no swarm tool or ctx method adds a Slack reaction, so the hook calls\n * the Slack Web API (reactions.remove / reactions.add) with the bot token. It\n * first removes the bot's own acceptance reactions (:eyes:, :heavy_plus_sign:,\n * :zap:, :speech_balloon:, or their SLACK_REACTION_* overrides), the same set\n * the engine clears when it finalizes a task, then adds :mute:. With\n * muteReaction \"\" it only removes them: a true no-op on the message. It runs after\n * the block decision returns, so a slow or failing Slack call never delays or\n * changes the drop; its errors go to the same KV namespace.\n *\n * Every decision lands in KV namespace `ext:slack-reply-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n timeoutMs: z.number().int().positive().default(3000),\n provider: z.enum([\"auto\", \"typesafe\", \"openrouter\"]).default(\"auto\"),\n /** Jev model on the TypeSafe transport. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on the OpenRouter transport. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n minConfidence: z.number().min(0).max(1).default(0.55),\n contextMessages: z.number().int().min(0).max(20).default(6),\n leadAliases: z.array(z.string()).default([\"lead\"]),\n botSlackUserIds: z.array(z.string()).default([]),\n /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */\n apiKey: z.string().optional(),\n /**\n * Reaction for a dropped message in enforce mode. \"\" only removes the bot's\n * acceptance reaction(s) and adds nothing.\n */\n muteReaction: z.string().default(\"mute\"),\n slackTimeoutMs: z.number().int().positive().default(5000),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:slack-reply-gate\";\nconst TYPESAFE_ENDPOINT = \"https://api.typesafe.ai/v1/systemone\";\nconst OPENROUTER_ENDPOINT = \"https://openrouter.ai/api/alpha/decisions\";\nconst HEADER_LINE_RE = /(?:^|\\n)\\[Thread follow-up — \\d+ message\\(s\\) buffered\\]\\n/g;\nconst CTX_OPEN = \"<thread_context>\";\nconst CTX_CLOSE = \"</thread_context>\";\n// The dispatcher cancels a handler after 5 s; leave room for the KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n\nexport const REASONS = {\n direct_ask:\n \"Asks Lead (or the swarm, the bot, 'you' meaning the AI) a question or for something, explicitly or clearly implied.\",\n followup_to_lead:\n \"Answers, confirms, corrects or continues something Lead or another swarm agent ([Agent]) just said or asked, so Lead needs it to proceed.\",\n instruction_to_lead:\n \"Gives a task, instruction, request or decision for the AI to act on, even without naming it (for example 'can you create X', 'ship it', 'do it').\",\n reaction_only:\n \"Only a reaction: emoji, laughter (lol, haha, jaja), 'boom!', 'nice', 'thanks', '+1', with no request and nothing new for Lead.\",\n banter_between_others:\n \"Chat between the humans, or between other people and other bots, that is not aimed at Lead.\",\n not_addressed_to_lead:\n \"Addressed to a specific other person or another assistant, or an FYI that needs no answer from Lead.\",\n other: \"None of the above, or unclear.\",\n} as const;\n\nexport type Reason = keyof typeof REASONS;\nconst SKIP_REASONS: ReadonlySet<string> = new Set([\n \"reaction_only\",\n \"banter_between_others\",\n \"not_addressed_to_lead\",\n]);\n\nconst cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._:-]/g, \"_\").slice(0, 120);\n\nconst escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n/**\n * Locate the engine's follow-up header (src/slack/thread-buffer.ts): at the start of the\n * description, or right after the closing `</thread_context>`. Thread context quotes old\n * messages verbatim, so a header (or a `</thread_context>`) quoted inside it is skipped.\n */\nconst findHeader = (description: string): { index: number; end: number } | null => {\n for (const m of description.matchAll(HEADER_LINE_RE)) {\n const index = m.index! + (m[0].startsWith(\"\\n\") ? 1 : 0);\n const before = description.slice(0, index);\n const open = before.indexOf(CTX_OPEN);\n if (open < 0 || before.trimEnd().endsWith(CTX_CLOSE)) {\n return { index, end: m.index! + m[0].length };\n }\n }\n return null;\n};\n\n/**\n * Split a buffered follow-up description into thread context and new messages, or null\n * when the description is not a buffered follow-up. Only `messages` is new text.\n */\nexport const parseFollowUp = (\n description: string,\n): { context: string[]; messages: string[] } | null => {\n const header = findHeader(description);\n if (!header) return null;\n const before = description.slice(0, header.index).trimEnd();\n const open = before.indexOf(CTX_OPEN);\n const context =\n open >= 0\n ? before\n .slice(open + CTX_OPEN.length, before.length - CTX_CLOSE.length)\n .split(/\\n(?=<@[A-Z0-9]+[|>]|\\[Agent\\]:)/)\n .map((m) => m.trim())\n .filter(Boolean)\n : [];\n const messages = description\n .slice(header.end)\n .split(\"\\n---\\n\")\n .map((m) => m.trim())\n .filter(Boolean);\n return { context, messages };\n};\n\n/** True when the new text addresses Lead with an @mention. */\nexport const mentionsLead = (text: string, aliases: readonly string[]): boolean => {\n // The buffer rewrites a mention of the swarm's own bot to `<@U…> (that's you)`.\n if (/<@[A-Z0-9]+>\\s*\\(that's you\\)/.test(text)) return true;\n const names = aliases\n .map((a) => a.trim())\n .filter(Boolean)\n .map(escapeRe);\n if (names.length === 0) return false;\n const alt = names.join(\"|\");\n return (\n new RegExp(`(^|[^\\\\w@])@(${alt})\\\\b`, \"i\").test(text) ||\n new RegExp(`<@[A-Z0-9]+\\\\|(${alt})>`, \"i\").test(text)\n );\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret: explicit value, then the API process env. The API process loads\n * global swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string, explicit?: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = [String(explicit ?? \"\"), envOf(name)].find(usable) ?? \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type JevTransport = {\n provider: \"typesafe\" | \"openrouter\";\n endpoint: string;\n key: string;\n model: string;\n};\n\n/**\n * Pick the Jev transport. `auto` prefers TypeSafe and falls back to OpenRouter.\n * Throws when the chosen provider has no usable key; the handler fails open.\n */\nexport const resolveTransport = (cfg: Cfg): JevTransport => {\n const typesafe = (): JevTransport | null => {\n const key = secret(\"TYPESAFE_API_KEY\", cfg.apiKey);\n return usable(key)\n ? { provider: \"typesafe\", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }\n : null;\n };\n const openrouter = (): JevTransport | null => {\n const key = secret(\"OPENROUTER_API_KEY\");\n return usable(key)\n ? { provider: \"openrouter\", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }\n : null;\n };\n const transport =\n cfg.provider === \"typesafe\"\n ? typesafe()\n : cfg.provider === \"openrouter\"\n ? openrouter()\n : (typesafe() ?? openrouter());\n if (transport) return transport;\n throw new Error(\n cfg.provider === \"typesafe\"\n ? \"no TYPESAFE_API_KEY\"\n : cfg.provider === \"openrouter\"\n ? \"no OPENROUTER_API_KEY\"\n : \"no TYPESAFE_API_KEY or OPENROUTER_API_KEY\",\n );\n};\n\nconst SLACK_API = \"https://slack.com/api\";\n// The engine's acceptance reactions (src/slack/reaction-shortcode.ts), by config key.\nconst ACCEPTANCE_REACTIONS: ReadonlyArray<[string, string]> = [\n [\"SLACK_REACTION_ACCEPTED\", \"eyes\"],\n [\"SLACK_REACTION_BUFFERED\", \"heavy_plus_sign\"],\n [\"SLACK_REACTION_NOW\", \"zap\"],\n [\"SLACK_REACTION_STEERED\", \"speech_balloon\"],\n];\nconst SHORTCODE_RE = /^[a-z0-9_+'-]+(::skin-tone-[2-6])?$/;\nconst shortcode = (raw: string): string | null => {\n const s = raw.trim().replace(/^:/, \"\").replace(/:$/, \"\").toLowerCase();\n return s && SHORTCODE_RE.test(s) ? s : null;\n};\n\n/** The acceptance reactions the engine may have put on the message, deduplicated. */\nexport const acceptanceReactions = (): string[] => [\n ...new Set(ACCEPTANCE_REACTIONS.map(([key, fallback]) => shortcode(envOf(key)) ?? fallback)),\n];\n\nconst slackCall = async (\n method: string,\n token: string,\n body: Record<string, string>,\n timeoutMs: number,\n): Promise<{ ok: boolean; error?: string }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetch(`${SLACK_API}/${method}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n if (!res.ok) return { ok: false, error: `http_${res.status}` };\n const data = (await res.json()) as { ok?: boolean; error?: string };\n return { ok: data?.ok === true, error: data?.error };\n } finally {\n clearTimeout(timer);\n }\n};\n\nexport type MuteResult = { removed: string[]; added: boolean; errors: string[] };\n\n/**\n * Replace the bot's acceptance reaction(s) on one message with `name`, or only\n * remove them when `name` is \"\". `reactions.remove` only removes the calling\n * bot's own reaction, so a human's :eyes: stays. Never throws: every failure\n * lands in `errors`.\n */\nexport const muteMessage = async (\n token: string,\n channel: string,\n timestamp: string,\n name: string,\n timeoutMs: number,\n): Promise<MuteResult> => {\n const result: MuteResult = { removed: [], added: false, errors: [] };\n const call = (method: string, reaction: string) =>\n slackCall(method, token, { channel, timestamp, name: reaction }, timeoutMs).catch(\n (err): { ok: boolean; error?: string } => ({ ok: false, error: String(err).slice(0, 120) }),\n );\n const removals = acceptanceReactions().filter((r) => r !== name);\n const removed = await Promise.all(removals.map((r) => call(\"reactions.remove\", r)));\n removed.forEach((res, i) => {\n const reaction = removals[i]!;\n if (res.ok) result.removed.push(reaction);\n else if (res.error !== \"no_reaction\" && res.error !== \"invalid_name\")\n result.errors.push(`remove ${reaction}: ${res.error ?? \"unknown\"}`);\n });\n if (!name) return result;\n const added = await call(\"reactions.add\", name);\n if (added.ok || added.error === \"already_reacted\") result.added = true;\n else result.errors.push(`add ${name}: ${added.error ?? \"unknown\"}`);\n return result;\n};\n\n// In-flight mutes, so tests can await the work the handler does not wait for.\nconst pendingMutes = new Set<Promise<void>>();\n/** Test hook: wait for every in-flight mute. */\nexport const settleMutes = async (): Promise<void> => {\n await Promise.allSettled([...pendingMutes]);\n};\n\nexport type JevVerdict = {\n reason: string;\n confidence: number | null;\n probabilities: Record<string, number> | null;\n latencyMs: number;\n};\n\n/**\n * One Jev call: classify the newest message into a reason. Throws on any failure.\n * TypeSafe and OpenRouter share the request body and the `answers` response shape.\n */\nexport const askJev = async (\n input: {\n messages: string[];\n context: string[];\n sender: string;\n leadNames: readonly string[];\n },\n transport: JevTransport,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<JevVerdict> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));\n const onOuterAbort = () => controller.abort();\n outer?.addEventListener(\"abort\", onOuterAbort);\n const t0 = Date.now();\n try {\n const res = await fetch(transport.endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${transport.key}`,\n },\n body: JSON.stringify({\n state: {\n role: \"Decide whether Lead, the coordinating AI agent of a team's agent swarm, should answer a new Slack message in a thread it takes part in. Lead is woken by every message in the thread, including ones not meant for it. Answering noise wastes everyone's time; missing a real ask is worse.\",\n lead_names: input.leadNames,\n recent_thread_messages: input.context,\n new_message: input.messages.join(\"\\n---\\n\"),\n new_message_sender: input.sender,\n },\n model: transport.model,\n questions: {\n reason: {\n type: \"choice\",\n instructions:\n \"Which label best describes new_message from Lead's point of view? Use recent_thread_messages to tell who it is talking to. Lines starting with [Agent] are swarm bots.\",\n criteria: REASONS,\n },\n },\n }),\n signal: controller.signal,\n });\n if (!res.ok) throw new Error(`${transport.provider} ${res.status}`);\n const body = (await res.json()) as {\n answers?: { reason?: { choice?: unknown; confidence?: unknown; probabilities?: unknown } };\n } | null;\n const answer = body?.answers?.reason;\n const reason = typeof answer?.choice === \"string\" ? answer.choice : \"\";\n if (!(reason in REASONS)) throw new Error(`unexpected choice: ${String(reason).slice(0, 60)}`);\n const confidence = typeof answer?.confidence === \"number\" ? answer.confidence : null;\n const probabilities =\n answer?.probabilities && typeof answer.probabilities === \"object\"\n ? (answer.probabilities as Record<string, number>)\n : null;\n return { reason, confidence, probabilities, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\n/** Probability that the message needs no reply: the summed mass of the skip labels. */\nexport const skipProbability = (verdict: JevVerdict): number | null => {\n if (verdict.probabilities) {\n let sum = 0;\n for (const [label, p] of Object.entries(verdict.probabilities)) {\n if (SKIP_REASONS.has(label) && typeof p === \"number\") sum += p;\n }\n return sum;\n }\n return verdict.confidence;\n};\n\n/** Map a Jev verdict to the typed decision. An unsure skip verdict replies. */\nexport const decide = (\n verdict: JevVerdict,\n minConfidence: number,\n): { reply: boolean; reason: Reason; skipP: number | null; lowConfidence: boolean } => {\n const reason = verdict.reason as Reason;\n const skipP = skipProbability(verdict);\n if (!SKIP_REASONS.has(reason)) return { reply: true, reason, skipP, lowConfidence: false };\n const lowConfidence = skipP !== null && skipP < minConfidence;\n return { reply: lowConfidence, reason, skipP, lowConfidence };\n};\n\nconst record = async (\n swarm: Swarm,\n entry: Record<string, unknown>,\n counters: string[],\n prefix: \"d\" | \"m\" = \"d\",\n) => {\n const at = new Date().toISOString();\n const thread = safeKey(`${entry.channelId ?? \"\"}_${entry.threadTs ?? \"\"}`);\n const value = { at, ...entry };\n await Promise.allSettled([\n swarm.kv_set({ namespace: NS, key: `${prefix}:${Date.now()}:${thread}`, value }),\n // `last` stays the latest decision; mute outcomes only get their own row.\n ...(prefix === \"d\" ? [swarm.kv_set({ namespace: NS, key: \"last\", value })] : []),\n ...counters.map((c) => swarm.kv_incr({ namespace: NS, key: safeKey(c) })),\n ]);\n};\n\n/** Mute the triggering message and log the outcome. Never throws. */\nconst muteTrigger = async (\n swarm: Swarm,\n cfg: Cfg,\n base: Record<string, unknown>,\n): Promise<void> => {\n try {\n const channel = String(base.channelId ?? \"\");\n const ts = String(base.triggerTs ?? \"\");\n if (!channel || !ts) throw new Error(\"no slackChannelId or slackTriggerMessageTs\");\n const token = secret(\"SLACK_BOT_TOKEN\");\n if (!usable(token)) throw new Error(\"no SLACK_BOT_TOKEN\");\n const result = await muteMessage(token, channel, ts, cfg.muteReaction, cfg.slackTimeoutMs);\n // \"\" adds nothing: success is clearing the acceptance reaction(s).\n const done = cfg.muteReaction ? \"muted\" : \"unreacted\";\n const ok = (result.added || !cfg.muteReaction) && result.errors.length === 0;\n const action = ok ? done : \"mute-error\";\n await record(swarm, { ...base, action, ...result }, [action], \"m\");\n } catch (err) {\n await record(\n swarm,\n { ...base, action: \"mute-error\", errors: [String(err).slice(0, 300)] },\n [\"mute-error\"],\n \"m\",\n ).catch(() => {});\n }\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.task.create\", async (event, ctx) => {\n if (event.origin !== \"slack\") return;\n // Only buffered follow-ups. A direct @mention task never carries the header outside\n // its thread context.\n const followUp = parseFollowUp(event.description);\n if (!followUp) return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\") return;\n\n const opts = event.options;\n const base = {\n mode: cfg.mode,\n channelId: opts.slackChannelId,\n threadTs: opts.slackThreadTs,\n triggerTs: opts.slackTriggerMessageTs,\n senderSlackId: opts.slackUserId,\n };\n\n try {\n // A draft follow-up carries downloaded files: always let Lead see them.\n if (opts.status === \"draft\") {\n await record(ctx.swarm, { ...base, action: \"pass\", reason: \"has_files\" }, [\n \"total\",\n \"files\",\n ]);\n return;\n }\n\n // The mention check reads only the new messages, never the thread context.\n const { context, messages } = followUp;\n const preview = messages.join(\" | \").slice(0, 300);\n\n if (messages.some((m) => mentionsLead(m, cfg.leadAliases))) {\n await record(ctx.swarm, { ...base, action: \"pass\", reason: \"mention\", preview }, [\n \"total\",\n \"mention\",\n ]);\n return;\n }\n\n const transport = resolveTransport(cfg);\n\n const sender =\n opts.slackUserId && cfg.botSlackUserIds.includes(opts.slackUserId)\n ? \"bot\"\n : opts.requestedByUserId\n ? \"human (known teammate)\"\n : \"unknown (not a known teammate; may be a bot or an external person)\";\n\n const verdict = await askJev(\n {\n messages,\n context: context.slice(-cfg.contextMessages).map((m) => m.slice(0, 500)),\n sender,\n leadNames: cfg.leadAliases,\n },\n transport,\n cfg,\n ctx.signal,\n );\n const decision = decide(verdict, cfg.minConfidence);\n\n const skip = !decision.reply;\n const action = skip ? (cfg.mode === \"enforce\" ? \"skipped\" : \"shadow-skip\") : \"pass\";\n await record(\n ctx.swarm,\n {\n ...base,\n action,\n reply: decision.reply,\n reason: decision.reason,\n confidence: verdict.confidence,\n skipP: decision.skipP,\n lowConfidence: decision.lowConfidence,\n latencyMs: verdict.latencyMs,\n provider: transport.provider,\n sender,\n preview,\n },\n [\"total\", skip ? action : \"reply\", `reason:${decision.reason}`],\n );\n\n if (skip && cfg.mode === \"enforce\") {\n // Not awaited: the block must return inside the dispatcher's 5 s budget\n // whatever Slack does, and a failed reaction never changes the drop.\n // An empty (or invalid) muteReaction still clears the acceptance reaction(s).\n const muteReaction = shortcode(cfg.muteReaction) ?? \"\";\n const pending = muteTrigger(ctx.swarm, { ...cfg, muteReaction }, base);\n pendingMutes.add(pending);\n void pending.finally(() => pendingMutes.delete(pending));\n return block(\n `slack-reply-gate: no reply needed (${decision.reason}${decision.skipP !== null ? `, p(skip) ${decision.skipP.toFixed(2)}` : \"\"})`,\n );\n }\n return;\n } catch (err) {\n // Fail open, always.\n try {\n await record(\n ctx.swarm,\n { ...base, action: \"fail-open\", error: String(err).slice(0, 300) },\n [\"total\", \"error\"],\n );\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"slack-reply-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
101
+ },
102
+ "readme": "# slack-reply-gate\n\nLead 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.\n\nInstall config:\n\n```json\n{ \"mode\": \"shadow\" }\n```\n\nStart in `shadow`, read the decisions in KV for a few days, then switch to `enforce`.\n\n## Modes\n\n| `mode` | Effect |\n|---|---|\n| `off` | Never evaluates. |\n| `shadow` (default) | Asks Jev and records the verdict. Never blocks. |\n| `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. |\n\n## What it gates\n\nHook: `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.\n\nThe 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.\n\n## Decision\n\n1. A follow-up with downloaded files (status `draft`) passes without a Jev call.\n2. **@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.\n3. 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`.\n4. 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.\n\nSeveral buffered messages are judged together. One mention in any of them passes the batch.\n\n## Jev transport\n\nJev is reachable two ways. Both take the same request and return the same answer, so the decision is identical whichever one runs.\n\n| `provider` | Endpoint | Key | Model |\n|---|---|---|---|\n| `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |\n| `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |\n| `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |\n\nUnder `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.\n\n## Fail open\n\nEvery 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.\n\n## :mute: on drop (enforce)\n\nNo 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.\n\n1. `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.\n2. `reactions.add` with `muteReaction` (default `mute`). `already_reacted` counts as success. Skipped when `muteReaction` is `\"\"`.\n\nThe 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.\n\n## Config\n\n| Key | Default | Notes |\n|---|---|---|\n| `mode` | `shadow` | `off`, `shadow`, or `enforce` |\n| `timeoutMs` | `3000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `minConfidence` | `0.55` | Minimum summed skip probability to skip |\n| `contextMessages` | `6` | Thread messages sent to Jev, 0 to 20 |\n| `leadAliases` | `[\"lead\"]` | A literal `@alias` in a new message counts as a mention |\n| `botSlackUserIds` | `[]` | Slack user ids labelled `bot` as the sender for Jev |\n| `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |\n| `muteReaction` | `mute` | Reaction for a dropped message; `\"\"` only removes the acceptance reaction(s) |\n| `slackTimeoutMs` | `5000` | Timeout per Slack call |\n\n## Required secrets\n\nAll 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.\n\n- `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.\n- `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`.\n\nNo key is written to config, KV, logs, or a description.\n\n## KV namespace `ext:slack-reply-gate`\n\n- `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`.\n- `m:<epochMs>:<channel>_<threadTs>`: one row per mute attempt, with `action` (`muted`, `unreacted` when `muteReaction` is `\"\"`, `mute-error`), `removed`, `added`, and `errors`.\n- `last`: the latest decision. Mute rows never overwrite it.\n- Counters: `total`, `mention`, `files`, `reply`, `shadow-skip`, `skipped`, `error`, `muted`, `unreacted`, `mute-error`, and `reason:<label>`.\n\n## Known limits\n\n- 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.\n- A blocked flush makes the thread buffer log a `TaskCreationBlockedError`. Nothing is lost: the next follow-up's `<thread_context>` carries the earlier messages.\n- A flush of several messages mutes only the triggering (last) message. The others keep their acceptance reaction.\n"
103
+ },
87
104
  "task-digest": {
88
105
  "manifestFile": "manifest.yaml",
89
106
  "manifest": {
@@ -26,6 +26,7 @@ import {
26
26
  type ConnectionAuthSummary,
27
27
  connectionAuthInputFromFlat,
28
28
  connectionAuthSummary,
29
+ deleteScriptConnection,
29
30
  findCredentialBindingByIdentity,
30
31
  getCredentialBindingById,
31
32
  getScriptConnectionById,
@@ -554,6 +555,27 @@ const setConnectionEnabledRoute = route({
554
555
  rbac: { permission: "script-connection.manage" },
555
556
  });
556
557
 
558
+ const deleteConnectionRoute = route({
559
+ method: "delete",
560
+ path: "/api/script-connections/{id}",
561
+ pattern: ["api", "script-connections", null],
562
+ operationId: "script_connections_delete",
563
+ summary: "Delete a script connection",
564
+ description:
565
+ "Hard-deletes the connection, its auto-managed credential binding, and any derived inline secret it owns.",
566
+ tags: ["Script Connections"],
567
+ params: idParamsSchema,
568
+ responses: {
569
+ 200: {
570
+ description: "Script connection deleted",
571
+ schema: z.object({ deleted: z.literal(true), id: z.string() }),
572
+ },
573
+ 403: { description: "Only the lead agent can manage script connections" },
574
+ 404: { description: "Script connection not found" },
575
+ },
576
+ rbac: { permission: "script-connection.manage" },
577
+ });
578
+
557
579
  const listCredentialBindingsRoute = route({
558
580
  method: "get",
559
581
  path: "/api/credential-bindings",
@@ -2111,6 +2133,19 @@ export async function handleScriptConnections(
2111
2133
  return true;
2112
2134
  }
2113
2135
 
2136
+ if (deleteConnectionRoute.match(req.method, pathSegments)) {
2137
+ const parsed = await deleteConnectionRoute.parse(req, res, pathSegments, queryParams);
2138
+ if (!parsed) return true;
2139
+ if (!(await ensureConnectionAdmin(req, res, agentId))) return true;
2140
+ const deleted = await deleteScriptConnection(parsed.params.id);
2141
+ if (!deleted) {
2142
+ jsonError(res, "Script connection not found.", 404);
2143
+ return true;
2144
+ }
2145
+ deleteConnectionRoute.respond(res, 200, { deleted: true, id: deleted.id });
2146
+ return true;
2147
+ }
2148
+
2114
2149
  if (listCredentialBindingsRoute.match(req.method, pathSegments)) {
2115
2150
  const includeManaged = queryParams.get("includeManaged") === "true";
2116
2151
  listCredentialBindingsRoute.respond(res, 200, {
@@ -253,7 +253,7 @@ const geminiTargetProfile: AcpTargetProfile = {
253
253
  /**
254
254
  * Copilot CLI has no system-prompt flag. Under `--acp` it reads custom
255
255
  * instructions from the git root, the cwd, and every directory listed in
256
- * COPILOT_CUSTOM_INSTRUCTIONS_DIRS. Verified against 1.0.93: from those extra
256
+ * COPILOT_CUSTOM_INSTRUCTIONS_DIRS. Verified against 1.0.94: from those extra
257
257
  * directories it loads `.github/instructions/*.instructions.md`, but not
258
258
  * `AGENTS.md`. So the swarm prompt goes into a per-task directory outside the
259
259
  * repo, and the repo's own AGENTS.md is never touched.
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1791460855-g1f688c";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1791547250-gb79b2a";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -41,6 +41,7 @@ import {
41
41
  terminateProcessGroup,
42
42
  } from "../utils/process-group";
43
43
  import { scrubSecrets } from "../utils/secret-scrubber";
44
+ import { ClaudeBackgroundKeepalive } from "./claude-background-keepalive";
44
45
  import { normalizeClaudeMessage } from "./claude-session-events";
45
46
  import { resolveSlashSkillPrompt } from "./codex-skill-resolver";
46
47
  import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
@@ -904,6 +905,8 @@ class ClaudeSession implements ProviderSession {
904
905
  private lastAssistantText = "";
905
906
  /** Per-session stream-json transcript used by the parent-owned session summarizer. */
906
907
  private transcript: string[];
908
+ /** Keeps the session heartbeat fresh while Claude idles on background tasks. */
909
+ private backgroundKeepalive: ClaudeBackgroundKeepalive;
907
910
  readonly deliverSteering?: (delivery: SteerDelivery) => Promise<SteerDeliveryResult>;
908
911
 
909
912
  constructor(
@@ -925,6 +928,7 @@ class ClaudeSession implements ProviderSession {
925
928
  this.contextWindowSize = getContextWindowSize(model);
926
929
  this.systemPromptFile = systemPromptFile;
927
930
  this.transcript = [`User: ${scrubSecrets(config.prompt)}`];
931
+ this.backgroundKeepalive = new ClaudeBackgroundKeepalive(config);
928
932
  const cmd = this.buildCommand();
929
933
 
930
934
  console.log(
@@ -1158,6 +1162,7 @@ class ClaudeSession implements ProviderSession {
1158
1162
  try {
1159
1163
  await Promise.all([stdoutPromise, stderrPromise]);
1160
1164
  } finally {
1165
+ this.backgroundKeepalive.stop();
1161
1166
  this.closeStdin();
1162
1167
  }
1163
1168
  await logFileHandle.end();
@@ -1226,6 +1231,7 @@ class ClaudeSession implements ProviderSession {
1226
1231
  if (json.type === "result") {
1227
1232
  this.closeStdin();
1228
1233
  }
1234
+ this.backgroundKeepalive.observe(json);
1229
1235
 
1230
1236
  const normalized = normalizeClaudeMessage(json, {
1231
1237
  taskId: this.config.taskId,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Keeps a Claude session's heartbeat fresh while it waits on background work.
3
+ *
4
+ * A Claude session that starts a `run_in_background` Bash job (or backgrounds a
5
+ * subagent) can end its turn and sit idle until the job's task_notification
6
+ * wakes it. During that wait it makes no tool calls and streams no output, so
7
+ * neither liveness input (the PostToolUse heartbeat and session-log ingestion)
8
+ * moves `active_sessions.lastHeartbeatAt`, and the stalled-task sweep classifies
9
+ * the live session as stale after `HEARTBEAT_STALL_STALE_HB_MIN`.
10
+ *
11
+ * Claude Code emits `system/background_tasks_changed` with the full set of live
12
+ * background tasks on every membership change (REPLACE semantics). While that
13
+ * set holds at least one non-ambient task, this pings the session heartbeat on
14
+ * an interval. With no background work the timer never runs, so a hung session
15
+ * still goes stale. A background job that never settles is bounded too: the
16
+ * keepalive stops once the session has emitted nothing for
17
+ * `BACKGROUND_KEEPALIVE_MAX_IDLE_MS`.
18
+ */
19
+
20
+ /** Ping interval; well inside the 15-minute stale-session threshold. */
21
+ export const BACKGROUND_KEEPALIVE_INTERVAL_MS = 60_000;
22
+ /** Longest silent wait on background work that the keepalive will cover. */
23
+ export const BACKGROUND_KEEPALIVE_MAX_IDLE_MS = 2 * 60 * 60_000;
24
+
25
+ type BackgroundTaskEntry = { task_id?: unknown; ambient?: unknown };
26
+
27
+ export interface ClaudeBackgroundKeepaliveOptions {
28
+ apiUrl: string;
29
+ apiKey: string;
30
+ agentId: string;
31
+ taskId: string;
32
+ intervalMs?: number;
33
+ maxIdleMs?: number;
34
+ }
35
+
36
+ export class ClaudeBackgroundKeepalive {
37
+ private liveTaskIds: string[] = [];
38
+ private timer: ReturnType<typeof setInterval> | undefined;
39
+ private lastMessageAt = Date.now();
40
+ private stopped = false;
41
+ private readonly intervalMs: number;
42
+ private readonly maxIdleMs: number;
43
+
44
+ constructor(private readonly options: ClaudeBackgroundKeepaliveOptions) {
45
+ this.intervalMs = options.intervalMs ?? BACKGROUND_KEEPALIVE_INTERVAL_MS;
46
+ this.maxIdleMs = options.maxIdleMs ?? BACKGROUND_KEEPALIVE_MAX_IDLE_MS;
47
+ }
48
+
49
+ /** Feed every protocol message the Claude process emits. */
50
+ observe(message: { type?: string; subtype?: string; tasks?: unknown }): void {
51
+ if (this.stopped) return;
52
+ this.lastMessageAt = Date.now();
53
+ if (message.type === "system" && message.subtype === "background_tasks_changed") {
54
+ const tasks = Array.isArray(message.tasks) ? (message.tasks as BackgroundTaskEntry[]) : [];
55
+ // Ambient tasks (live-update watchers, skip_transcript tasks) are not
56
+ // activity; Claude Code asks hosts to leave them out of activity signals.
57
+ this.liveTaskIds = tasks
58
+ .filter((task) => task && task.ambient !== true && typeof task.task_id === "string")
59
+ .map((task) => task.task_id as string);
60
+ }
61
+ // Any message restarts a keepalive the idle cap stopped, while work is live.
62
+ if (this.liveTaskIds.length > 0) this.start();
63
+ else this.clearTimer();
64
+ }
65
+
66
+ /** Stop pinging for good; call when the session ends. */
67
+ stop(): void {
68
+ this.stopped = true;
69
+ this.liveTaskIds = [];
70
+ this.clearTimer();
71
+ }
72
+
73
+ private start(): void {
74
+ if (this.timer !== undefined) return;
75
+ if (!this.options.apiUrl || !this.options.taskId) return;
76
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
77
+ }
78
+
79
+ private tick(): void {
80
+ if (Date.now() - this.lastMessageAt > this.maxIdleMs) {
81
+ this.clearTimer();
82
+ return;
83
+ }
84
+ const { apiUrl, apiKey, agentId, taskId } = this.options;
85
+ void fetch(`${apiUrl}/api/active-sessions/heartbeat/${encodeURIComponent(taskId)}`, {
86
+ method: "PUT",
87
+ headers: {
88
+ "Content-Type": "application/json",
89
+ Authorization: `Bearer ${apiKey}`,
90
+ "X-Agent-ID": agentId,
91
+ },
92
+ }).catch(() => {});
93
+ }
94
+
95
+ private clearTimer(): void {
96
+ if (this.timer === undefined) return;
97
+ clearInterval(this.timer);
98
+ this.timer = undefined;
99
+ }
100
+ }
@@ -29,6 +29,7 @@ import {
29
29
  cleanupTaskFile,
30
30
  runClaudeSessionSummary,
31
31
  } from "./claude-adapter";
32
+ import { ClaudeBackgroundKeepalive } from "./claude-background-keepalive";
32
33
  import type { ClaudeProtocolMessage } from "./claude-session-events";
33
34
  import { normalizeClaudeMessage } from "./claude-session-events";
34
35
  import type {
@@ -317,8 +318,11 @@ class ClaudeSdkSession implements ProviderSession {
317
318
  private acceptingInput = true;
318
319
  private errorTracker = new SessionErrorTracker();
319
320
  private shutdownPromise: Promise<void> | undefined;
321
+ /** Keeps the session heartbeat fresh while Claude idles on background tasks. */
322
+ private backgroundKeepalive: ClaudeBackgroundKeepalive;
320
323
 
321
324
  constructor(private options: CreateClaudeSdkSessionOptions) {
325
+ this.backgroundKeepalive = new ClaudeBackgroundKeepalive(options.config);
322
326
  this.model = options.model;
323
327
  this.contextWindowSize = getContextWindowSize(options.model);
324
328
  this.transcript = [`User: ${scrubSecrets(options.config.prompt)}`];
@@ -406,6 +410,7 @@ class ClaudeSdkSession implements ProviderSession {
406
410
 
407
411
  private handleMessage(message: SDKMessage): boolean {
408
412
  const json = message as unknown as ClaudeProtocolMessage;
413
+ this.backgroundKeepalive.observe(json);
409
414
  const normalized = normalizeClaudeMessage(json, {
410
415
  taskId: this.options.config.taskId,
411
416
  agentId: this.options.config.agentId,
@@ -514,6 +519,7 @@ class ClaudeSdkSession implements ProviderSession {
514
519
  this.emit({ type: "error", message, category: "sdk_transport" });
515
520
  }
516
521
  } finally {
522
+ this.backgroundKeepalive.stop();
517
523
  this.acceptingInput = false;
518
524
  this.input.close();
519
525
  if (this.aborted) {
@@ -37,7 +37,7 @@ import type {
37
37
  } from "./types";
38
38
 
39
39
  // npm's `latest` is older and lacks the JSON/stdin contract used here.
40
- export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-alpha.1";
40
+ export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.1-alpha.2";
41
41
 
42
42
  export function checkDshCredentials(env: Record<string, string | undefined>): CredStatus {
43
43
  return env.DEEPSEEK_API_KEY?.trim() || env.OPENROUTER_API_KEY?.trim()