@desplega.ai/agent-swarm 1.167.0 → 1.169.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.
- package/dist/{acp-adapter-zpvy6321.js → acp-adapter-m47zfx4a.js} +2 -2
- package/dist/{actions-fq2zrwtf.js → actions-ws7k3tvq.js} +25 -19
- package/dist/{amp-adapter-5gbk7501.js → amp-adapter-nvv8k8j5.js} +1 -1
- package/dist/{app-nctetn1e.js → app-80w6qspx.js} +5 -5
- package/dist/{assistant-0aq8gjvj.js → assistant-jtq439kw.js} +29 -20
- package/dist/{boot-reembed-1ze5853n.js → boot-reembed-3nc0aaed.js} +4 -4
- package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-feda8wn8.js} +4 -4
- package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-grsa2er0.js} +5 -5
- package/dist/{boot-seeds-22tx8a53.js → boot-seeds-gcfgq44f.js} +7 -7
- package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-pjv2q7t8.js} +7 -7
- package/dist/{claude-managed-adapter-tzwxy803.js → claude-managed-adapter-0wa8se82.js} +2 -2
- package/dist/{claude-managed-setup-nrhfx5b0.js → claude-managed-setup-zxa9p9tx.js} +1 -1
- package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-c9j6k9kx.js} +8772 -8663
- package/dist/{cli-c2tpw9ck.js → cli-3ry5gx4f.js} +1 -1
- package/dist/{cli-wydz3feg.js → cli-61tbnjpf.js} +214 -2472
- package/dist/{cli-cexctgn5.js → cli-7vz49g7h.js} +101 -34
- package/dist/{cli-6qxmjpem.js → cli-8xjst8q2.js} +34 -22
- package/dist/{cli-tx2zvn23.js → cli-91r17hb1.js} +3 -3
- package/dist/{cli-577bxkf6.js → cli-a8vps458.js} +182 -2208
- package/dist/{cli-tc5zec7v.js → cli-b7y8113e.js} +1 -1
- package/dist/{cli-69egj654.js → cli-bqrs5ycj.js} +1 -1
- package/dist/{cli-cf2vgn9d.js → cli-dv76vq5d.js} +30 -28
- package/dist/{cli-g5erf0zt.js → cli-e2prcx5m.js} +31 -0
- package/dist/{cli-c8r99akr.js → cli-e5qk3m2a.js} +209 -31
- package/dist/{cli-z1dqhc9y.js → cli-eqfbsxsn.js} +3 -3
- package/dist/{cli-kw0jcfje.js → cli-fyeft04q.js} +1 -1
- package/dist/{cli-5qkcdv8c.js → cli-gmdajfw5.js} +3 -3
- package/dist/{cli-ypgba1a8.js → cli-jkfxpg1b.js} +1 -1
- package/dist/{cli-mxvnnv5q.js → cli-jv36y8wz.js} +1 -1
- package/dist/{cli-m7vmeepg.js → cli-km8559dm.js} +6 -6
- package/dist/{cli-ykt8f7va.js → cli-kp3vfknv.js} +1 -1
- package/dist/{cli-ymmc1zxg.js → cli-m23r88re.js} +8 -5
- package/dist/{cli-v1pwxreq.js → cli-n4z21pt8.js} +1 -1
- package/dist/{cli-mxry91sa.js → cli-qg2zhjfc.js} +73 -4
- package/dist/{cli-1yrcfa5s.js → cli-rbr91whd.js} +4 -4
- package/dist/{cli-mkdsy1nq.js → cli-rg1c8ccw.js} +93 -7
- package/dist/cli-rh44xx46.js +2659 -0
- package/dist/cli-s0k2j7q9.js +2084 -0
- package/dist/{cli-ty4bzkqr.js → cli-sajr098b.js} +1 -1
- package/dist/{cli-6vfz44sr.js → cli-swrz3mj8.js} +1 -1
- package/dist/{cli-p8rt8spp.js → cli-vejds37t.js} +2 -2
- package/dist/{cli-jbzbr36n.js → cli-w6xa5nf5.js} +1 -1
- package/dist/{cli-9q3wt23f.js → cli-x6z90nq9.js} +1 -1
- package/dist/{cli-qzmzwegq.js → cli-ysnkvdx6.js} +102 -9
- package/dist/{cli-y0kjpfm9.js → cli-yvxfaem2.js} +2 -2
- package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
- package/dist/{cli-6bpvv8h4.js → cli-zzev32jw.js} +1 -1
- package/dist/cli.js +20 -17
- package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-0kpg09ca.js} +3 -3
- package/dist/{codex-hook-15dgc4cb.js → codex-hook-295jpbfa.js} +2 -2
- package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-8n672hxh.js} +3 -3
- package/dist/{commands-h6zaaxym.js → commands-mhmg5n0x.js} +4 -4
- package/dist/{cursor-adapter-v8mm6bp5.js → cursor-adapter-wn8n5zzk.js} +2 -2
- package/dist/{db-qg3myn53.js → db-wa6s1mwv.js} +4 -4
- package/dist/{dispatcher-7vnwj9qw.js → dispatcher-nhs2pkr8.js} +16 -14
- package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
- package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-28pkdt80.js} +3 -3
- package/dist/{handlers-2qtkpncr.js → handlers-5rtf9nng.js} +19 -17
- package/dist/{hook-51yfcgsv.js → hook-krjfkpzy.js} +4 -4
- package/dist/{hook-qnbdgwvd.js → hook-qkm73zk6.js} +5 -5
- package/dist/{http-jsmtag08.js → http-a397xcn4.js} +4845 -3484
- package/dist/{index-2t9kesn1.js → index-ac0v8v9d.js} +4 -4
- package/dist/{index-ekywyv2v.js → index-kqre45fr.js} +16 -14
- package/dist/{index-bt8wkv1w.js → index-rtxg15ah.js} +17 -15
- package/dist/{index-t4g06w93.js → index-vvsq7v45.js} +26 -23
- package/dist/{index-cx1sb9bq.js → index-ytdhtdry.js} +50 -11
- package/dist/{keepalive-4aezx4bj.js → keepalive-psfh8ysx.js} +6 -6
- package/dist/{lead-r44bkpm5.js → lead-m6rppjn7.js} +29 -27
- package/dist/{maintenance-grz5jrzh.js → maintenance-41f837ke.js} +4 -4
- package/dist/{node-qm9eb6z0.js → node-fkajaq96.js} +1 -1
- package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-6hr55hm8.js} +5 -5
- package/dist/{onboard-v60zgqf0.js → onboard-ktrtd7cz.js} +2 -2
- package/dist/{otel-impl-ghh4dyf8.js → otel-impl-dq4f2k3m.js} +1 -1
- package/dist/{pi-mono-adapter-0eg8kw2c.js → pi-mono-adapter-e5v9w0hv.js} +3 -2
- package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-kmk7j1ra.js} +6 -6
- package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-9w1dkkxd.js} +4 -4
- package/dist/{rbac-roles-axv4d74x.js → rbac-roles-9ykx9tc5.js} +5 -5
- package/dist/{render-v2-k50j33qd.js → render-v2-ycqm53np.js} +5 -5
- package/dist/resume-cq2pkp8q.js +70 -0
- package/dist/{setup-4syh0pd2.js → setup-0dcmdvbn.js} +2 -2
- package/dist/{virtual-modules-ftr9kwe8.js → virtual-modules-1x9k4jcs.js} +1 -1
- package/dist/{work-objects-jcdbg8rr.js → work-objects-x01x2fh8.js} +19 -17
- package/dist/{worker-x8j8c9sx.js → worker-py1ssb1h.js} +29 -27
- package/openapi.json +15622 -15042
- package/package.json +8 -5
- package/src/apps/store.ts +1 -0
- package/src/artifact-sdk/feedback-overlay.ts +528 -0
- package/src/be/db/workflow-runs.ts +95 -0
- package/src/be/db-queries/slack-inbound.ts +489 -0
- package/src/be/db.ts +9 -2
- package/src/be/extensions/assets.ts +1 -1
- package/src/be/migrations/203_workflow_run_parent_step.sql +10 -0
- package/src/be/migrations/204_slack_inbound_receipts.sql +51 -0
- package/src/be/script-connections.ts +41 -7
- package/src/be/scripts/run-saved.ts +40 -20
- package/src/be/swarm-config-guard.ts +4 -0
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/catalog.ts +1 -1
- package/src/extensions/dispatcher.ts +3 -3
- package/src/heartbeat/heartbeat.ts +152 -2
- package/src/heartbeat/templates.ts +16 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/index.ts +2 -0
- package/src/http/integrations.ts +3 -1
- package/src/http/mcp-bridge.ts +1 -0
- package/src/http/pages-public.ts +7 -2
- package/src/http/pages.ts +178 -0
- package/src/http/script-connections.ts +35 -0
- package/src/http/slack-inbound.ts +107 -0
- package/src/http/tasks.ts +45 -27
- package/src/http/users.ts +1 -1
- package/src/http/webhooks.ts +20 -20
- package/src/prompts/session-templates.ts +32 -0
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +2 -2
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-background-keepalive.ts +100 -0
- package/src/providers/claude-managed-adapter.ts +2 -11
- package/src/providers/claude-sdk-session.ts +7 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +1 -2
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/realtime/browser.generated.txt +8 -8
- package/src/realtime/browser.ts +3 -3
- package/src/scheduler/scheduler.ts +82 -13
- package/src/scripts-runtime/executors/quickjs-pool-config.ts +26 -0
- package/src/scripts-runtime/executors/quickjs.ts +9 -2
- package/src/scripts-runtime/executors/registry.ts +1 -1
- package/src/server.ts +1 -0
- package/src/slack/actions.ts +6 -2
- package/src/slack/app.ts +5 -0
- package/src/slack/assistant.ts +15 -5
- package/src/slack/egress-scrub.ts +3 -2
- package/src/slack/handlers.ts +36 -18
- package/src/slack/inbound-dispatch.ts +549 -0
- package/src/slack/inbound-files.ts +2 -0
- package/src/telemetry-context.ts +1 -0
- package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
- package/src/tests/claude-background-keepalive.fixture.ts +162 -0
- package/src/tests/claude-background-keepalive.test.ts +139 -0
- package/src/tests/codex-oauth-device.test.ts +1 -1
- package/src/tests/connection-embedded-auth.test.ts +51 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/extensions-catalog.test.ts +1 -0
- package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
- package/src/tests/heartbeat-checklist.test.ts +243 -0
- package/src/tests/pages-feedback.test.ts +218 -0
- package/src/tests/schedule-target-type.test.ts +136 -0
- package/src/tests/script-connections-http.test.ts +45 -0
- package/src/tests/script-connections-mcp.test.ts +41 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/script-executor-quickjs-pool.test.ts +106 -0
- package/src/tests/script-executor-registry.test.ts +56 -0
- package/src/tests/send-task-self-send.test.ts +107 -0
- package/src/tests/slack-inbound-diagnostics-auth.test.ts +93 -0
- package/src/tests/slack-inbound-receipts.test.ts +684 -0
- package/src/tests/slack-session-status.test.ts +4 -2
- package/src/tests/ui-e2e-ingest-payload.test.ts +5 -1
- package/src/tests/user-token-routes.test.ts +1 -1
- package/src/tests/workflow-executors.test.ts +3 -2
- package/src/tests/workflow-sub-workflow.test.ts +363 -0
- package/src/tools/script-connections/tool.ts +35 -3
- package/src/tools/send-task.ts +9 -1
- package/src/utils/internal-ai/complete-structured.ts +6 -3
- package/src/utils/runtime-model-catalog.ts +1 -1
- package/src/workflows/engine.ts +17 -1
- package/src/workflows/executors/agent-task.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +3 -3
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +1 -1
- package/src/workflows/executors/sub-workflow.ts +135 -0
- package/src/workflows/executors/wait.ts +3 -3
- package/src/workflows/recovery.ts +11 -1
- package/src/workflows/resume.ts +77 -0
- package/src/workflows/wait-filter.ts +5 -7
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/slack-reply-gate/README.md +98 -0
- package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
- package/templates/extensions/slack-reply-gate/manifest.json +8 -0
- package/templates/skills/heartbeat-runbook/content.md +3 -1
- package/templates/skills/pages/content.md +25 -0
- package/templates/skills/swarm-scripts/SKILL.md +10 -0
- package/templates/skills/swarm-scripts/content.md +10 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Durable receipts for inbound Slack deliveries (events, interactions, commands).
|
|
2
|
+
--
|
|
3
|
+
-- A receipt is admitted with one atomic INSERT against the unique dedup key, so a
|
|
4
|
+
-- retried delivery can never create a second drain job. The payload is stored
|
|
5
|
+
-- encrypted (AES-256-GCM, the secrets cipher) only while it may still be needed:
|
|
6
|
+
-- processed and ignored receipts erase it on completion. Pending, processing and
|
|
7
|
+
-- uncertain receipts are never expired by retention; completed dedup keys are kept
|
|
8
|
+
-- for 48 hours so Slack's retry schedule cannot replay them.
|
|
9
|
+
--
|
|
10
|
+
-- A receipt left `processing` by a crash becomes `uncertain` at the next boot and is
|
|
11
|
+
-- never replayed automatically: a handler may already have created tasks.
|
|
12
|
+
CREATE TABLE slack_inbound_receipts (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
dedup_key TEXT NOT NULL UNIQUE,
|
|
15
|
+
transport TEXT NOT NULL CHECK (transport IN ('socket', 'http')),
|
|
16
|
+
kind TEXT NOT NULL CHECK (kind IN ('event', 'interaction', 'command')),
|
|
17
|
+
payload_type TEXT NOT NULL,
|
|
18
|
+
api_app_id TEXT,
|
|
19
|
+
event_id TEXT,
|
|
20
|
+
payload_ciphertext TEXT,
|
|
21
|
+
payload_bytes INTEGER NOT NULL DEFAULT 0 CHECK (payload_bytes >= 0),
|
|
22
|
+
retry_num INTEGER,
|
|
23
|
+
retry_reason TEXT,
|
|
24
|
+
state TEXT NOT NULL DEFAULT 'pending'
|
|
25
|
+
CHECK (state IN ('pending', 'processing', 'processed', 'ignored', 'failed', 'uncertain')),
|
|
26
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
27
|
+
duplicate_count INTEGER NOT NULL DEFAULT 0 CHECK (duplicate_count >= 0),
|
|
28
|
+
outcome_code TEXT,
|
|
29
|
+
error_code TEXT,
|
|
30
|
+
received_at TEXT NOT NULL,
|
|
31
|
+
available_at TEXT NOT NULL,
|
|
32
|
+
claimed_at TEXT,
|
|
33
|
+
completed_at TEXT,
|
|
34
|
+
last_duplicate_at TEXT,
|
|
35
|
+
payload_erased_at TEXT,
|
|
36
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
37
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
38
|
+
created_by TEXT REFERENCES users(id),
|
|
39
|
+
updated_by TEXT REFERENCES users(id)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE INDEX idx_slack_inbound_receipts_claim
|
|
43
|
+
ON slack_inbound_receipts(available_at, received_at)
|
|
44
|
+
WHERE state = 'pending';
|
|
45
|
+
|
|
46
|
+
CREATE INDEX idx_slack_inbound_receipts_state
|
|
47
|
+
ON slack_inbound_receipts(state, received_at);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX idx_slack_inbound_receipts_completed
|
|
50
|
+
ON slack_inbound_receipts(completed_at)
|
|
51
|
+
WHERE state IN ('processed', 'ignored', 'failed');
|
|
@@ -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;
|
|
1141
|
-
//
|
|
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.
|
|
1312
|
-
*
|
|
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;
|
|
@@ -1470,7 +1468,7 @@ function parseOpenapiSpecBody(body: string): unknown {
|
|
|
1470
1468
|
try {
|
|
1471
1469
|
return JSON.parse(body);
|
|
1472
1470
|
} catch (jsonErr) {
|
|
1473
|
-
const yaml = (Bun as
|
|
1471
|
+
const yaml = (Bun as { YAML?: { parse(input: string): unknown } }).YAML;
|
|
1474
1472
|
if (!yaml) {
|
|
1475
1473
|
throw new Error(
|
|
1476
1474
|
`OpenAPI spec response was not valid JSON. JSON specs only unless Bun.YAML.parse is available: ${
|
|
@@ -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[] {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runScript } from "../../scripts-runtime/loader";
|
|
1
|
+
import { type RunScriptOutput, runScript } from "../../scripts-runtime/loader";
|
|
2
2
|
import type { ScriptRecord } from "../../types";
|
|
3
3
|
import {
|
|
4
4
|
getScriptApiConnectionDescriptors,
|
|
@@ -11,12 +11,21 @@ export function getSavedScriptOwnerAgentId(script: ScriptRecord): string | null
|
|
|
11
11
|
return script.scopeId ?? script.createdByAgentId;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Run a saved script with the selected agent's credential and connection bindings.
|
|
16
|
+
* `signal` covers the whole call: an abort during credential setup means the
|
|
17
|
+
* script never starts, and an abort during execution kills it.
|
|
18
|
+
*/
|
|
19
|
+
export async function runSavedScriptAsAgent(
|
|
20
|
+
args: {
|
|
21
|
+
script: ScriptRecord;
|
|
22
|
+
input: unknown;
|
|
23
|
+
agentId: string;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
},
|
|
27
|
+
deps: { buildCredentials?: typeof buildScriptCredentialBindingsWithFailures } = {},
|
|
28
|
+
) {
|
|
20
29
|
// Touch before executing (not just after) so a scratch script that's already
|
|
21
30
|
// stale when a run starts can't be reaped by the retention sweep while the
|
|
22
31
|
// run — which may take up to the runtime's wall-clock ceiling — is in flight.
|
|
@@ -24,19 +33,30 @@ export async function runSavedScriptAsAgent(args: {
|
|
|
24
33
|
? await touchScratchScriptLastUsed(args.script.id)
|
|
25
34
|
: null;
|
|
26
35
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
const buildCredentials = deps.buildCredentials ?? buildScriptCredentialBindingsWithFailures;
|
|
37
|
+
const credentials = await buildCredentials({ agentId: args.agentId });
|
|
38
|
+
const output: RunScriptOutput = args.signal?.aborted
|
|
39
|
+
? {
|
|
40
|
+
result: undefined,
|
|
41
|
+
stdout: "",
|
|
42
|
+
stderr: "aborted before execution started",
|
|
43
|
+
truncated: { stdout: false, stderr: false },
|
|
44
|
+
durationMs: 0,
|
|
45
|
+
exitCode: 1,
|
|
46
|
+
error: "killed",
|
|
47
|
+
}
|
|
48
|
+
: await runScript({
|
|
49
|
+
source: args.script.source,
|
|
50
|
+
args: args.input,
|
|
51
|
+
fsMode: args.script.fsMode,
|
|
52
|
+
agentId: args.agentId,
|
|
53
|
+
egressSecrets: credentials.egressSecrets,
|
|
54
|
+
failedBindings: credentials.failedBindings,
|
|
55
|
+
apiConnections: getScriptApiConnectionDescriptors({ agentId: args.agentId }),
|
|
56
|
+
mcpConnections: getScriptMcpConnectionDescriptors({ agentId: args.agentId }),
|
|
57
|
+
...(args.timeoutMs ? { timeoutMs: args.timeoutMs } : {}),
|
|
58
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
59
|
+
});
|
|
40
60
|
if (output.exitCode === 0 && !output.error && !output.runtimeError) {
|
|
41
61
|
await touchScratchScriptLastUsed(args.script.id);
|
|
42
62
|
} else if (runStartTouch) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateQuickJSPoolSize } from "../scripts-runtime/executors/quickjs-pool-config";
|
|
1
2
|
import { normalizeSlackReactionShortcode } from "../slack/reaction-shortcode";
|
|
2
3
|
import { ProviderNameSchema } from "../types";
|
|
3
4
|
import { API_DRAIN_MAX_MS_LIMIT } from "../utils/api-drain";
|
|
@@ -263,9 +264,12 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
263
264
|
return `Invalid HARNESS_PROVIDER value (must be one of: ${ProviderNameSchema.options.join(", ")})`;
|
|
264
265
|
},
|
|
265
266
|
...enumValidator("CLAUDE_TRANSPORT", ["cli", "sdk"]),
|
|
267
|
+
...enumValidator("SCRIPT_EXECUTOR", ["native", "quickjs"]),
|
|
268
|
+
SCRIPT_QUICKJS_POOL_SIZE: validateQuickJSPoolSize,
|
|
266
269
|
// fail: worker fails a task fast when every key has exhausted the task
|
|
267
270
|
// model's weekly window (Fable/Opus/Sonnet). fallback: legacy random pick.
|
|
268
271
|
...enumValidator("MODEL_WINDOW_EXHAUSTED_POLICY", ["fail", "fallback"]),
|
|
272
|
+
...enumValidator("HEARTBEAT_CHECKLIST_GATE_MODE", ["shadow", "enforce"]),
|
|
269
273
|
// Codex credits-exhausted cooldown (ms). Permissive on range here (positive
|
|
270
274
|
// integer) — the worker clamps to [5m, 7d] via resolveCodexCreditsExhaustedCooldownMs.
|
|
271
275
|
CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS: (value) => {
|
|
@@ -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": {
|
|
@@ -15,7 +15,7 @@ export type ExtensionCatalogEntry = {
|
|
|
15
15
|
readme: string | null;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const BUNDLED = generated as
|
|
18
|
+
const BUNDLED = generated as Record<string, ExtensionCatalogEntry>;
|
|
19
19
|
let override: Record<string, ExtensionCatalogEntry> | null = null;
|
|
20
20
|
|
|
21
21
|
export function getExtensionCatalog(): Record<string, ExtensionCatalogEntry> {
|
|
@@ -374,8 +374,8 @@ export async function dispatchPre<E extends PreEventName>(
|
|
|
374
374
|
return { action: "continue" };
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
let currentPayload
|
|
378
|
-
|
|
377
|
+
let currentPayload: Record<string, unknown> = { ...payload };
|
|
378
|
+
const mergedData: Record<string, unknown> = {};
|
|
379
379
|
let modified = false;
|
|
380
380
|
for (const { loaded, handler } of handlersFor(event, opts)) {
|
|
381
381
|
if (!isRegistered(loaded.record.id)) continue;
|
|
@@ -445,7 +445,7 @@ export async function dispatchPre<E extends PreEventName>(
|
|
|
445
445
|
if (!validation.success) throw validation.error;
|
|
446
446
|
data = definedObject(validation.data);
|
|
447
447
|
}
|
|
448
|
-
mergedData
|
|
448
|
+
Object.assign(mergedData, data);
|
|
449
449
|
currentPayload = mergePayload(event, currentPayload, data);
|
|
450
450
|
modified = true;
|
|
451
451
|
await markSuccess(loaded);
|
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
countActiveRuntimeInstancesForAgent,
|
|
57
57
|
expireStaleRuntimeInstances,
|
|
58
58
|
} from "../be/multi-runtime";
|
|
59
|
+
import { getScript } from "../be/scripts/db";
|
|
60
|
+
import { runSavedScriptAsAgent } from "../be/scripts/run-saved";
|
|
59
61
|
import { promotePendingSteeringForTask } from "../be/steering";
|
|
60
62
|
import type { HeartbeatAction, HeartbeatClassification } from "../extensions/contract";
|
|
61
63
|
import { dispatchPre, extensionIdForAgent } from "../extensions/dispatcher";
|
|
@@ -257,6 +259,31 @@ function isBootTriageAlways(): boolean {
|
|
|
257
259
|
return isEnvFlagEnabled("HEARTBEAT_BOOT_TRIAGE_ALWAYS", false);
|
|
258
260
|
}
|
|
259
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Wall-clock ceiling for the whole checklist gate call: script lookup,
|
|
264
|
+
* credential setup, and execution.
|
|
265
|
+
*/
|
|
266
|
+
const HEARTBEAT_CHECKLIST_GATE_TIMEOUT_MS = 60_000;
|
|
267
|
+
let checklistGateTimeoutMs = HEARTBEAT_CHECKLIST_GATE_TIMEOUT_MS;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Name of a global-scope script that decides whether a checklist tick needs
|
|
271
|
+
* the Lead. Unset or empty turns the gate off.
|
|
272
|
+
*/
|
|
273
|
+
function heartbeatChecklistGateScript(): string | null {
|
|
274
|
+
return process.env.HEARTBEAT_CHECKLIST_GATE_SCRIPT?.trim() || null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `shadow` (default) runs the gate and only tags the checklist task with its
|
|
279
|
+
* verdict. `enforce` skips the task when the gate reports a quiet tick.
|
|
280
|
+
*/
|
|
281
|
+
function heartbeatChecklistGateMode(): "shadow" | "enforce" {
|
|
282
|
+
return process.env.HEARTBEAT_CHECKLIST_GATE_MODE?.trim().toLowerCase() === "enforce"
|
|
283
|
+
? "enforce"
|
|
284
|
+
: "shadow";
|
|
285
|
+
}
|
|
286
|
+
|
|
260
287
|
// ============================================================================
|
|
261
288
|
// Types
|
|
262
289
|
// ============================================================================
|
|
@@ -322,6 +349,23 @@ export function setBeforeHeartbeatSupersedeForTests(
|
|
|
322
349
|
beforeHeartbeatSupersedeForTests = hook;
|
|
323
350
|
}
|
|
324
351
|
|
|
352
|
+
type ChecklistGateRunner = (
|
|
353
|
+
scriptName: string,
|
|
354
|
+
leadAgentId: string,
|
|
355
|
+
signal: AbortSignal,
|
|
356
|
+
) => Promise<unknown>;
|
|
357
|
+
let checklistGateRunnerForTests: ChecklistGateRunner | null = null;
|
|
358
|
+
|
|
359
|
+
/** Replace the gate script runner. The runner returns the script's result or throws. */
|
|
360
|
+
export function setChecklistGateRunnerForTests(runner: ChecklistGateRunner | null): void {
|
|
361
|
+
checklistGateRunnerForTests = runner;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Override the gate deadline. `null` restores the 60s default. */
|
|
365
|
+
export function setChecklistGateTimeoutMsForTests(ms: number | null): void {
|
|
366
|
+
checklistGateTimeoutMs = ms ?? HEARTBEAT_CHECKLIST_GATE_TIMEOUT_MS;
|
|
367
|
+
}
|
|
368
|
+
|
|
325
369
|
// ============================================================================
|
|
326
370
|
// Tier 1: Preflight Gate
|
|
327
371
|
// ============================================================================
|
|
@@ -1664,6 +1708,91 @@ export async function getBootTriageFindings(): Promise<BootTriageFindings> {
|
|
|
1664
1708
|
};
|
|
1665
1709
|
}
|
|
1666
1710
|
|
|
1711
|
+
type ChecklistGateVerdict =
|
|
1712
|
+
| { verdict: "quiet"; reason: string }
|
|
1713
|
+
| { verdict: "wake"; reason: string; summary: string | null }
|
|
1714
|
+
| { verdict: "error"; reason: string };
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Look up the global gate script and run it as the Lead. `deps` lets tests
|
|
1718
|
+
* stall credential setup on the real path.
|
|
1719
|
+
*/
|
|
1720
|
+
export async function defaultChecklistGateRunner(
|
|
1721
|
+
scriptName: string,
|
|
1722
|
+
leadAgentId: string,
|
|
1723
|
+
signal: AbortSignal,
|
|
1724
|
+
deps: Parameters<typeof runSavedScriptAsAgent>[1] = {},
|
|
1725
|
+
): Promise<unknown> {
|
|
1726
|
+
const script = await getScript({ name: scriptName, scope: "global" });
|
|
1727
|
+
if (!script) throw new Error(`global script "${scriptName}" not found`);
|
|
1728
|
+
const output = await runSavedScriptAsAgent(
|
|
1729
|
+
{
|
|
1730
|
+
script,
|
|
1731
|
+
input: { leadAgentId },
|
|
1732
|
+
agentId: leadAgentId,
|
|
1733
|
+
timeoutMs: checklistGateTimeoutMs,
|
|
1734
|
+
signal,
|
|
1735
|
+
},
|
|
1736
|
+
deps,
|
|
1737
|
+
);
|
|
1738
|
+
if (output.exitCode !== 0 || output.error || output.runtimeError) {
|
|
1739
|
+
throw new Error(
|
|
1740
|
+
output.runtimeError?.message ?? output.error ?? `script exited with code ${output.exitCode}`,
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
return output.result;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Ask the gate script whether this tick needs the Lead. The script returns
|
|
1748
|
+
* `{ quiet: boolean, reason?: string, summary?: string }`. Anything else, and
|
|
1749
|
+
* any failure, is an `error` verdict, which never skips a checklist.
|
|
1750
|
+
*
|
|
1751
|
+
* One deadline covers the whole call, credential setup included. When it
|
|
1752
|
+
* fires, the runner's signal aborts (the script never starts, or is killed)
|
|
1753
|
+
* and the verdict is `error` without waiting for the runner to settle.
|
|
1754
|
+
*/
|
|
1755
|
+
async function runChecklistGate(
|
|
1756
|
+
scriptName: string,
|
|
1757
|
+
leadAgentId: string,
|
|
1758
|
+
): Promise<ChecklistGateVerdict> {
|
|
1759
|
+
const controller = new AbortController();
|
|
1760
|
+
const timeoutMs = checklistGateTimeoutMs;
|
|
1761
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1762
|
+
try {
|
|
1763
|
+
const runner = checklistGateRunnerForTests ?? defaultChecklistGateRunner;
|
|
1764
|
+
const run = runner(scriptName, leadAgentId, controller.signal);
|
|
1765
|
+
// A runner that settles after the deadline must not surface as an unhandled rejection.
|
|
1766
|
+
run.catch(() => {});
|
|
1767
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
1768
|
+
timer = setTimeout(() => {
|
|
1769
|
+
controller.abort();
|
|
1770
|
+
reject(new Error(`gate timed out after ${timeoutMs}ms`));
|
|
1771
|
+
}, timeoutMs);
|
|
1772
|
+
});
|
|
1773
|
+
const result = (await Promise.race([run, deadline])) as {
|
|
1774
|
+
quiet?: unknown;
|
|
1775
|
+
reason?: unknown;
|
|
1776
|
+
summary?: unknown;
|
|
1777
|
+
} | null;
|
|
1778
|
+
if (!result || typeof result !== "object" || typeof result.quiet !== "boolean") {
|
|
1779
|
+
return { verdict: "error", reason: "result has no boolean `quiet` field" };
|
|
1780
|
+
}
|
|
1781
|
+
const reason = typeof result.reason === "string" ? result.reason.slice(0, 300) : "";
|
|
1782
|
+
if (result.quiet) return { verdict: "quiet", reason };
|
|
1783
|
+
const summary =
|
|
1784
|
+
typeof result.summary === "string" && result.summary.trim()
|
|
1785
|
+
? result.summary.slice(0, 4000)
|
|
1786
|
+
: null;
|
|
1787
|
+
return { verdict: "wake", reason, summary };
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1790
|
+
return { verdict: "error", reason: scrubSecrets(message).slice(0, 300) };
|
|
1791
|
+
} finally {
|
|
1792
|
+
clearTimeout(timer);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1667
1796
|
/**
|
|
1668
1797
|
* Check HEARTBEAT.md content and create a checklist task for the lead if needed.
|
|
1669
1798
|
*/
|
|
@@ -1687,7 +1816,28 @@ export async function checkHeartbeatChecklist(): Promise<void> {
|
|
|
1687
1816
|
);
|
|
1688
1817
|
if (existing) return;
|
|
1689
1818
|
|
|
1690
|
-
const
|
|
1819
|
+
const gateScript = heartbeatChecklistGateScript();
|
|
1820
|
+
const gateMode = heartbeatChecklistGateMode();
|
|
1821
|
+
const gate = gateScript ? await runChecklistGate(gateScript, lead.id) : null;
|
|
1822
|
+
if (gate) {
|
|
1823
|
+
console.log(
|
|
1824
|
+
`[Heartbeat] Checklist gate "${gateScript}" (${gateMode}): ${gate.verdict}${gate.reason ? ` (${gate.reason})` : ""}`,
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
if (gate?.verdict === "quiet" && gateMode === "enforce") {
|
|
1828
|
+
console.log(`[Heartbeat] Checklist skipped for lead ${lead.name}: quiet tick`);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
let systemStatus = await gatherSystemStatus();
|
|
1833
|
+
// Shadow mode leaves the prompt untouched so the Lead's own quiet/non-quiet
|
|
1834
|
+
// outcome stays an independent check on the gate's verdict.
|
|
1835
|
+
if (gateMode === "enforce" && gate?.verdict === "wake" && gate.summary) {
|
|
1836
|
+
const findings = resolveTemplate("heartbeat.checklist.gate_findings", {
|
|
1837
|
+
gate_summary: gate.summary,
|
|
1838
|
+
});
|
|
1839
|
+
if (!findings.skipped && findings.text) systemStatus += `\n\n${findings.text}`;
|
|
1840
|
+
}
|
|
1691
1841
|
|
|
1692
1842
|
const result = resolveTemplate("heartbeat.checklist", {
|
|
1693
1843
|
system_status: systemStatus,
|
|
@@ -1704,7 +1854,7 @@ export async function checkHeartbeatChecklist(): Promise<void> {
|
|
|
1704
1854
|
// the swarm's own work, not as a human's MCP usage.
|
|
1705
1855
|
source: "system",
|
|
1706
1856
|
taskType: "heartbeat-checklist",
|
|
1707
|
-
tags: ["checklist", "auto-generated"],
|
|
1857
|
+
tags: ["checklist", "auto-generated", ...(gate ? [`heartbeat-gate:${gate.verdict}`] : [])],
|
|
1708
1858
|
priority: 60,
|
|
1709
1859
|
});
|
|
1710
1860
|
|
|
@@ -53,6 +53,22 @@ Goal: Review system status and your standing orders, take action if needed.
|
|
|
53
53
|
category: "event",
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
// Appended to the checklist's system status when the checklist gate runs in
|
|
57
|
+
// enforce mode and reports a wake verdict with a summary.
|
|
58
|
+
registerTemplate({
|
|
59
|
+
eventType: "heartbeat.checklist.gate_findings",
|
|
60
|
+
header: "",
|
|
61
|
+
defaultBody: `## Gate Findings [auto-generated]
|
|
62
|
+
{{gate_summary}}`,
|
|
63
|
+
variables: [
|
|
64
|
+
{
|
|
65
|
+
name: "gate_summary",
|
|
66
|
+
description: "The checklist gate script's `summary` of what changed since the last tick",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
category: "event",
|
|
70
|
+
});
|
|
71
|
+
|
|
56
72
|
// ============================================================================
|
|
57
73
|
// Boot triage (one-off after container restart)
|
|
58
74
|
// ============================================================================
|
package/src/http/all-routes.ts
CHANGED
package/src/http/index.ts
CHANGED
|
@@ -121,6 +121,7 @@ import { handleScripts } from "./scripts";
|
|
|
121
121
|
import { handleSessionData } from "./session-data";
|
|
122
122
|
import { handleSessions } from "./sessions";
|
|
123
123
|
import { handleSkills } from "./skills";
|
|
124
|
+
import { handleSlackInbound } from "./slack-inbound";
|
|
124
125
|
import { handleStats } from "./stats";
|
|
125
126
|
import { handleStatus } from "./status";
|
|
126
127
|
import { handleTaskTemplates } from "./task-templates";
|
|
@@ -362,6 +363,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
362
363
|
() => handleRealtimeTicket(req, res, pathSegments, queryParams),
|
|
363
364
|
() => handleRealtimeAsset(req, res),
|
|
364
365
|
() => handleIntegrations(req, res, pathSegments, queryParams),
|
|
366
|
+
() => handleSlackInbound(req, res, pathSegments),
|
|
365
367
|
() => handlePromptTemplates(req, res, pathSegments, queryParams),
|
|
366
368
|
() => handleDbQuery(req, res, pathSegments, queryParams),
|
|
367
369
|
() => handleMetrics(req, res, pathSegments, queryParams, myAgentId),
|
package/src/http/integrations.ts
CHANGED
|
@@ -105,7 +105,9 @@ const slackManifestRoute = route({
|
|
|
105
105
|
export function createIntegrationsHandler(deps: TestConnectionDeps = {}) {
|
|
106
106
|
const buildClient =
|
|
107
107
|
deps.buildClient ??
|
|
108
|
-
((apiKey: string) =>
|
|
108
|
+
((apiKey: string) =>
|
|
109
|
+
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- the SDK client is a superset of this narrow test seam.
|
|
110
|
+
new Anthropic({ apiKey }) as unknown as ClaudeManagedTestClient);
|
|
109
111
|
|
|
110
112
|
return async function handleIntegrations(
|
|
111
113
|
req: IncomingMessage,
|
package/src/http/mcp-bridge.ts
CHANGED
|
@@ -66,6 +66,7 @@ export async function invokeToolInProcess(args: {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const server = await getBridgeServer();
|
|
69
|
+
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- the MCP SDK keeps its tool registry private.
|
|
69
70
|
const tools = (server as unknown as { _registeredTools: ToolRegistry })._registeredTools;
|
|
70
71
|
const tool = tools[args.toolName];
|
|
71
72
|
if (!tool) {
|
package/src/http/pages-public.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
25
25
|
import { z } from "zod";
|
|
26
26
|
import { BROWSER_SDK_JS, SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
|
|
27
|
+
import { feedbackOverlayScripts, isPageFeedbackRequested } from "../artifact-sdk/feedback-overlay";
|
|
27
28
|
import { getPage, incrementPageViewCount } from "../be/db";
|
|
28
29
|
import { type Page, PageSchema } from "../types";
|
|
29
30
|
import { getAppUrl, getConfiguredAppUrls } from "../utils/constants";
|
|
@@ -642,8 +643,12 @@ export async function handlePagesPublic(
|
|
|
642
643
|
}
|
|
643
644
|
|
|
644
645
|
// text/html — inject SDK + serve. Append the self-print snippet when the
|
|
645
|
-
// export button requested it
|
|
646
|
-
|
|
646
|
+
// export button requested it, and the feedback overlay when
|
|
647
|
+
// `?__swarm-feedback` is on (the overlay hides itself in print).
|
|
648
|
+
const html =
|
|
649
|
+
injectBrowserSdk(page.body) +
|
|
650
|
+
(wantsPrint ? PRINT_AUTOTRIGGER_SCRIPT : "") +
|
|
651
|
+
(isPageFeedbackRequested(queryParams) ? feedbackOverlayScripts(page.id) : "");
|
|
647
652
|
const headers: Record<string, string> = {
|
|
648
653
|
"Content-Type": "text/html; charset=utf-8",
|
|
649
654
|
"Cache-Control": "no-store",
|