@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
@@ -27,7 +27,7 @@ import {
27
27
  import {
28
28
  ensureAgentFsCredentialsForAgent,
29
29
  inviteEmailToSharedOrg
30
- } from "./cli-c2tpw9ck.js";
30
+ } from "./cli-jb931ba2.js";
31
31
  import {
32
32
  DEFAULT_CONNECTOR_CODE_LABEL,
33
33
  checkHeartbeatChecklist,
@@ -35,7 +35,7 @@ import {
35
35
  exchangeConnectorCode,
36
36
  runHeartbeatSweep,
37
37
  stopHeartbeat
38
- } from "./cli-qzmzwegq.js";
38
+ } from "./cli-pgehz8e1.js";
39
39
  import {
40
40
  Anthropic
41
41
  } from "./cli-5qkcdv8c.js";
@@ -335,7 +335,7 @@ import {
335
335
  workflowModelErrors,
336
336
  workflowSaveWarnings,
337
337
  writeUnhandledError
338
- } from "./cli-wydz3feg.js";
338
+ } from "./cli-ee0402n1.js";
339
339
  import"./cli-xvc3q9br.js";
340
340
  import {
341
341
  AMP_ESTIMATE_MODELS,
@@ -350,16 +350,16 @@ import {
350
350
  import {
351
351
  isMultiRuntimeEnabled
352
352
  } from "./cli-re8z5p8p.js";
353
- import"./cli-tx2zvn23.js";
353
+ import"./cli-43jg9xdw.js";
354
354
  import {
355
355
  deleteModelCatalogOverlay,
356
356
  loadModelsCatalog,
357
357
  requestModelCatalogRefresh,
358
358
  upsertModelCatalogOverlay
359
- } from "./cli-p8rt8spp.js";
359
+ } from "./cli-9q24yyvs.js";
360
360
  import {
361
361
  normalizeModelKey
362
- } from "./cli-v1pwxreq.js";
362
+ } from "./cli-ewhwpyrb.js";
363
363
  import {
364
364
  ConnectionAuthInputSchema,
365
365
  MAX_SCRIPT_WALL_CLOCK_MS,
@@ -383,6 +383,7 @@ import {
383
383
  createApprovalFollowUpTask,
384
384
  definitionNodeIds,
385
385
  deleteMcpOAuthToken,
386
+ deleteScriptConnection,
386
387
  discoverAuthorizationServerMetadata,
387
388
  discoverProtectedResourceMetadata,
388
389
  ensureMcpToken,
@@ -436,7 +437,7 @@ import {
436
437
  upsertMcpOAuthToken,
437
438
  upsertScriptConnection,
438
439
  validateDefinition
439
- } from "./cli-577bxkf6.js";
440
+ } from "./cli-g6gx2dn1.js";
440
441
  import"./cli-epan13p1.js";
441
442
  import {
442
443
  getApiKey
@@ -469,7 +470,7 @@ import {
469
470
  forceRefreshAuthorizationOrThrow,
470
471
  forceRefreshTokenOrThrow,
471
472
  oauthAppRowToProviderConfig
472
- } from "./cli-kw0jcfje.js";
473
+ } from "./cli-fhj4y16f.js";
473
474
  import {
474
475
  AgentFsProvider,
475
476
  FilesError,
@@ -502,7 +503,7 @@ import {
502
503
  withRemoteContext,
503
504
  withSpan,
504
505
  withSpanContext
505
- } from "./cli-c8r99akr.js";
506
+ } from "./cli-j05x7pnb.js";
506
507
  import"./cli-q51yprfq.js";
507
508
  import"./cli-3er6gcsn.js";
508
509
  import"./cli-yatxqynm.js";
@@ -532,7 +533,7 @@ import"./cli-pzxba7gw.js";
532
533
  import {
533
534
  getUserGrant,
534
535
  init_rbac_roles
535
- } from "./cli-ypgba1a8.js";
536
+ } from "./cli-0jngabtb.js";
536
537
  import {
537
538
  LEGACY_RULES,
538
539
  can,
@@ -925,7 +926,7 @@ import {
925
926
  upsertSwarmConfig,
926
927
  validateConfigValue,
927
928
  withFavoriteFlags
928
- } from "./cli-mkdsy1nq.js";
929
+ } from "./cli-74ez9b66.js";
929
930
  import"./cli-3qhyh31j.js";
930
931
  import {
931
932
  ActiveSessionSchema,
@@ -1129,7 +1130,7 @@ import {
1129
1130
  import {
1130
1131
  init_package,
1131
1132
  package_default
1132
- } from "./cli-ymmc1zxg.js";
1133
+ } from "./cli-ng1pmdx6.js";
1133
1134
  import {
1134
1135
  init_event_bus,
1135
1136
  workflowEventBus
@@ -10008,6 +10009,605 @@ export default extension;
10008
10009
  },
10009
10010
  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'
10010
10011
  },
