@desplega.ai/agent-swarm 1.166.0 → 1.168.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-adapter-zhejfecx.js +34 -0
- package/dist/{actions-qz3m692w.js → actions-7jg840nr.js} +25 -25
- package/dist/{amp-adapter-j1xbmsqz.js → amp-adapter-ye6rnetm.js} +4 -4
- package/dist/{anthropic-messages-hst62ws9.js → anthropic-messages-13yn502s.js} +4 -4
- package/dist/{app-43vrdmv8.js → app-ffes2kyf.js} +10 -9
- package/dist/{assistant-ertffmka.js → assistant-fhm5smyz.js} +26 -26
- package/dist/{azure-openai-responses-mh8tvf78.js → azure-openai-responses-9st34pfz.js} +5 -5
- package/dist/{boot-reembed-998jtpdg.js → boot-reembed-20q3nzvg.js} +9 -8
- package/dist/{boot-reembed-s7k7c6v2.js → boot-reembed-jvfadhsk.js} +9 -8
- package/dist/{boot-scrub-sweep-nepsk4t1.js → boot-scrub-sweep-jtfajzve.js} +10 -9
- package/dist/{boot-seeds-vvzs1zhk.js → boot-seeds-9qt0j8j2.js} +12 -11
- package/dist/{claude-adapter-2zvmekrp.js → claude-adapter-a8znhthz.js} +24 -22
- package/dist/{claude-managed-adapter-s49wbk8t.js → claude-managed-adapter-tzwxy803.js} +4 -4
- package/dist/{claude-managed-setup-cvhqq8qn.js → claude-managed-setup-nrhfx5b0.js} +3 -3
- package/dist/{claude-sdk-session-kkbxtw5q.js → claude-sdk-session-7x7chdrs.js} +7608 -7596
- package/dist/{cli-zsh093hk.js → cli-014vns2y.js} +63 -1
- package/dist/{cli-8kgvmkak.js → cli-0jngabtb.js} +1 -1
- package/dist/{cli-qvfk83fy.js → cli-13xwcqvj.js} +2 -2
- package/dist/{cli-0nhdwxd5.js → cli-43jg9xdw.js} +3 -3
- package/dist/{cli-cpfmqz3n.js → cli-47zfzshm.js} +5 -5
- package/dist/{cli-zy7vwsne.js → cli-5qkcdv8c.js} +16 -9
- package/dist/{cli-cwxhbf43.js → cli-5t5my0h8.js} +85 -2
- package/dist/{cli-y59h2pk9.js → cli-69egj654.js} +1 -1
- package/dist/{cli-68r1j4sg.js → cli-6bpvv8h4.js} +3 -3
- package/dist/{cli-yshaazs1.js → cli-74ez9b66.js} +63 -20
- package/dist/{cli-32bca7nr.js → cli-7avy4v8w.js} +1 -1
- package/dist/{cli-f2n17zhp.js → cli-7mhy3ths.js} +21 -18
- package/dist/{cli-q38n4bq0.js → cli-95zs84qw.js} +3 -3
- package/dist/{cli-fn697bnt.js → cli-9q0bh411.js} +21 -8
- package/dist/{cli-rggdq8wq.js → cli-9q24yyvs.js} +3 -3
- package/dist/cli-a35hzdve.js +33 -0
- package/dist/{cli-3qn4r334.js → cli-aj1e0h0d.js} +20 -14
- package/dist/{cli-rsy933yz.js → cli-bcspch6h.js} +30 -19
- package/dist/{cli-x3x0qwky.js → cli-bxqez1p0.js} +51 -23
- package/dist/{cli-ng97fjq7.js → cli-cmxkbzzs.js} +33 -7
- package/dist/{cli-df56tqdw.js → cli-ee0402n1.js} +198 -70
- package/dist/{cli-mwegs4af.js → cli-ew0c3a1j.js} +3 -3
- package/dist/{cli-ysnyhws9.js → cli-ewhwpyrb.js} +54 -51
- package/dist/{cli-j2jbr8af.js → cli-fhj4y16f.js} +1 -1
- package/dist/{cli-8t229cmr.js → cli-fqq06kkb.js} +30 -17
- package/dist/{cli-jvj6a0cv.js → cli-g6gx2dn1.js} +54 -19
- package/dist/{cli-nexwh8zg.js → cli-g7k7zbcz.js} +12 -5
- package/dist/{cli-ecknp7p1.js → cli-j05x7pnb.js} +10 -10
- package/dist/{cli-gt8azyet.js → cli-jb931ba2.js} +1 -1
- package/dist/{cli-nvvfd8bq.js → cli-jbzbr36n.js} +4091 -3793
- package/dist/{cli-3ddybbbx.js → cli-jvmtxqtc.js} +26 -14
- package/dist/{cli-pkke6geq.js → cli-km4x66j0.js} +83 -65
- package/dist/{cli-b0f70z24.js → cli-mn8qkd8v.js} +1 -1
- package/dist/{cli-0qjcsxgz.js → cli-nctfv2yy.js} +99 -9
- package/dist/{cli-4r1sjfc4.js → cli-ng1pmdx6.js} +10 -10
- package/dist/cli-nkqjj42q.js +104 -0
- package/dist/{cli-aqcey741.js → cli-p5jqk8fr.js} +1 -1
- package/dist/{cli-66zh26sp.js → cli-pgehz8e1.js} +57 -17
- package/dist/cli-q1vnz8wv.js +96 -0
- package/dist/{cli-fj86xr3c.js → cli-qd97v6z2.js} +2 -2
- package/dist/{cli-p4ztb80h.js → cli-r7eeddkq.js} +13 -8
- package/dist/{cli-8ssjez7e.js → cli-rhy7z1rp.js} +22 -11
- package/dist/{cli-vzc73wet.js → cli-rr5z1j4p.js} +87 -69
- package/dist/{cli-4mevd1hf.js → cli-s9y81s7j.js} +5 -5
- package/dist/{cli-dw2q9f7g.js → cli-svah6b6w.js} +4 -4
- package/dist/{cli-h3x613ze.js → cli-tc5zec7v.js} +6 -6
- package/dist/{cli-ttbsse3f.js → cli-tmhsxhb8.js} +1 -1
- package/dist/{cli-feh3dkah.js → cli-v1w7pbd8.js} +2 -1
- package/dist/{cli-10zmp1p7.js → cli-vr5avem2.js} +27 -32
- package/dist/{cli-bzr0ftht.js → cli-wnb9b2g3.js} +19 -0
- package/dist/{cli-krqm5f11.js → cli-xezn9nkh.js} +16 -13
- package/dist/{cli-8kt0bmgx.js → cli-xf713zz0.js} +2 -2
- package/dist/{cli-6zcfw0x7.js → cli-y195hv1a.js} +1 -1
- package/dist/{cli-x7kpr72a.js → cli-ytmmyj7f.js} +2 -2
- package/dist/{cli-v2a8x45d.js → cli-z48ggfyq.js} +11 -6
- package/dist/{cli-qbksc292.js → cli-zy39d433.js} +2 -0
- package/dist/cli.js +28 -28
- package/dist/{cloudflare-workers-ai-system-one-tt484t61.js → cloudflare-workers-ai-system-one-sta45jf9.js} +7 -5
- package/dist/{codex-adapter-dw1wxqt8.js → codex-adapter-n63k33d3.js} +18 -18
- package/dist/{codex-login-terbdsqq.js → codex-login-g1cyt5zm.js} +1 -1
- package/dist/{codex-session-runner-3cgb8y1g.js → codex-session-runner-xkxfd81a.js} +18 -18
- package/dist/{commands-mhxam6z2.js → commands-q4mfhn51.js} +9 -8
- package/dist/{cursor-adapter-2qda3st7.js → cursor-adapter-v8mm6bp5.js} +5 -5
- package/dist/{db-sjks418k.js → db-1t0bj3pc.js} +9 -8
- package/dist/{dispatcher-1es759rn.js → dispatcher-k1fhavcf.js} +24 -24
- package/dist/{dsh-adapter-bek4cyw9.js → dsh-adapter-5423p70m.js} +5 -5
- package/dist/{easter-egg-3d-mtpnw06s.js → easter-egg-3d-66zxervv.js} +2 -2
- package/dist/{execute-s4ccjq3n.js → execute-h4jgevyw.js} +61 -11
- package/dist/{google-generative-ai-d6nphg3q.js → google-generative-ai-b8zxrpax.js} +5 -5
- package/dist/{google-vertex-cpshkref.js → google-vertex-nrdd697t.js} +5 -5
- package/dist/{grok-adapter-ar9cw9jg.js → grok-adapter-phrndeb8.js} +18 -8
- package/dist/{handlers-1gkbhktr.js → handlers-59ywqwzh.js} +27 -27
- package/dist/{hook-vxa3tcsy.js → hook-atj0swc2.js} +14 -14
- package/dist/{hook-ds2zxq9v.js → hook-mew1chq9.js} +42 -31
- package/dist/{http-3h21z9md.js → http-ef2fnn9v.js} +953 -98
- package/dist/{index-n99fycq7.js → index-48pb9erw.js} +24 -24
- package/dist/{index-bn6cy8ps.js → index-9fc2h7ww.js} +24 -24
- package/dist/{index-9b65j1m5.js → index-gr69c43q.js} +9 -8
- package/dist/{index-f8xnqrye.js → index-h26wjcfy.js} +43 -24
- package/dist/{index-dkfxpkmm.js → index-w2yhye23.js} +25 -25
- package/dist/{keepalive-3xspa9jw.js → keepalive-qhcqz56v.js} +11 -10
- package/dist/{lead-6qphc124.js → lead-hpk5r3b1.js} +47 -47
- package/dist/{llama-cpp-classify-tgj35k8d.js → llama-cpp-classify-6erb7wwv.js} +3 -1
- package/dist/{maintenance-0teem1bb.js → maintenance-nvkyp2xe.js} +9 -8
- package/dist/{mistral-conversations-8445ddjn.js → mistral-conversations-059y51zt.js} +4 -4
- package/dist/{node-9sg7ajby.js → node-qm9eb6z0.js} +1 -1
- package/dist/{oauth-refresh-sweep-sh9q25xp.js → oauth-refresh-sweep-ngskthwh.js} +10 -9
- package/dist/{onboard-54qmnqxe.js → onboard-yfnkqhxc.js} +2 -2
- package/dist/{openai-codex-responses-9ynm88zy.js → openai-codex-responses-jzxkta9b.js} +8 -7
- package/dist/{openai-completions-nhqwjmaw.js → openai-completions-663yncfq.js} +4 -4
- package/dist/openai-decisions-vj9nkpf8.js +162 -0
- package/dist/{openai-responses-xx1c4kgm.js → openai-responses-m3g43rrt.js} +5 -5
- package/dist/{opencode-adapter-wjfa2wvd.js → opencode-adapter-yjyqynzf.js} +7 -7
- package/dist/{openrouter-images-8yx3487g.js → openrouter-images-kr60n6b9.js} +1 -1
- package/dist/{otel-impl-ycqzp2a1.js → otel-impl-hhm64r6m.js} +1 -1
- package/dist/{pi-messages-vxdt8v7j.js → pi-messages-xszk613n.js} +1 -1
- package/dist/{pi-mono-adapter-e77e9zst.js → pi-mono-adapter-0eg8kw2c.js} +68 -51
- package/dist/{pricing-refresh-atnkngqs.js → pricing-refresh-35f91nx2.js} +11 -10
- package/dist/{rbac-roles-j78wdksm.js → rbac-roles-97w370qj.js} +10 -9
- package/dist/{rbac-roles-9pjphh9q.js → rbac-roles-z6sje3gy.js} +9 -8
- package/dist/{render-v2-pxjs49fx.js → render-v2-n4v66bj3.js} +10 -9
- package/dist/{runtime-v2t1r79e.js → runtime-h9ha1mq6.js} +4 -4
- package/dist/{setup-ymej0xtx.js → setup-m3hhyv5f.js} +2 -2
- package/dist/{typesafe-system-one-datfr82p.js → typesafe-system-one-e988vty7.js} +7 -5
- package/dist/{virtual-modules-wm8mxdxx.js → virtual-modules-ftr9kwe8.js} +11 -11
- package/dist/{work-objects-3nh4ymzb.js → work-objects-mxf2c842.js} +27 -27
- package/dist/{worker-qa9fkz5s.js → worker-9t07z1gq.js} +47 -47
- package/openapi.json +72 -1
- package/package.json +10 -10
- package/src/be/memory/providers/openai-embedding.ts +7 -2
- package/src/be/memory/raters/llm-summarizer.ts +7 -2
- package/src/be/modelsdev-cache.json +61 -0
- package/src/be/script-connections.ts +40 -6
- package/src/be/seed-pricing.ts +84 -53
- package/src/be/swarm-config-guard.ts +14 -0
- package/src/commands/provider-credentials.ts +28 -5
- package/src/commands/runner.ts +7 -12
- package/src/extensions/catalog.generated.json +17 -0
- package/src/heartbeat/heartbeat.ts +91 -19
- package/src/hooks/hook.ts +72 -27
- package/src/http/mcp.ts +5 -0
- package/src/http/onboarding.ts +2 -0
- package/src/http/script-connections.ts +35 -0
- package/src/providers/acp-targets.ts +10 -5
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/claude-adapter.ts +32 -0
- package/src/providers/claude-background-keepalive.ts +100 -0
- package/src/providers/claude-sdk-session.ts +6 -0
- package/src/providers/codex-adapter.ts +30 -3
- package/src/providers/codex-oauth/auth-json.ts +21 -4
- package/src/providers/codex-oauth/types.ts +2 -0
- package/src/providers/dsh-adapter.ts +11 -2
- package/src/providers/grok-adapter.ts +18 -2
- package/src/providers/opencode-adapter.ts +21 -5
- package/src/providers/pi-mono-extension.ts +56 -25
- package/src/providers/pricing-sources.md +2 -0
- package/src/scheduler/scheduler.ts +82 -13
- package/src/scripts-runtime/executors/registry.ts +1 -1
- package/src/server.ts +63 -3
- package/src/tests/acp-adapter.test.ts +152 -12
- package/src/tests/ci-unit-test-report.test.ts +73 -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/claude-default-route.test.ts +75 -0
- package/src/tests/claude-hook-output.test.ts +233 -0
- package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +4 -2
- package/src/tests/codex-adapter.test.ts +166 -26
- package/src/tests/codex-oauth-auth-timestamps.test.ts +76 -0
- package/src/tests/codex-oauth.test.ts +4 -1
- package/src/tests/connection-embedded-auth.test.ts +51 -0
- package/src/tests/context-window.test.ts +2 -2
- package/src/tests/credential-check.test.ts +51 -1
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +24 -2
- package/src/tests/enabled-tools.test.ts +269 -0
- package/src/tests/entrypoint-codex-oauth-seed.test.ts +12 -0
- package/src/tests/extensions-catalog.test.ts +1 -0
- package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
- package/src/tests/grok-adapter.test.ts +13 -0
- package/src/tests/haiku-5-5-pricing.test.ts +38 -0
- package/src/tests/heartbeat-checklist.test.ts +158 -1
- package/src/tests/onboarding.test.ts +44 -0
- package/src/tests/opencode-adapter.test.ts +66 -3
- package/src/tests/opencode-plugin.test.ts +37 -0
- package/src/tests/openrouter-attribution-call-sites.test.ts +204 -0
- package/src/tests/openrouter-base-url.test.ts +30 -39
- package/src/tests/pi-mono-extension.test.ts +161 -2
- package/src/tests/pricing-refresh.test.ts +70 -1
- package/src/tests/reasoning-effort.test.ts +3 -1
- 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-executor-registry.test.ts +18 -0
- package/src/tests/send-task-self-send.test.ts +107 -0
- package/src/tests/telemetry-trigger.test.ts +12 -1
- package/src/tests/workflow-llm.test.ts +14 -0
- package/src/tests/workflow-system-one-decision-providers.test.ts +33 -0
- package/src/tools/request-human-input.ts +7 -2
- package/src/tools/script-connections/tool.ts +35 -3
- package/src/tools/send-task.ts +9 -1
- package/src/utils/enabled-tools.ts +27 -0
- package/src/utils/internal-ai/complete-structured.ts +11 -3
- package/src/utils/openrouter-base-url.ts +23 -0
- package/src/workflows/executors/raw-llm.ts +1 -0
- package/src/workflows/executors/system-one-decision.ts +2 -0
- package/src/workflows/executors/validate.ts +1 -0
- package/src/workflows/executors/workflow-llm.ts +9 -2
- 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/agent-browser/SKILL.md +2 -0
- package/templates/skills/agent-browser/content.md +2 -0
- package/templates/skills/code-quality/content.md +1 -0
- package/templates/skills/heartbeat-runbook/content.md +1 -1
- package/templates/skills/swarm-scripts/SKILL.md +10 -0
- package/templates/skills/swarm-scripts/content.md +10 -0
- package/templates/skills/work-on-task/SKILL.md +4 -0
- package/templates/skills/work-on-task/content.md +4 -0
- package/templates/skills/workflow-iterate/content.md +9 -0
- package/dist/acp-adapter-yzfxn8vj.js +0 -23
- package/dist/cli-3dpj7atn.js +0 -11
- package/dist/cli-gvcgmx69.js +0 -183
- package/dist/{cli-jhm6hzjn.js → cli-mxvnnv5q.js} +7 -7
- package/dist/{codex-hook-d2rkvf39.js → codex-hook-15dgc4cb.js} +1 -1
package/src/be/seed-pricing.ts
CHANGED
|
@@ -183,6 +183,55 @@ function projectCostBlock(
|
|
|
183
183
|
return rows;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** A projected row plus the models.dev id it came from, before normalized ids are collapsed. */
|
|
187
|
+
interface SourcedSeedRow {
|
|
188
|
+
sourceId: string;
|
|
189
|
+
row: PricingSeedRow;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Whether `candidate` beats `current` as the source of a normalized `key`. */
|
|
193
|
+
function outranksSource(candidate: string, current: string, key: string): boolean {
|
|
194
|
+
const a = candidate.toLowerCase();
|
|
195
|
+
const b = current.toLowerCase();
|
|
196
|
+
if ((a === key) !== (b === key)) return a === key;
|
|
197
|
+
return a > b;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `normalizeModelKey` can fold several models.dev ids onto one key: amp strips
|
|
202
|
+
* OpenAI's dated snapshots, so `gpt-4o`, `gpt-4o-2024-05-13`, `gpt-4o-2024-08-06`
|
|
203
|
+
* and `gpt-4o-2024-11-20` all become `amp|gpt-4o`, at different prices. The
|
|
204
|
+
* refresh writes every changed row at one `effective_from`, so two prices for
|
|
205
|
+
* one (provider, model, tokenClass) hit the pricing primary key and roll back
|
|
206
|
+
* the whole batch.
|
|
207
|
+
*
|
|
208
|
+
* Rule: one source id wins per (provider, normalized model), and all its token
|
|
209
|
+
* classes come from it. The id that is already canonical (the undated alias)
|
|
210
|
+
* wins; failing that, the greatest id, i.e. the newest dated snapshot. A source
|
|
211
|
+
* id repeated across catalog sections keeps its first row per token class.
|
|
212
|
+
*/
|
|
213
|
+
function collapseNormalizedIds(sourced: SourcedSeedRow[]): PricingSeedRow[] {
|
|
214
|
+
const modelKey = (row: PricingSeedRow) => `${row.provider}\u0000${row.model}`;
|
|
215
|
+
const winners = new Map<string, string>();
|
|
216
|
+
for (const { sourceId, row } of sourced) {
|
|
217
|
+
const current = winners.get(modelKey(row));
|
|
218
|
+
if (current === undefined || outranksSource(sourceId, current, row.model)) {
|
|
219
|
+
winners.set(modelKey(row), sourceId);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const seen = new Set<string>();
|
|
224
|
+
const rows: PricingSeedRow[] = [];
|
|
225
|
+
for (const { sourceId, row } of sourced) {
|
|
226
|
+
if (winners.get(modelKey(row)) !== sourceId) continue;
|
|
227
|
+
const rowKey = `${modelKey(row)}\u0000${row.tokenClass}`;
|
|
228
|
+
if (seen.has(rowKey)) continue;
|
|
229
|
+
seen.add(rowKey);
|
|
230
|
+
rows.push(row);
|
|
231
|
+
}
|
|
232
|
+
return rows;
|
|
233
|
+
}
|
|
234
|
+
|
|
186
235
|
/**
|
|
187
236
|
* Build the full set of seed rows from a loaded models.dev cache.
|
|
188
237
|
*
|
|
@@ -191,7 +240,10 @@ function projectCostBlock(
|
|
|
191
240
|
* explicit and auditable.
|
|
192
241
|
*/
|
|
193
242
|
export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
|
|
194
|
-
const
|
|
243
|
+
const sourced: SourcedSeedRow[] = [];
|
|
244
|
+
const add = (sourceId: string, projected: PricingSeedRow[]) => {
|
|
245
|
+
for (const row of projected) sourced.push({ sourceId, row });
|
|
246
|
+
};
|
|
195
247
|
|
|
196
248
|
// ---- Anthropic / claude family ----------------------------------------
|
|
197
249
|
// The 'claude' provider (local-CLI adapter) reports the model id as the
|
|
@@ -202,9 +254,7 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
202
254
|
for (const [id, model] of Object.entries(anthropic)) {
|
|
203
255
|
if (!model?.cost) continue;
|
|
204
256
|
for (const provider of ["claude", "claude-managed"] as const) {
|
|
205
|
-
|
|
206
|
-
rows.push(row);
|
|
207
|
-
}
|
|
257
|
+
add(id, projectCostBlock(provider, id, model.cost));
|
|
208
258
|
}
|
|
209
259
|
}
|
|
210
260
|
// Anthropic shortnames (opus/sonnet/haiku) → resolve to the current default.
|
|
@@ -214,9 +264,7 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
214
264
|
(cache.anthropic ? ANTHROPIC_SHORTNAME_PRICING_FALLBACKS[fullId] : undefined);
|
|
215
265
|
if (!cost) continue;
|
|
216
266
|
for (const provider of ["claude", "claude-managed"] as const) {
|
|
217
|
-
|
|
218
|
-
rows.push(row);
|
|
219
|
-
}
|
|
267
|
+
add(shortname, projectCostBlock(provider, shortname, cost));
|
|
220
268
|
}
|
|
221
269
|
}
|
|
222
270
|
// Pi-mono uses anthropic models via OpenRouter mirrors; project those too.
|
|
@@ -225,9 +273,7 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
225
273
|
anthropic[fullId]?.cost ??
|
|
226
274
|
(cache.anthropic ? ANTHROPIC_SHORTNAME_PRICING_FALLBACKS[fullId] : undefined);
|
|
227
275
|
if (!cost) continue;
|
|
228
|
-
|
|
229
|
-
rows.push(row);
|
|
230
|
-
}
|
|
276
|
+
add(shortname, projectCostBlock("pi", shortname, cost, { anthropicBilled: true }));
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
// ---- Amp (Anthropic, OpenAI, Google and Fireworks models by id) ---------
|
|
@@ -237,9 +283,7 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
237
283
|
for (const section of ["anthropic", "openai", "google", "fireworks-ai"]) {
|
|
238
284
|
for (const [id, model] of Object.entries(cache[section]?.models ?? {})) {
|
|
239
285
|
if (!model?.cost) continue;
|
|
240
|
-
|
|
241
|
-
rows.push(row);
|
|
242
|
-
}
|
|
286
|
+
add(id, projectCostBlock("amp", id, model.cost));
|
|
243
287
|
}
|
|
244
288
|
}
|
|
245
289
|
|
|
@@ -247,16 +291,12 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
247
291
|
const openai = cache.openai?.models ?? {};
|
|
248
292
|
for (const [id, model] of Object.entries(openai)) {
|
|
249
293
|
if (!model?.cost) continue;
|
|
250
|
-
|
|
251
|
-
rows.push(row);
|
|
252
|
-
}
|
|
294
|
+
add(id, projectCostBlock("codex", id, model.cost));
|
|
253
295
|
// Phase 2 fix — pi-mono can route to openai models through the
|
|
254
296
|
// github-copilot proxy (`github-copilot/gpt-5.4`). The lookup helper
|
|
255
297
|
// strips the prefix, so we seed the bare id under `pi` too. Without this
|
|
256
298
|
// every gh-copilot-backed pi run fell through to `costSource='unpriced'`.
|
|
257
|
-
|
|
258
|
-
rows.push(row);
|
|
259
|
-
}
|
|
299
|
+
add(id, projectCostBlock("pi", id, model.cost));
|
|
260
300
|
}
|
|
261
301
|
|
|
262
302
|
// ---- Cursor (bare vendor ids through Cursor's hosted inference) --------
|
|
@@ -267,27 +307,21 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
267
307
|
for (const vendor of ["openai", "anthropic", "google", "xai"] as const) {
|
|
268
308
|
for (const [id, model] of Object.entries(cache[vendor]?.models ?? {})) {
|
|
269
309
|
if (!model?.cost) continue;
|
|
270
|
-
|
|
271
|
-
rows.push(row);
|
|
272
|
-
}
|
|
310
|
+
add(id, projectCostBlock("cursor", id, model.cost, { anthropicBilled: false }));
|
|
273
311
|
}
|
|
274
312
|
}
|
|
275
313
|
|
|
276
314
|
// ---- Grok CLI (bare xAI ids, billed at xAI's API rates via XAI_API_KEY) --
|
|
277
315
|
for (const [id, model] of Object.entries(cache.xai?.models ?? {})) {
|
|
278
316
|
if (!model?.cost) continue;
|
|
279
|
-
|
|
280
|
-
rows.push(row);
|
|
281
|
-
}
|
|
317
|
+
add(id, projectCostBlock("grok", id, model.cost, { anthropicBilled: false }));
|
|
282
318
|
}
|
|
283
319
|
|
|
284
320
|
// ---- DeepSeek direct API (dsh with DEEPSEEK_API_KEY, bare ids) ---------
|
|
285
321
|
const deepseek = cache.deepseek?.models ?? {};
|
|
286
322
|
for (const [id, model] of Object.entries(deepseek)) {
|
|
287
323
|
if (!model?.cost) continue;
|
|
288
|
-
|
|
289
|
-
rows.push(row);
|
|
290
|
-
}
|
|
324
|
+
add(id, projectCostBlock("dsh", id, model.cost));
|
|
291
325
|
}
|
|
292
326
|
|
|
293
327
|
// ---- OpenRouter passthrough (covers gemini + every opencode-routed model)
|
|
@@ -295,48 +329,45 @@ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[]
|
|
|
295
329
|
for (const [id, model] of Object.entries(openrouter)) {
|
|
296
330
|
if (!model?.cost) continue;
|
|
297
331
|
// opencode routes whatever model the user picks; we project them all.
|
|
298
|
-
|
|
299
|
-
rows.push(row);
|
|
300
|
-
}
|
|
332
|
+
add(id, projectCostBlock("opencode", id, model.cost));
|
|
301
333
|
// pi-mono also routes via OpenRouter when only OPENROUTER_API_KEY is set
|
|
302
334
|
// (see src/providers/pi-mono-adapter.ts). Without this projection, pi runs
|
|
303
335
|
// against non-anthropic models (e.g. deepseek/deepseek-v4-flash) fall
|
|
304
336
|
// through to costSource='unpriced' even though the model is in the
|
|
305
337
|
// models.dev snapshot.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
338
|
+
add(
|
|
339
|
+
id,
|
|
340
|
+
projectCostBlock("pi", id, model.cost, {
|
|
341
|
+
anthropicBilled: id.startsWith("anthropic/"),
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
311
344
|
// dsh routes `openrouter/<id>` models (all four tier defaults) through
|
|
312
345
|
// OpenRouter, so it bills at the same rates.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
346
|
+
add(
|
|
347
|
+
id,
|
|
348
|
+
projectCostBlock("dsh", id, model.cost, {
|
|
349
|
+
anthropicBilled: id.startsWith("anthropic/"),
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
318
352
|
// grok runs `openrouter/<id>` models as OpenAI-compatible BYOK models.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
353
|
+
add(
|
|
354
|
+
id,
|
|
355
|
+
projectCostBlock("grok", id, model.cost, {
|
|
356
|
+
anthropicBilled: id.startsWith("anthropic/"),
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
324
359
|
// Gemini specifically: also project under the 'gemini' provider so
|
|
325
360
|
// internal-ai callers that tag with provider='gemini' find a hit.
|
|
326
361
|
if (id.startsWith("google/")) {
|
|
327
362
|
const geminiKey = id.replace(/^google\//, "");
|
|
328
|
-
|
|
329
|
-
rows.push(row);
|
|
330
|
-
}
|
|
363
|
+
add(geminiKey, projectCostBlock("gemini", geminiKey, model.cost));
|
|
331
364
|
// Also store under the full openrouter id so the same row resolves
|
|
332
365
|
// whether the caller passes "google/..." or the stripped name.
|
|
333
|
-
|
|
334
|
-
rows.push(row);
|
|
335
|
-
}
|
|
366
|
+
add(id, projectCostBlock("gemini", id, model.cost));
|
|
336
367
|
}
|
|
337
368
|
}
|
|
338
369
|
|
|
339
|
-
return
|
|
370
|
+
return collapseNormalizedIds(sourced);
|
|
340
371
|
}
|
|
341
372
|
|
|
342
373
|
/** Build the exact provider rows inserted by the boot-time pricing seeder. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeSlackReactionShortcode } from "../slack/reaction-shortcode";
|
|
2
2
|
import { ProviderNameSchema } from "../types";
|
|
3
3
|
import { API_DRAIN_MAX_MS_LIMIT } from "../utils/api-drain";
|
|
4
|
+
import { parseEnabledTools } from "../utils/enabled-tools";
|
|
4
5
|
import { parseTaskToolManifest } from "../utils/task-tool-manifest";
|
|
5
6
|
import { isTierConfigKey, validateTierConfigValue } from "./model-tier-keys";
|
|
6
7
|
|
|
@@ -212,6 +213,17 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
212
213
|
return "Invalid TASK_TOOL_MANIFESTS (expected JSON with taskTypes and/or schedules maps, each selecting at most 16 known swarm tools)";
|
|
213
214
|
}
|
|
214
215
|
},
|
|
216
|
+
// MCP tool allowlist: comma-separated names or a JSON array of strings.
|
|
217
|
+
// Unknown names are accepted here and warned about at session init.
|
|
218
|
+
SWARM_ENABLED_TOOLS: (value) => {
|
|
219
|
+
try {
|
|
220
|
+
if (typeof value !== "string") throw new Error("Expected string");
|
|
221
|
+
parseEnabledTools(value);
|
|
222
|
+
return null;
|
|
223
|
+
} catch {
|
|
224
|
+
return "Invalid SWARM_ENABLED_TOOLS (expected comma-separated tool names or a JSON array of strings)";
|
|
225
|
+
}
|
|
226
|
+
},
|
|
215
227
|
FEEDBACK_ENDPOINT: (value) => {
|
|
216
228
|
if (typeof value !== "string") {
|
|
217
229
|
return "Invalid FEEDBACK_ENDPOINT (must use HTTPS, or HTTP on a loopback host)";
|
|
@@ -251,6 +263,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
251
263
|
return `Invalid HARNESS_PROVIDER value (must be one of: ${ProviderNameSchema.options.join(", ")})`;
|
|
252
264
|
},
|
|
253
265
|
...enumValidator("CLAUDE_TRANSPORT", ["cli", "sdk"]),
|
|
266
|
+
...enumValidator("SCRIPT_EXECUTOR", ["native", "quickjs"]),
|
|
254
267
|
// fail: worker fails a task fast when every key has exhausted the task
|
|
255
268
|
// model's weekly window (Fable/Opus/Sonnet). fallback: legacy random pick.
|
|
256
269
|
...enumValidator("MODEL_WINDOW_EXHAUSTED_POLICY", ["fail", "fallback"]),
|
|
@@ -304,6 +317,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
304
317
|
"PI_TOOL_DEFERRAL",
|
|
305
318
|
"PI_CODEMODE",
|
|
306
319
|
"PI_CODEMODE_MODELS",
|
|
320
|
+
"OPENROUTER_APP_ATTRIBUTION",
|
|
307
321
|
"CURSOR_NATIVE_SYSTEM_PROMPT",
|
|
308
322
|
"SLACK_DISABLE",
|
|
309
323
|
"SLACK_RENDER_V2",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { deriveDefaultRoute, validateRoute } from "@desplega/model-routing";
|
|
22
|
+
import { ACP_TARGET_IDS, isAcpTarget } from "../providers/acp-target-catalog";
|
|
22
23
|
import { checkAmpCredentials, liveTestAmpCredentials } from "../providers/amp-adapter";
|
|
23
24
|
import { checkClaudeCredentials } from "../providers/claude-adapter";
|
|
24
25
|
import { checkClaudeManagedCredentials } from "../providers/claude-managed-adapter";
|
|
@@ -38,7 +39,10 @@ import type {
|
|
|
38
39
|
ReasoningEffort,
|
|
39
40
|
} from "../types";
|
|
40
41
|
import { CLAUDE_CREDENTIALS_HINT } from "../utils/credentials";
|
|
41
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
getOpenRouterAttributionHeaders,
|
|
44
|
+
getOpenRouterBaseUrl,
|
|
45
|
+
} from "../utils/openrouter-base-url";
|
|
42
46
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
43
47
|
|
|
44
48
|
export type SupportedProvider =
|
|
@@ -114,7 +118,7 @@ export const REQUIRED_CRED_VARS_BY_PROVIDER: Record<SupportedProvider, readonly
|
|
|
114
118
|
devin: ["DEVIN_API_KEY", "DEVIN_ORG_ID"],
|
|
115
119
|
opencode: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
|
116
120
|
pi: ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"],
|
|
117
|
-
// The ACP target
|
|
121
|
+
// The ACP target owns its auth; custom targets still need a command.
|
|
118
122
|
acp: [],
|
|
119
123
|
dsh: ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY"],
|
|
120
124
|
amp: ["AMP_API_KEY"],
|
|
@@ -142,7 +146,25 @@ export const CREDENTIAL_PROVIDER_CHECKERS: Record<SupportedProvider, CredentialC
|
|
|
142
146
|
const { checkPiMonoCredentials } = await import("../providers/pi-mono-adapter");
|
|
143
147
|
return checkPiMonoCredentials(env, opts);
|
|
144
148
|
},
|
|
145
|
-
acp: () =>
|
|
149
|
+
acp: (env) => {
|
|
150
|
+
const target = env.ACP_TARGET ?? "custom";
|
|
151
|
+
if (!isAcpTarget(target)) {
|
|
152
|
+
return {
|
|
153
|
+
ready: false,
|
|
154
|
+
missing: ["ACP_TARGET"],
|
|
155
|
+
hint: `Set ACP_TARGET to one of: ${ACP_TARGET_IDS.join(", ")}.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const command = env.ACP_TARGET_COMMAND ?? env.ACP_COMMAND;
|
|
159
|
+
if (target === "custom" && !command?.trim()) {
|
|
160
|
+
return {
|
|
161
|
+
ready: false,
|
|
162
|
+
missing: ["ACP_TARGET_COMMAND"],
|
|
163
|
+
hint: "Set ACP_TARGET_COMMAND (or ACP_COMMAND) to an ACP-compatible executable, or select a preset with ACP_TARGET.",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ready: true, missing: [], satisfiedBy: "sdk-delegated" };
|
|
167
|
+
},
|
|
146
168
|
};
|
|
147
169
|
|
|
148
170
|
/**
|
|
@@ -288,9 +310,10 @@ async function checkXaiApiKey(apiKey: string): Promise<LiveValidationResult> {
|
|
|
288
310
|
}
|
|
289
311
|
|
|
290
312
|
async function checkOpenRouter(apiKey: string): Promise<LiveValidationResult> {
|
|
291
|
-
const
|
|
313
|
+
const url = `${getOpenRouterBaseUrl()}/models`;
|
|
314
|
+
const r = await timedFetch(url, {
|
|
292
315
|
method: "GET",
|
|
293
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
316
|
+
headers: { ...getOpenRouterAttributionHeaders(url), Authorization: `Bearer ${apiKey}` },
|
|
294
317
|
});
|
|
295
318
|
if (r.ok) return { ok: true, latency_ms: r.latency_ms };
|
|
296
319
|
return {
|
package/src/commands/runner.ts
CHANGED
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
import { type RelevantMemory, renderMemoriesPrompt } from "../prompts/memories.ts";
|
|
22
22
|
import { configureHttpResolver, resolveTemplateAsync } from "../prompts/resolver.ts";
|
|
23
23
|
import { renderSteeringDelivery } from "../prompts/steering-delivery.ts";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
authJsonToCredentialSelection,
|
|
26
|
+
credentialsToAuthJson,
|
|
27
|
+
} from "../providers/codex-oauth/auth-json.js";
|
|
25
28
|
import { materializeCodexAuthJson } from "../providers/codex-oauth/auth-json-fs.js";
|
|
26
29
|
import {
|
|
27
30
|
CODEX_OAUTH_POOL_SLOTS_ENV,
|
|
@@ -1937,17 +1940,9 @@ export async function resolveCodexOAuthCredentialInfo(
|
|
|
1937
1940
|
await materializeCodexAuthJson(selectedSlot, slotEntry.creds, {
|
|
1938
1941
|
includeRefreshToken: false,
|
|
1939
1942
|
});
|
|
1940
|
-
const authJson = {
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
tokens: {
|
|
1944
|
-
id_token: slotEntry.creds.access,
|
|
1945
|
-
access_token: slotEntry.creds.access,
|
|
1946
|
-
refresh_token: slotEntry.creds.refresh,
|
|
1947
|
-
account_id: slotEntry.creds.accountId,
|
|
1948
|
-
},
|
|
1949
|
-
last_refresh: new Date(slotEntry.creds.expires).toISOString(),
|
|
1950
|
-
};
|
|
1943
|
+
const authJson = credentialsToAuthJson(slotEntry.creds, {
|
|
1944
|
+
includeRefreshToken: false,
|
|
1945
|
+
});
|
|
1951
1946
|
const sel = {
|
|
1952
1947
|
...authJsonToCredentialSelection(authJson, selectedSlot, slots.length),
|
|
1953
1948
|
authFailureFence,
|
|
@@ -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": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
1
2
|
import {
|
|
2
3
|
autoCancelStaleApprovalRequests,
|
|
3
4
|
timeoutExpiredApprovalRequests,
|
|
@@ -247,6 +248,15 @@ function isHeartbeatChecklistDisabled(): boolean {
|
|
|
247
248
|
return isEnvFlagEnabled("HEARTBEAT_CHECKLIST_DISABLE", false);
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Whether to create the boot-triage task even when the boot is clean (no
|
|
253
|
+
* interrupted, stalled, orphaned, or resume-less superseded work). Off by
|
|
254
|
+
* default: a clean boot only logs a one-line note.
|
|
255
|
+
*/
|
|
256
|
+
function isBootTriageAlways(): boolean {
|
|
257
|
+
return isEnvFlagEnabled("HEARTBEAT_BOOT_TRIAGE_ALWAYS", false);
|
|
258
|
+
}
|
|
259
|
+
|
|
250
260
|
// ============================================================================
|
|
251
261
|
// Types
|
|
252
262
|
// ============================================================================
|
|
@@ -1576,24 +1586,7 @@ export async function gatherSystemStatus(options?: { isBootTriage?: boolean }):
|
|
|
1576
1586
|
}
|
|
1577
1587
|
|
|
1578
1588
|
// Orphaned pending/offered tasks (assigned to workers with no active session)
|
|
1579
|
-
const orphanedTasks
|
|
1580
|
-
|
|
1581
|
-
for (const status of ["pending", "offered"] as const) {
|
|
1582
|
-
const tasks = await getTasksByStatus(status);
|
|
1583
|
-
|
|
1584
|
-
for (const task of tasks) {
|
|
1585
|
-
// 'pending' tasks carry their holder in agentId; 'offered' tasks have
|
|
1586
|
-
// not been accepted yet, so agentId is still NULL and the offeree
|
|
1587
|
-
// lives in offeredTo instead (#1190) — `!task.agentId` alone used to
|
|
1588
|
-
// skip every offered row here, hiding this whole class of orphan.
|
|
1589
|
-
const holderId = status === "offered" ? task.offeredTo : task.agentId;
|
|
1590
|
-
if (!holderId) continue;
|
|
1591
|
-
const agent = agents.find((a) => a.id === holderId);
|
|
1592
|
-
if (!agent || agent.status === "offline") {
|
|
1593
|
-
orphanedTasks.push(task);
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1589
|
+
const orphanedTasks = await getOrphanedPendingOrOfferedTasks(agents);
|
|
1597
1590
|
|
|
1598
1591
|
if (orphanedTasks.length > 0) {
|
|
1599
1592
|
sections.push("");
|
|
@@ -1617,6 +1610,60 @@ export async function gatherSystemStatus(options?: { isBootTriage?: boolean }):
|
|
|
1617
1610
|
return sections.join("\n");
|
|
1618
1611
|
}
|
|
1619
1612
|
|
|
1613
|
+
/** Pending/offered tasks whose holder is offline or no longer registered. */
|
|
1614
|
+
async function getOrphanedPendingOrOfferedTasks(
|
|
1615
|
+
agents: Awaited<ReturnType<typeof getAllAgents>>,
|
|
1616
|
+
): Promise<AgentTask[]> {
|
|
1617
|
+
const orphanedTasks: AgentTask[] = [];
|
|
1618
|
+
|
|
1619
|
+
for (const status of ["pending", "offered"] as const) {
|
|
1620
|
+
const tasks = await getTasksByStatus(status);
|
|
1621
|
+
|
|
1622
|
+
for (const task of tasks) {
|
|
1623
|
+
// 'pending' tasks carry their holder in agentId; 'offered' tasks have
|
|
1624
|
+
// not been accepted yet, so agentId is still NULL and the offeree
|
|
1625
|
+
// lives in offeredTo instead (#1190) — `!task.agentId` alone used to
|
|
1626
|
+
// skip every offered row here, hiding this whole class of orphan.
|
|
1627
|
+
const holderId = status === "offered" ? task.offeredTo : task.agentId;
|
|
1628
|
+
if (!holderId) continue;
|
|
1629
|
+
const agent = agents.find((a) => a.id === holderId);
|
|
1630
|
+
if (!agent || agent.status === "offline") {
|
|
1631
|
+
orphanedTasks.push(task);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
return orphanedTasks;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
export interface BootTriageFindings {
|
|
1640
|
+
rebootInterrupted: number;
|
|
1641
|
+
stalled: number;
|
|
1642
|
+
orphaned: number;
|
|
1643
|
+
supersededWithoutResume: number;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Count the boot signals the Lead would have to act on. All zero means a
|
|
1648
|
+
* clean boot: nothing was interrupted, stuck, orphaned, or left without a
|
|
1649
|
+
* resume, so a Lead task would only echo the deploy.
|
|
1650
|
+
*/
|
|
1651
|
+
export async function getBootTriageFindings(): Promise<BootTriageFindings> {
|
|
1652
|
+
const now = Date.now();
|
|
1653
|
+
const agents = await getAllAgents();
|
|
1654
|
+
return {
|
|
1655
|
+
rebootInterrupted: getRebootAffectedTasks().length,
|
|
1656
|
+
stalled: (await getStalledInProgressTasks(stallThresholdMinutes())).length,
|
|
1657
|
+
orphaned: (await getOrphanedPendingOrOfferedTasks(agents)).length,
|
|
1658
|
+
supersededWithoutResume: (
|
|
1659
|
+
await getSupersededTasksWithoutResume(
|
|
1660
|
+
new Date(now - ORPHAN_SUPERSEDE_MIN_AGE_MS).toISOString(),
|
|
1661
|
+
new Date(now - ORPHAN_SUPERSEDE_MAX_AGE_MS).toISOString(),
|
|
1662
|
+
)
|
|
1663
|
+
).length,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1620
1667
|
/**
|
|
1621
1668
|
* Check HEARTBEAT.md content and create a checklist task for the lead if needed.
|
|
1622
1669
|
*/
|
|
@@ -1858,6 +1905,8 @@ export function stopHeartbeat(): void {
|
|
|
1858
1905
|
/**
|
|
1859
1906
|
* Create a one-off boot triage task for the lead after a server restart.
|
|
1860
1907
|
* Uses the same HEARTBEAT.md content but with reboot-specific context prepended.
|
|
1908
|
+
* Skipped on a clean boot (see getBootTriageFindings) unless
|
|
1909
|
+
* HEARTBEAT_BOOT_TRIAGE_ALWAYS is set.
|
|
1861
1910
|
*/
|
|
1862
1911
|
export async function createBootTriageTask(): Promise<void> {
|
|
1863
1912
|
const lead = await getLeadAgent();
|
|
@@ -1876,7 +1925,30 @@ export async function createBootTriageTask(): Promise<void> {
|
|
|
1876
1925
|
);
|
|
1877
1926
|
if (existing) return;
|
|
1878
1927
|
|
|
1879
|
-
|
|
1928
|
+
if (!isBootTriageAlways()) {
|
|
1929
|
+
// A failed read falls through to the Lead task: an unknown boot is not a
|
|
1930
|
+
// clean one.
|
|
1931
|
+
const findings = await getBootTriageFindings().catch((err) => {
|
|
1932
|
+
console.error(
|
|
1933
|
+
"[Heartbeat] Boot triage findings failed, creating Lead task:",
|
|
1934
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
1935
|
+
);
|
|
1936
|
+
return null;
|
|
1937
|
+
});
|
|
1938
|
+
if (findings && Object.values(findings).every((count) => count === 0)) {
|
|
1939
|
+
console.log(
|
|
1940
|
+
`[Heartbeat] Clean boot (v${pkg.version}): 0 interrupted, stalled, orphaned, or resume-less superseded tasks; no boot-triage task for lead ${lead.name}`,
|
|
1941
|
+
);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// A reader that failed above can fail again here; the task must still land.
|
|
1947
|
+
const systemStatus = await gatherSystemStatus({ isBootTriage: true }).catch((err) => {
|
|
1948
|
+
const reason = scrubSecrets(err instanceof Error ? err.message : String(err));
|
|
1949
|
+
console.error("[Heartbeat] Boot triage status gathering failed:", reason);
|
|
1950
|
+
return `## System status unavailable\n\nStatus gathering failed after this boot (${reason}). Investigate the task, agent, and DB state directly.`;
|
|
1951
|
+
});
|
|
1880
1952
|
|
|
1881
1953
|
const result = resolveTemplate("heartbeat.boot-triage", {
|
|
1882
1954
|
system_status: systemStatus,
|