10012
+ "slack-reply-gate": {
10013
+ manifestFile: "manifest.json",
10014
+ manifest: {
10015
+ $schema: "../manifest.schema.json",
10016
+ name: "slack-reply-gate",
10017
+ 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",
10018
+ version: "1.2.0",
10019
+ runtime: "api",
10020
+ assets: {
10021
+ hooks: "hooks.ts"
10022
+ }
10023
+ },
10024
+ files: {
10025
+ "hooks.ts": `import { type ApiCtx, block, type SwarmExtension } from "swarm-extension";
10026
+ import { z } from "zod";
10027
+
10028
+ /**
10029
+ * slack-reply-gate
10030
+ *
10031
+ * Every human message in a Slack thread the swarm takes part in is buffered and
10032
+ * flushed into a Lead task ("[Thread follow-up — N message(s) buffered]"), even
10033
+ * "lol", ":joy:" or two people bantering. This hook asks TypeSafe Jev whether
10034
+ * Lead should answer, and drops the wake-up when the answer is no.
10035
+ *
10036
+ * Hook: \`pre.task.create\`, origin "slack", description carrying the
10037
+ * thread-buffer header. \`pre.slack.route\` cannot be used: the buffered thread
10038
+ * path returns before that hook is dispatched, and its result can only
10039
+ * re-target a message, never drop it.
10040
+ *
10041
+ * Rules:
10042
+ * - A message that @mentions Lead passes without a Jev call.
10043
+ * - Otherwise one Jev \`choice\` question classifies the message into a reason;
10044
+ * reply=false only for reaction_only | banter_between_others |
10045
+ * not_addressed_to_lead, and only when the summed probability of those
10046
+ * three labels is >= minConfidence (a banter/reaction split is still a skip).
10047
+ * - Fails OPEN on every error, timeout or missing key.
10048
+ *
10049
+ * Transport (config.provider): Jev is served by TypeSafe directly and by
10050
+ * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}
10051
+ * body and return the same \`answers\` shape. \`auto\` uses TYPESAFE_API_KEY when
10052
+ * set, else OPENROUTER_API_KEY.
10053
+ *
10054
+ * modes (config.mode):
10055
+ * off -> never evaluate
10056
+ * shadow -> evaluate + record, never block (default)
10057
+ * enforce -> record, block the task when reply=false, and mark the
10058
+ * triggering Slack message with :mute: (config.muteReaction)
10059
+ *
10060
+ * Muting: no swarm tool or ctx method adds a Slack reaction, so the hook calls
10061
+ * the Slack Web API (reactions.remove / reactions.add) with the bot token. It
10062
+ * first removes the bot's own acceptance reactions (:eyes:, :heavy_plus_sign:,
10063
+ * :zap:, :speech_balloon:, or their SLACK_REACTION_* overrides), the same set
10064
+ * the engine clears when it finalizes a task, then adds :mute:. With
10065
+ * muteReaction "" it only removes them: a true no-op on the message. It runs after
10066
+ * the block decision returns, so a slow or failing Slack call never delays or
10067
+ * changes the drop; its errors go to the same KV namespace.
10068
+ *
10069
+ * Every decision lands in KV namespace \`ext:slack-reply-gate\`.
10070
+ */
10071
+
10072
+ export const config = z.object({
10073
+ mode: z.enum(["off", "shadow", "enforce"]).default("shadow"),
10074
+ timeoutMs: z.number().int().positive().default(3000),
10075
+ provider: z.enum(["auto", "typesafe", "openrouter"]).default("auto"),
10076
+ /** Jev model on the TypeSafe transport. */
10077
+ model: z.string().default("jev-latest"),
10078
+ /** Jev model on the OpenRouter transport. */
10079
+ openrouterModel: z.string().default("typesafe/jev-1.13"),
10080
+ minConfidence: z.number().min(0).max(1).default(0.55),
10081
+ contextMessages: z.number().int().min(0).max(20).default(6),
10082
+ leadAliases: z.array(z.string()).default(["lead"]),
10083
+ botSlackUserIds: z.array(z.string()).default([]),
10084
+ /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */
10085
+ apiKey: z.string().optional(),
10086
+ /**
10087
+ * Reaction for a dropped message in enforce mode. "" only removes the bot's
10088
+ * acceptance reaction(s) and adds nothing.
10089
+ */
10090
+ muteReaction: z.string().default("mute"),
10091
+ slackTimeoutMs: z.number().int().positive().default(5000),
10092
+ });
10093
+
10094
+ type Cfg = z.infer<typeof config>;
10095
+ type Swarm = ApiCtx["swarm"];
10096
+
10097
+ const NS = "ext:slack-reply-gate";
10098
+ const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
10099
+ const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/alpha/decisions";
10100
+ const HEADER_LINE_RE = /(?:^|\\n)\\[Thread follow-up — \\d+ message\\(s\\) buffered\\]\\n/g;
10101
+ const CTX_OPEN = "<thread_context>";
10102
+ const CTX_CLOSE = "</thread_context>";
10103
+ // The dispatcher cancels a handler after 5 s; leave room for the KV writes.
10104
+ const MAX_TIMEOUT_MS = 4000;
10105
+
10106
+ export const REASONS = {
10107
+ direct_ask:
10108
+ "Asks Lead (or the swarm, the bot, 'you' meaning the AI) a question or for something, explicitly or clearly implied.",
10109
+ followup_to_lead:
10110
+ "Answers, confirms, corrects or continues something Lead or another swarm agent ([Agent]) just said or asked, so Lead needs it to proceed.",
10111
+ instruction_to_lead:
10112
+ "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').",
10113
+ reaction_only:
10114
+ "Only a reaction: emoji, laughter (lol, haha, jaja), 'boom!', 'nice', 'thanks', '+1', with no request and nothing new for Lead.",
10115
+ banter_between_others:
10116
+ "Chat between the humans, or between other people and other bots, that is not aimed at Lead.",
10117
+ not_addressed_to_lead:
10118
+ "Addressed to a specific other person or another assistant, or an FYI that needs no answer from Lead.",
10119
+ other: "None of the above, or unclear.",
10120
+ } as const;
10121
+
10122
+ export type Reason = keyof typeof REASONS;
10123
+ const SKIP_REASONS: ReadonlySet<string> = new Set([
10124
+ "reaction_only",
10125
+ "banter_between_others",
10126
+ "not_addressed_to_lead",
10127
+ ]);
10128
+
10129
+ const cfgOf = (raw: unknown): Cfg => {
10130
+ const parsed = config.safeParse(raw ?? {});
10131
+ return parsed.success ? parsed.data : config.parse({});
10132
+ };
10133
+
10134
+ // kv keys accept [a-zA-Z0-9._:/%-]
10135
+ const safeKey = (raw: string | undefined | null): string =>
10136
+ (raw && raw.length > 0 ? raw : "unknown").replace(/[^a-zA-Z0-9._:-]/g, "_").slice(0, 120);
10137
+
10138
+ const escapeRe = (s: string): string => s.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&");
10139
+
10140
+ /**
10141
+ * Locate the engine's follow-up header (src/slack/thread-buffer.ts): at the start of the
10142
+ * description, or right after the closing \`</thread_context>\`. Thread context quotes old
10143
+ * messages verbatim, so a header (or a \`</thread_context>\`) quoted inside it is skipped.
10144
+ */
10145
+ const findHeader = (description: string): { index: number; end: number } | null => {
10146
+ for (const m of description.matchAll(HEADER_LINE_RE)) {
10147
+ const index = m.index! + (m[0].startsWith("\\n") ? 1 : 0);
10148
+ const before = description.slice(0, index);
10149
+ const open = before.indexOf(CTX_OPEN);
10150
+ if (open < 0 || before.trimEnd().endsWith(CTX_CLOSE)) {
10151
+ return { index, end: m.index! + m[0].length };
10152
+ }
10153
+ }
10154
+ return null;
10155
+ };
10156
+
10157
+ /**
10158
+ * Split a buffered follow-up description into thread context and new messages, or null
10159
+ * when the description is not a buffered follow-up. Only \`messages\` is new text.
10160
+ */
10161
+ export const parseFollowUp = (
10162
+ description: string,
10163
+ ): { context: string[]; messages: string[] } | null => {
10164
+ const header = findHeader(description);
10165
+ if (!header) return null;
10166
+ const before = description.slice(0, header.index).trimEnd();
10167
+ const open = before.indexOf(CTX_OPEN);
10168
+ const context =
10169
+ open >= 0
10170
+ ? before
10171
+ .slice(open + CTX_OPEN.length, before.length - CTX_CLOSE.length)
10172
+ .split(/\\n(?=<@[A-Z0-9]+[|>]|\\[Agent\\]:)/)
10173
+ .map((m) => m.trim())
10174
+ .filter(Boolean)
10175
+ : [];
10176
+ const messages = description
10177
+ .slice(header.end)
10178
+ .split("\\n---\\n")
10179
+ .map((m) => m.trim())
10180
+ .filter(Boolean);
10181
+ return { context, messages };
10182
+ };
10183
+
10184
+ /** True when the new text addresses Lead with an @mention. */
10185
+ export const mentionsLead = (text: string, aliases: readonly string[]): boolean => {
10186
+ // The buffer rewrites a mention of the swarm's own bot to \`<@U…> (that's you)\`.
10187
+ if (/<@[A-Z0-9]+>\\s*\\(that's you\\)/.test(text)) return true;
10188
+ const names = aliases
10189
+ .map((a) => a.trim())
10190
+ .filter(Boolean)
10191
+ .map(escapeRe);
10192
+ if (names.length === 0) return false;
10193
+ const alt = names.join("|");
10194
+ return (
10195
+ new RegExp(\`(^|[^\\\\w@])@(\${alt})\\\\b\`, "i").test(text) ||
10196
+ new RegExp(\`<@[A-Z0-9]+\\\\|(\${alt})>\`, "i").test(text)
10197
+ );
10198
+ };
10199
+
10200
+ const secretCache = new Map<string, { value: string; at: number }>();
10201
+ const KEY_TTL_MS = 5 * 60 * 1000;
10202
+ const usable = (v: string): boolean => Boolean(v) && !v.startsWith("[REDACTED");
10203
+ const envOf = (name: string): string => String(process.env[name] ?? "");
10204
+
10205
+ /**
10206
+ * Resolve a secret: explicit value, then the API process env. The API process loads
10207
+ * global swarm_config rows into process.env at boot and on config reload.
10208
+ * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to "[REDACTED:<name>]".
10209
+ */
10210
+ const secret = (name: string, explicit?: string): string => {
10211
+ const cached = secretCache.get(name);
10212
+ if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;
10213
+ const value = [String(explicit ?? ""), envOf(name)].find(usable) ?? "";
10214
+ if (usable(value)) secretCache.set(name, { value, at: Date.now() });
10215
+ return value;
10216
+ };
10217
+
10218
+ /** Test hook: forget cached secrets. */
10219
+ export const resetKeyCache = (): void => {
10220
+ secretCache.clear();
10221
+ };
10222
+
10223
+ export type JevTransport = {
10224
+ provider: "typesafe" | "openrouter";
10225
+ endpoint: string;
10226
+ key: string;
10227
+ model: string;
10228
+ };
10229
+
10230
+ /**
10231
+ * Pick the Jev transport. \`auto\` prefers TypeSafe and falls back to OpenRouter.
10232
+ * Throws when the chosen provider has no usable key; the handler fails open.
10233
+ */
10234
+ export const resolveTransport = (cfg: Cfg): JevTransport => {
10235
+ const typesafe = (): JevTransport | null => {
10236
+ const key = secret("TYPESAFE_API_KEY", cfg.apiKey);
10237
+ return usable(key)
10238
+ ? { provider: "typesafe", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }
10239
+ : null;
10240
+ };
10241
+ const openrouter = (): JevTransport | null => {
10242
+ const key = secret("OPENROUTER_API_KEY");
10243
+ return usable(key)
10244
+ ? { provider: "openrouter", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }
10245
+ : null;
10246
+ };
10247
+ const transport =
10248
+ cfg.provider === "typesafe"
10249
+ ? typesafe()
10250
+ : cfg.provider === "openrouter"
10251
+ ? openrouter()
10252
+ : (typesafe() ?? openrouter());
10253
+ if (transport) return transport;
10254
+ throw new Error(
10255
+ cfg.provider === "typesafe"
10256
+ ? "no TYPESAFE_API_KEY"
10257
+ : cfg.provider === "openrouter"
10258
+ ? "no OPENROUTER_API_KEY"
10259
+ : "no TYPESAFE_API_KEY or OPENROUTER_API_KEY",
10260
+ );
10261
+ };
10262
+
10263
+ const SLACK_API = "https://slack.com/api";
10264
+ // The engine's acceptance reactions (src/slack/reaction-shortcode.ts), by config key.
10265
+ const ACCEPTANCE_REACTIONS: ReadonlyArray<[string, string]> = [
10266
+ ["SLACK_REACTION_ACCEPTED", "eyes"],
10267
+ ["SLACK_REACTION_BUFFERED", "heavy_plus_sign"],
10268
+ ["SLACK_REACTION_NOW", "zap"],
10269
+ ["SLACK_REACTION_STEERED", "speech_balloon"],
10270
+ ];
10271
+ const SHORTCODE_RE = /^[a-z0-9_+'-]+(::skin-tone-[2-6])?$/;
10272
+ const shortcode = (raw: string): string | null => {
10273
+ const s = raw.trim().replace(/^:/, "").replace(/:$/, "").toLowerCase();
10274
+ return s && SHORTCODE_RE.test(s) ? s : null;
10275
+ };
10276
+
10277
+ /** The acceptance reactions the engine may have put on the message, deduplicated. */
10278
+ export const acceptanceReactions = (): string[] => [
10279
+ ...new Set(ACCEPTANCE_REACTIONS.map(([key, fallback]) => shortcode(envOf(key)) ?? fallback)),
10280
+ ];
10281
+
10282
+ const slackCall = async (
10283
+ method: string,
10284
+ token: string,
10285
+ body: Record<string, string>,
10286
+ timeoutMs: number,
10287
+ ): Promise<{ ok: boolean; error?: string }> => {
10288
+ const controller = new AbortController();
10289
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
10290
+ try {
10291
+ const res = await fetch(\`\${SLACK_API}/\${method}\`, {
10292
+ method: "POST",
10293
+ headers: {
10294
+ "Content-Type": "application/json; charset=utf-8",
10295
+ Authorization: \`Bearer \${token}\`,
10296
+ },
10297
+ body: JSON.stringify(body),
10298
+ signal: controller.signal,
10299
+ });
10300
+ if (!res.ok) return { ok: false, error: \`http_\${res.status}\` };
10301
+ const data = (await res.json()) as { ok?: boolean; error?: string };
10302
+ return { ok: data?.ok === true, error: data?.error };
10303
+ } finally {
10304
+ clearTimeout(timer);
10305
+ }
10306
+ };
10307
+
10308
+ export type MuteResult = { removed: string[]; added: boolean; errors: string[] };
10309
+
10310
+ /**
10311
+ * Replace the bot's acceptance reaction(s) on one message with \`name\`, or only
10312
+ * remove them when \`name\` is "". \`reactions.remove\` only removes the calling
10313
+ * bot's own reaction, so a human's :eyes: stays. Never throws: every failure
10314
+ * lands in \`errors\`.
10315
+ */
10316
+ export const muteMessage = async (
10317
+ token: string,
10318
+ channel: string,
10319
+ timestamp: string,
10320
+ name: string,
10321
+ timeoutMs: number,
10322
+ ): Promise<MuteResult> => {
10323
+ const result: MuteResult = { removed: [], added: false, errors: [] };
10324
+ const call = (method: string, reaction: string) =>
10325
+ slackCall(method, token, { channel, timestamp, name: reaction }, timeoutMs).catch(
10326
+ (err): { ok: boolean; error?: string } => ({ ok: false, error: String(err).slice(0, 120) }),
10327
+ );
10328
+ const removals = acceptanceReactions().filter((r) => r !== name);
10329
+ const removed = await Promise.all(removals.map((r) => call("reactions.remove", r)));
10330
+ removed.forEach((res, i) => {
10331
+ const reaction = removals[i]!;
10332
+ if (res.ok) result.removed.push(reaction);
10333
+ else if (res.error !== "no_reaction" && res.error !== "invalid_name")
10334
+ result.errors.push(\`remove \${reaction}: \${res.error ?? "unknown"}\`);
10335
+ });
10336
+ if (!name) return result;
10337
+ const added = await call("reactions.add", name);
10338
+ if (added.ok || added.error === "already_reacted") result.added = true;
10339
+ else result.errors.push(\`add \${name}: \${added.error ?? "unknown"}\`);
10340
+ return result;
10341
+ };
10342
+
10343
+ // In-flight mutes, so tests can await the work the handler does not wait for.
10344
+ const pendingMutes = new Set<Promise<void>>();
10345
+ /** Test hook: wait for every in-flight mute. */
10346
+ export const settleMutes = async (): Promise<void> => {
10347
+ await Promise.allSettled([...pendingMutes]);
10348
+ };
10349
+
10350
+ export type JevVerdict = {
10351
+ reason: string;
10352
+ confidence: number | null;
10353
+ probabilities: Record<string, number> | null;
10354
+ latencyMs: number;
10355
+ };
10356
+
10357
+ /**
10358
+ * One Jev call: classify the newest message into a reason. Throws on any failure.
10359
+ * TypeSafe and OpenRouter share the request body and the \`answers\` response shape.
10360
+ */
10361
+ export const askJev = async (
10362
+ input: {
10363
+ messages: string[];
10364
+ context: string[];
10365
+ sender: string;
10366
+ leadNames: readonly string[];
10367
+ },
10368
+ transport: JevTransport,
10369
+ cfg: Cfg,
10370
+ outer?: AbortSignal,
10371
+ ): Promise<JevVerdict> => {
10372
+ const controller = new AbortController();
10373
+ const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));
10374
+ const onOuterAbort = () => controller.abort();
10375
+ outer?.addEventListener("abort", onOuterAbort);
10376
+ const t0 = Date.now();
10377
+ try {
10378
+ const res = await fetch(transport.endpoint, {
10379
+ method: "POST",
10380
+ headers: {
10381
+ "Content-Type": "application/json",
10382
+ Authorization: \`Bearer \${transport.key}\`,
10383
+ },
10384
+ body: JSON.stringify({
10385
+ state: {
10386
+ 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.",
10387
+ lead_names: input.leadNames,
10388
+ recent_thread_messages: input.context,
10389
+ new_message: input.messages.join("\\n---\\n"),
10390
+ new_message_sender: input.sender,
10391
+ },
10392
+ model: transport.model,
10393
+ questions: {
10394
+ reason: {
10395
+ type: "choice",
10396
+ instructions:
10397
+ "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.",
10398
+ criteria: REASONS,
10399
+ },
10400
+ },
10401
+ }),
10402
+ signal: controller.signal,
10403
+ });
10404
+ if (!res.ok) throw new Error(\`\${transport.provider} \${res.status}\`);
10405
+ const body = (await res.json()) as {
10406
+ answers?: { reason?: { choice?: unknown; confidence?: unknown; probabilities?: unknown } };
10407
+ } | null;
10408
+ const answer = body?.answers?.reason;
10409
+ const reason = typeof answer?.choice === "string" ? answer.choice : "";
10410
+ if (!(reason in REASONS)) throw new Error(\`unexpected choice: \${String(reason).slice(0, 60)}\`);
10411
+ const confidence = typeof answer?.confidence === "number" ? answer.confidence : null;
10412
+ const probabilities =
10413
+ answer?.probabilities && typeof answer.probabilities === "object"
10414
+ ? (answer.probabilities as Record<string, number>)
10415
+ : null;
10416
+ return { reason, confidence, probabilities, latencyMs: Date.now() - t0 };
10417
+ } finally {
10418
+ clearTimeout(timer);
10419
+ outer?.removeEventListener("abort", onOuterAbort);
10420
+ }
10421
+ };
10422
+
10423
+ /** Probability that the message needs no reply: the summed mass of the skip labels. */
10424
+ export const skipProbability = (verdict: JevVerdict): number | null => {
10425
+ if (verdict.probabilities) {
10426
+ let sum = 0;
10427
+ for (const [label, p] of Object.entries(verdict.probabilities)) {
10428
+ if (SKIP_REASONS.has(label) && typeof p === "number") sum += p;
10429
+ }
10430
+ return sum;
10431
+ }
10432
+ return verdict.confidence;
10433
+ };
10434
+
10435
+ /** Map a Jev verdict to the typed decision. An unsure skip verdict replies. */
10436
+ export const decide = (
10437
+ verdict: JevVerdict,
10438
+ minConfidence: number,
10439
+ ): { reply: boolean; reason: Reason; skipP: number | null; lowConfidence: boolean } => {
10440
+ const reason = verdict.reason as Reason;
10441
+ const skipP = skipProbability(verdict);
10442
+ if (!SKIP_REASONS.has(reason)) return { reply: true, reason, skipP, lowConfidence: false };
10443
+ const lowConfidence = skipP !== null && skipP < minConfidence;
10444
+ return { reply: lowConfidence, reason, skipP, lowConfidence };
10445
+ };
10446
+
10447
+ const record = async (
10448
+ swarm: Swarm,
10449
+ entry: Record<string, unknown>,
10450
+ counters: string[],
10451
+ prefix: "d" | "m" = "d",
10452
+ ) => {
10453
+ const at = new Date().toISOString();
10454
+ const thread = safeKey(\`\${entry.channelId ?? ""}_\${entry.threadTs ?? ""}\`);
10455
+ const value = { at, ...entry };
10456
+ await Promise.allSettled([
10457
+ swarm.kv_set({ namespace: NS, key: \`\${prefix}:\${Date.now()}:\${thread}\`, value }),
10458
+ // \`last\` stays the latest decision; mute outcomes only get their own row.
10459
+ ...(prefix === "d" ? [swarm.kv_set({ namespace: NS, key: "last", value })] : []),
10460
+ ...counters.map((c) => swarm.kv_incr({ namespace: NS, key: safeKey(c) })),
10461
+ ]);
10462
+ };
10463
+
10464
+ /** Mute the triggering message and log the outcome. Never throws. */
10465
+ const muteTrigger = async (
10466
+ swarm: Swarm,
10467
+ cfg: Cfg,
10468
+ base: Record<string, unknown>,
10469
+ ): Promise<void> => {
10470
+ try {
10471
+ const channel = String(base.channelId ?? "");
10472
+ const ts = String(base.triggerTs ?? "");
10473
+ if (!channel || !ts) throw new Error("no slackChannelId or slackTriggerMessageTs");
10474
+ const token = secret("SLACK_BOT_TOKEN");
10475
+ if (!usable(token)) throw new Error("no SLACK_BOT_TOKEN");
10476
+ const result = await muteMessage(token, channel, ts, cfg.muteReaction, cfg.slackTimeoutMs);
10477
+ // "" adds nothing: success is clearing the acceptance reaction(s).
10478
+ const done = cfg.muteReaction ? "muted" : "unreacted";
10479
+ const ok = (result.added || !cfg.muteReaction) && result.errors.length === 0;
10480
+ const action = ok ? done : "mute-error";
10481
+ await record(swarm, { ...base, action, ...result }, [action], "m");
10482
+ } catch (err) {
10483
+ await record(
10484
+ swarm,
10485
+ { ...base, action: "mute-error", errors: [String(err).slice(0, 300)] },
10486
+ ["mute-error"],
10487
+ "m",
10488
+ ).catch(() => {});
10489
+ }
10490
+ };
10491
+
10492
+ const extension: SwarmExtension = (api) => {
10493
+ api.on("pre.task.create", async (event, ctx) => {
10494
+ if (event.origin !== "slack") return;
10495
+ // Only buffered follow-ups. A direct @mention task never carries the header outside
10496
+ // its thread context.
10497
+ const followUp = parseFollowUp(event.description);
10498
+ if (!followUp) return;
10499
+
10500
+ const cfg = cfgOf(ctx.config);
10501
+ if (cfg.mode === "off") return;
10502
+
10503
+ const opts = event.options;
10504
+ const base = {
10505
+ mode: cfg.mode,
10506
+ channelId: opts.slackChannelId,
10507
+ threadTs: opts.slackThreadTs,
10508
+ triggerTs: opts.slackTriggerMessageTs,
10509
+ senderSlackId: opts.slackUserId,
10510
+ };
10511
+
10512
+ try {
10513
+ // A draft follow-up carries downloaded files: always let Lead see them.
10514
+ if (opts.status === "draft") {
10515
+ await record(ctx.swarm, { ...base, action: "pass", reason: "has_files" }, [
10516
+ "total",
10517
+ "files",
10518
+ ]);
10519
+ return;
10520
+ }
10521
+
10522
+ // The mention check reads only the new messages, never the thread context.
10523
+ const { context, messages } = followUp;
10524
+ const preview = messages.join(" | ").slice(0, 300);
10525
+
10526
+ if (messages.some((m) => mentionsLead(m, cfg.leadAliases))) {
10527
+ await record(ctx.swarm, { ...base, action: "pass", reason: "mention", preview }, [
10528
+ "total",
10529
+ "mention",
10530
+ ]);
10531
+ return;
10532
+ }
10533
+
10534
+ const transport = resolveTransport(cfg);
10535
+
10536
+ const sender =
10537
+ opts.slackUserId && cfg.botSlackUserIds.includes(opts.slackUserId)
10538
+ ? "bot"
10539
+ : opts.requestedByUserId
10540
+ ? "human (known teammate)"
10541
+ : "unknown (not a known teammate; may be a bot or an external person)";
10542
+
10543
+ const verdict = await askJev(
10544
+ {
10545
+ messages,
10546
+ context: context.slice(-cfg.contextMessages).map((m) => m.slice(0, 500)),
10547
+ sender,
10548
+ leadNames: cfg.leadAliases,
10549
+ },
10550
+ transport,
10551
+ cfg,
10552
+ ctx.signal,
10553
+ );
10554
+ const decision = decide(verdict, cfg.minConfidence);
10555
+
10556
+ const skip = !decision.reply;
10557
+ const action = skip ? (cfg.mode === "enforce" ? "skipped" : "shadow-skip") : "pass";
10558
+ await record(
10559
+ ctx.swarm,
10560
+ {
10561
+ ...base,
10562
+ action,
10563
+ reply: decision.reply,
10564
+ reason: decision.reason,
10565
+ confidence: verdict.confidence,
10566
+ skipP: decision.skipP,
10567
+ lowConfidence: decision.lowConfidence,
10568
+ latencyMs: verdict.latencyMs,
10569
+ provider: transport.provider,
10570
+ sender,
10571
+ preview,
10572
+ },
10573
+ ["total", skip ? action : "reply", \`reason:\${decision.reason}\`],
10574
+ );
10575
+
10576
+ if (skip && cfg.mode === "enforce") {
10577
+ // Not awaited: the block must return inside the dispatcher's 5 s budget
10578
+ // whatever Slack does, and a failed reaction never changes the drop.
10579
+ // An empty (or invalid) muteReaction still clears the acceptance reaction(s).
10580
+ const muteReaction = shortcode(cfg.muteReaction) ?? "";
10581
+ const pending = muteTrigger(ctx.swarm, { ...cfg, muteReaction }, base);
10582
+ pendingMutes.add(pending);
10583
+ void pending.finally(() => pendingMutes.delete(pending));
10584
+ return block(
10585
+ \`slack-reply-gate: no reply needed (\${decision.reason}\${decision.skipP !== null ? \`, p(skip) \${decision.skipP.toFixed(2)}\` : ""})\`,
10586
+ );
10587
+ }
10588
+ return;
10589
+ } catch (err) {
10590
+ // Fail open, always.
10591
+ try {
10592
+ await record(
10593
+ ctx.swarm,
10594
+ { ...base, action: "fail-open", error: String(err).slice(0, 300) },
10595
+ ["total", "error"],
10596
+ );
10597
+ } catch {
10598
+ /* ignore */
10599
+ }
10600
+ ctx.log.warn("slack-reply-gate: failing open", { error: String(err) });
10601
+ return;
10602
+ }
10603
+ });
10604
+ };
10605
+
10606
+ export default extension;
10607
+ `
10608
+ },
10609
+ 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'
10610
+ },
10011
10611
  "task-digest": {
10012
10612
  manifestFile: "manifest.yaml",
10013
10613
  manifest: {
@@ -22995,6 +23595,25 @@ var setConnectionEnabledRoute = route({
22995
23595
  },
22996
23596
  rbac: { permission: "script-connection.manage" }
22997
23597
  });
23598
+ var deleteConnectionRoute = route({
23599
+ method: "delete",
23600
+ path: "/api/script-connections/{id}",
23601
+ pattern: ["api", "script-connections", null],
23602
+ operationId: "script_connections_delete",
23603
+ summary: "Delete a script connection",
23604
+ description: "Hard-deletes the connection, its auto-managed credential binding, and any derived inline secret it owns.",
23605
+ tags: ["Script Connections"],
23606
+ params: idParamsSchema2,
23607
+ responses: {
23608
+ 200: {
23609
+ description: "Script connection deleted",
23610
+ schema: exports_external.object({ deleted: exports_external.literal(true), id: exports_external.string() })
23611
+ },
23612
+ 403: { description: "Only the lead agent can manage script connections" },
23613
+ 404: { description: "Script connection not found" }
23614
+ },
23615
+ rbac: { permission: "script-connection.manage" }
23616
+ });
22998
23617
  var listCredentialBindingsRoute = route({
22999
23618
  method: "get",
23000
23619
  path: "/api/credential-bindings",
@@ -24083,6 +24702,20 @@ async function handleScriptConnections(req, res, pathSegments, queryParams, agen
24083
24702
  });
24084
24703
  return true;
24085
24704
  }
24705
+ if (deleteConnectionRoute.match(req.method, pathSegments)) {
24706
+ const parsed = await deleteConnectionRoute.parse(req, res, pathSegments, queryParams);
24707
+ if (!parsed)
24708
+ return true;
24709
+ if (!await ensureConnectionAdmin(req, res, agentId))
24710
+ return true;
24711
+ const deleted = await deleteScriptConnection(parsed.params.id);
24712
+ if (!deleted) {
24713
+ jsonError(res, "Script connection not found.", 404);
24714
+ return true;
24715
+ }
24716
+ deleteConnectionRoute.respond(res, 200, { deleted: true, id: deleted.id });
24717
+ return true;
24718
+ }
24086
24719
  if (listCredentialBindingsRoute.match(req.method, pathSegments)) {
24087
24720
  const includeManaged = queryParams.get("includeManaged") === "true";
24088
24721
  listCredentialBindingsRoute.respond(res, 200, {
@@ -33070,7 +33703,7 @@ async function shutdown() {
33070
33703
  sessionsProcessed: getServerSessionsProcessed()
33071
33704
  });
33072
33705
  if (hasCapability("scheduling")) {
33073
- const { stopScheduler } = await import("./index-ekywyv2v.js");
33706
+ const { stopScheduler } = await import("./index-9fc2h7ww.js");
33074
33707
  stopScheduler();
33075
33708
  }
33076
33709
  stopHeartbeat();
@@ -33079,7 +33712,7 @@ async function shutdown() {
33079
33712
  await stopScriptRunSupervisor();
33080
33713
  await stopSlackApp();
33081
33714
  if (process.env.OAUTH_KEEPALIVE_DISABLE !== "true") {
33082
- const { stopOAuthKeepalive } = await import("./keepalive-4aezx4bj.js");
33715
+ const { stopOAuthKeepalive } = await import("./keepalive-qhcqz56v.js");
33083
33716
  await stopOAuthKeepalive();
33084
33717
  }
33085
33718
  stopOAuthPendingGc();
@@ -33154,15 +33787,15 @@ try {
33154
33787
  console.warn("[startup] CAPABILITIES upgrade seed failed (non-fatal):", err);
33155
33788
  }
33156
33789
  try {
33157
- const { ensurePricingSeeded } = await import("./boot-seeds-22tx8a53.js");
33790
+ const { ensurePricingSeeded } = await import("./boot-seeds-9qt0j8j2.js");
33158
33791
  ensurePricingSeeded();
33159
- const { startPricingRefreshLoop } = await import("./pricing-refresh-mjrqvnvw.js");
33792
+ const { startPricingRefreshLoop } = await import("./pricing-refresh-35f91nx2.js");
33160
33793
  startPricingRefreshLoop();
33161
33794
  } catch (err) {
33162
33795
  console.error("[startup] Failed to seed pricing rows:", err);
33163
33796
  }
33164
33797
  try {
33165
- const { ensureRbacSeeded } = await import("./boot-seeds-22tx8a53.js");
33798
+ const { ensureRbacSeeded } = await import("./boot-seeds-9qt0j8j2.js");
33166
33799
  ensureRbacSeeded();
33167
33800
  } catch (err) {
33168
33801
  console.error("[startup] Failed to sync RBAC seed rows:", err);
@@ -33170,7 +33803,7 @@ try {
33170
33803
  throw err;
33171
33804
  }
33172
33805
  try {
33173
- const { runAllSeeders } = await import("./index-cx1sb9bq.js");
33806
+ const { runAllSeeders } = await import("./index-h26wjcfy.js");
33174
33807
  await runAllSeeders({ scriptEmbeddingMode: "skip" });
33175
33808
  } catch (err) {
33176
33809
  console.error("[startup] Failed to seed built-in entities:", err);
@@ -33230,37 +33863,37 @@ httpServer.listen(port, async () => {
33230
33863
  await initWorkflows();
33231
33864
  await startScriptRunSupervisor(getMcpBaseUrl());
33232
33865
  if (hasCapability("scheduling")) {
33233
- const { startScheduler } = await import("./index-ekywyv2v.js");
33234
- const { getExecutorRegistry: getExecutorRegistry2 } = await import("./index-t4g06w93.js");
33866
+ const { startScheduler } = await import("./index-9fc2h7ww.js");
33867
+ const { getExecutorRegistry: getExecutorRegistry2 } = await import("./index-48pb9erw.js");
33235
33868
  const intervalMs = Number(process.env.SCHEDULER_INTERVAL_MS) || 1e4;
33236
33869
  startScheduler(getExecutorRegistry2(), intervalMs, {
33237
33870
  runId: globalState.__runId
33238
33871
  });
33239
33872
  }
33240
33873
  if (!isEnvFlagEnabled("HEARTBEAT_DISABLE", false)) {
33241
- const { startHeartbeat } = await import("./index-bt8wkv1w.js");
33874
+ const { startHeartbeat } = await import("./index-w2yhye23.js");
33242
33875
  const heartbeatMs = Number(process.env.HEARTBEAT_INTERVAL_MS) || 90000;
33243
33876
  startHeartbeat(heartbeatMs);
33244
33877
  }
33245
33878
  if (process.env.OAUTH_KEEPALIVE_DISABLE !== "true") {
33246
- const { startOAuthKeepalive } = await import("./keepalive-4aezx4bj.js");
33879
+ const { startOAuthKeepalive } = await import("./keepalive-qhcqz56v.js");
33247
33880
  startOAuthKeepalive();
33248
33881
  }
33249
33882
  if (process.env.OAUTH_REFRESH_SWEEP_DISABLE !== "true") {
33250
- const { startOAuthRefreshSweep } = await import("./oauth-refresh-sweep-wm4ctjfj.js");
33883
+ const { startOAuthRefreshSweep } = await import("./oauth-refresh-sweep-ngskthwh.js");
33251
33884
  startOAuthRefreshSweep();
33252
33885
  }
33253
33886
  startOAuthPendingGc();
33254
33887
  await startMemoryGc();
33255
33888
  await startDbRetention();
33256
33889
  startHumanFreeDrain();
33257
- import("./boot-reembed-fmp5ww69.js").then(({ runBootReembed }) => runBootReembed()).catch((err) => {
33890
+ import("./boot-reembed-jvfadhsk.js").then(({ runBootReembed }) => runBootReembed()).catch((err) => {
33258
33891
  console.error("[boot-reembed] startup backfill failed (non-fatal):", err);
33259
33892
  });
33260
- import("./boot-reembed-1ze5853n.js").then(({ runBootReembedScripts }) => runBootReembedScripts()).catch((err) => {
33893
+ import("./boot-reembed-20q3nzvg.js").then(({ runBootReembedScripts }) => runBootReembedScripts()).catch((err) => {
33261
33894
  console.error("[boot-reembed-scripts] startup backfill failed (non-fatal):", err);
33262
33895
  });
33263
- import("./boot-scrub-sweep-cbp8xd4e.js").then(({ runBootScrubSweep }) => runBootScrubSweep()).catch((err) => {
33896
+ import("./boot-scrub-sweep-jtfajzve.js").then(({ runBootScrubSweep }) => runBootScrubSweep()).catch((err) => {
33264
33897
  console.error("[boot-scrub-sweep] startup scrub failed (non-fatal):", err);
33265
33898
  });
33266
33899
  }).on("error", (err) => {