@desplega.ai/agent-swarm 2.4.4 → 2.5.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/README.md +4 -2
- package/dist/{acp-adapter-thq04adh.js → acp-adapter-11gp67nr.js} +8 -6
- package/dist/{actions-j6ywh6x5.js → actions-8p2kze8j.js} +24 -22
- package/dist/{amp-adapter-k8b5xmj8.js → amp-adapter-z4qvhacj.js} +2 -2
- package/dist/{anthropic-messages-g6pqmqk4.js → anthropic-messages-txs190dd.js} +17 -20
- package/dist/{app-rb7vc02s.js → app-0qp0a2gk.js} +3 -3
- package/dist/{assistant-ftta1zjf.js → assistant-jedd3ktm.js} +27 -25
- package/dist/{azure-openai-responses-9dbmj346.js → azure-openai-responses-9kfwhmn7.js} +18 -13
- package/dist/{boot-reembed-xns7h5h9.js → boot-reembed-4xwq5tg0.js} +1 -1
- package/dist/{boot-reembed-srp6v6af.js → boot-reembed-n4wq5dd2.js} +1 -1
- package/dist/{boot-scrub-sweep-3qqrh2mz.js → boot-scrub-sweep-69h90xcq.js} +4 -4
- package/dist/{boot-seeds-9ks8hce7.js → boot-seeds-3vn0m9sg.js} +4 -4
- package/dist/{claude-adapter-k5ksyres.js → claude-adapter-221mvad5.js} +8 -6
- package/dist/{claude-managed-adapter-h2y889ge.js → claude-managed-adapter-gt6pa35d.js} +2 -2
- package/dist/{claude-managed-setup-n5a5eqks.js → claude-managed-setup-c3hp36ph.js} +4 -4
- package/dist/{claude-sdk-session-2j3j4kwn.js → claude-sdk-session-8thjsk5e.js} +9729 -9628
- package/dist/{cli-b9fcxxev.js → cli-007cnhm1.js} +50 -4
- package/dist/{cli-j4rqdf2n.js → cli-03vpq45j.js} +2 -2
- package/dist/{cli-amyhm3gj.js → cli-0y6f0ckq.js} +1 -1
- package/dist/{cli-4dg55wsk.js → cli-0z3vjrpm.js} +1 -1
- package/dist/{cli-pccfmtb5.js → cli-179xcyky.js} +11 -11
- package/dist/{cli-3r8samv3.js → cli-1f0gp9vz.js} +3 -2
- package/dist/{cli-hj3debgw.js → cli-1pr672hz.js} +3 -3
- package/dist/{cli-0bqteszy.js → cli-2nf3b08t.js} +328 -25
- package/dist/{cli-t7cyef5g.js → cli-36j4jnk3.js} +4 -4
- package/dist/{cli-er83cjbn.js → cli-3qv8naq8.js} +527 -231
- package/dist/cli-3zwye9sn.js +1193 -0
- package/dist/{cli-60cgh1rv.js → cli-459fk97x.js} +7 -7
- package/dist/{cli-xj0re5tm.js → cli-4w24f7z3.js} +1 -1
- package/dist/{cli-v3yr2gbg.js → cli-5kzk3472.js} +2198 -968
- package/dist/{cli-h7gc0b4r.js → cli-5m84zd2k.js} +55 -18
- package/dist/{cli-w6nadkh4.js → cli-5rvg1ddk.js} +5 -5
- package/dist/{cli-dvkmp9bt.js → cli-6ctwrf1z.js} +20 -10
- package/dist/{cli-9qtjfd60.js → cli-6rg2jn2y.js} +3 -2
- package/dist/{cli-2v045xyf.js → cli-7bwtc6yx.js} +3 -3
- package/dist/{cli-3cgngyd8.js → cli-7g40g8hq.js} +35 -17
- package/dist/{cli-q6r7t299.js → cli-92mqenkc.js} +2379 -154
- package/dist/{cli-cynvc9fj.js → cli-9ce2xw98.js} +8 -8
- package/dist/{cli-gqvh8d8a.js → cli-bgdtv62r.js} +36 -22
- package/dist/{cli-wxbn3yfx.js → cli-cz5nmdxt.js} +2 -2
- package/dist/{cli-wt645v7q.js → cli-dabehxhp.js} +1 -1
- package/dist/{cli-vfha21c9.js → cli-e5bxnjps.js} +2 -2
- package/dist/{cli-p20r8w42.js → cli-fcc5qe3f.js} +763 -268
- package/dist/{cli-1ephgwnq.js → cli-fes51ya2.js} +3 -3
- package/dist/{cli-1d70crsy.js → cli-ftmq4nvk.js} +2 -2
- package/dist/{cli-qkhc5aqy.js → cli-fy1jyyym.js} +13 -13
- package/dist/{cli-s95dknxh.js → cli-g1cw7xtt.js} +1 -1
- package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
- package/dist/{cli-74c1wpm0.js → cli-gpv60278.js} +1 -1
- package/dist/{cli-pemywhfq.js → cli-ha9rmwgq.js} +2 -1
- package/dist/{cli-bnz0r3kc.js → cli-j1eghqfm.js} +5 -5
- package/dist/{cli-2zqpkvtt.js → cli-jpxsrxcb.js} +1 -1
- package/dist/{cli-ejs3m1sx.js → cli-kxpfx2xr.js} +1 -1
- package/dist/{cli-37yb7307.js → cli-mbfj11fq.js} +1 -1
- package/dist/{cli-pnp2j5zg.js → cli-mmhj8vd7.js} +2 -2
- package/dist/{cli-xf234ws8.js → cli-n4c8e3s5.js} +2 -0
- package/dist/{cli-a0kcty51.js → cli-n6kmqngw.js} +2 -2
- package/dist/{cli-te6b5wvg.js → cli-nnvjb17s.js} +3 -3
- package/dist/{cli-1dmcq2eh.js → cli-nzfnt2gs.js} +1 -1
- package/dist/{cli-625tb7aq.js → cli-p859hcc8.js} +2 -2
- package/dist/{cli-smvcw9z5.js → cli-pasyvjmm.js} +1 -1
- package/dist/cli-pkaqk57w.js +128 -0
- package/dist/{cli-8nyhf7za.js → cli-qqzmjxkf.js} +129 -105
- package/dist/{cli-zrwpctp4.js → cli-r25qpaxt.js} +161 -63
- package/dist/{cli-nc35pxb7.js → cli-rh5smt8t.js} +19 -4
- package/dist/{cli-qwkcwjdn.js → cli-rycdaj0a.js} +1 -1
- package/dist/{cli-esxerzg7.js → cli-s4sntwt4.js} +2 -2
- package/dist/{cli-b8k7epj1.js → cli-segs7hx9.js} +8 -8
- package/dist/{cli-36ckw0wx.js → cli-stxdrqs2.js} +2 -2
- package/dist/{cli-vdm20wej.js → cli-sy8f9705.js} +4 -4
- package/dist/{cli-1m17dc9d.js → cli-ta8bd7th.js} +2 -2
- package/dist/{cli-2y91wnbh.js → cli-we0ra057.js} +222 -10
- package/dist/{cli-55qmbp6z.js → cli-x21dqn18.js} +5 -5
- package/dist/{cli-dqb4n629.js → cli-x6vf4sad.js} +11 -11
- package/dist/{cli-d2mb5s8a.js → cli-x77gen2p.js} +680 -440
- package/dist/{cli-vq97g1jq.js → cli-xawhpgc9.js} +95 -195
- package/dist/{cli-82d3nkc3.js → cli-xd4eqtr2.js} +2 -2
- package/dist/{cli-9fhkkpj5.js → cli-xt768r5m.js} +2 -2
- package/dist/{cli-zrk6jxv8.js → cli-y4x8ww6s.js} +2 -2
- package/dist/{cli-xc27fwzg.js → cli-y9jvxfhk.js} +3 -3
- package/dist/{cli-44159k28.js → cli-z63a269p.js} +2 -2
- package/dist/{cli-sm8rses9.js → cli-zfdp01hn.js} +8 -5
- package/dist/cli.js +31 -29
- package/dist/{cloudflare-workers-ai-system-one-ch2wxfcp.js → cloudflare-workers-ai-system-one-43v9qkjk.js} +7 -2
- package/dist/{codex-adapter-968xwfy7.js → codex-adapter-tcg1q3jk.js} +6 -4
- package/dist/{codex-login-m819c41r.js → codex-login-rn7c5gxt.js} +1 -1
- package/dist/{codex-session-runner-ht4z99fa.js → codex-session-runner-xfx4wd9x.js} +7 -5
- package/dist/{commands-pcymaeg9.js → commands-ew83nj70.js} +2 -2
- package/dist/{controller-fpjyk46r.js → controller-gnq1k1mf.js} +4 -4
- package/dist/{controllers-connect-mwzx1fh6.js → controllers-connect-tw1dan9r.js} +26 -24
- package/dist/{cursor-adapter-cp4s2e2f.js → cursor-adapter-yq5xgfcg.js} +1 -1
- package/dist/{db-xsjg9jqq.js → db-7gbc53fd.js} +14 -2
- package/dist/{db-sgd12bt9.js → db-t86xrnes.js} +3 -3
- package/dist/{devin-adapter-zgqadvnm.js → devin-adapter-4ycy4vrf.js} +1 -1
- package/dist/{dispatcher-x0jfw2c9.js → dispatcher-64bdyhps.js} +23 -21
- package/dist/{docker-fkgtr75t.js → docker-nfy2gc8t.js} +2 -2
- package/dist/{dsh-adapter-jrq7ma7h.js → dsh-adapter-tvhc4k2d.js} +1 -1
- package/dist/{e2b-16d6ch39.js → e2b-9gkdhm3c.js} +2 -2
- package/dist/{e2b-zn0b4w5w.js → e2b-cjtf8k1z.js} +2 -2
- package/dist/{easter-egg-3d-5cgynaja.js → easter-egg-3d-e4qv8dxj.js} +1 -1
- package/dist/{event-bus-9vpk8gmq.js → event-bus-3z78ep5n.js} +1 -1
- package/dist/{execute-t4e6m3rw.js → execute-0xpz8950.js} +85 -23
- package/dist/{external-channel-8njj4h3b.js → external-channel-e9nw24zv.js} +2 -2
- package/dist/{google-generative-ai-bmq8gkf3.js → google-generative-ai-mk9vcpe1.js} +15 -15
- package/dist/{google-vertex-b1rvp52s.js → google-vertex-q580kz8k.js} +20 -20
- package/dist/{grok-adapter-kkr97v2f.js → grok-adapter-v82srhrx.js} +9 -7
- package/dist/handlers-c9ajzhbn.js +41 -0
- package/dist/{heartbeat-wd03zhn0.js → heartbeat-2vqs1319.js} +26 -24
- package/dist/{hook-xzj2ts2p.js → hook-01nk13wx.js} +7 -5
- package/dist/{hook-f0vghj42.js → hook-jzpheegg.js} +9 -7
- package/dist/{http-4hwxv62p.js → http-fc1k6ec8.js} +859 -252
- package/dist/index-07ef7nkm.js +42 -0
- package/dist/{index-g6tbb6ve.js → index-31ceyf42.js} +1 -1
- package/dist/index-7yts8n9s.js +43 -0
- package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
- package/dist/index-q3t40ryv.js +46 -0
- package/dist/{index-ww2vznkp.js → index-sepja052.js} +1 -1
- package/dist/{index-97q4xn03.js → index-zdycv3ja.js} +135 -79
- package/dist/{index-g216q6jr.js → index-zsyx51ap.js} +1 -4
- package/dist/{keepalive-hrh7xnr9.js → keepalive-5nh2rhmr.js} +4 -4
- package/dist/lead-aayq64jn.js +79 -0
- package/dist/{llama-cpp-classify-vv43gswy.js → llama-cpp-classify-ee7vmd7q.js} +4 -1
- package/dist/{maintenance-e8wnwnqx.js → maintenance-hjhzdt3k.js} +1 -1
- package/dist/{mistral-conversations-1medfwv7.js → mistral-conversations-sd0xf1tn.js} +13 -13
- package/dist/{node-3v3f08f7.js → node-jzjj7r9x.js} +10 -9
- package/dist/{oauth-refresh-sweep-zm0yj4sj.js → oauth-refresh-sweep-y3n1knmd.js} +3 -3
- package/dist/{onboard-dtwcq603.js → onboard-1zq3m7db.js} +2 -2
- package/dist/{openai-codex-responses-de9pvs4d.js → openai-codex-responses-79s8xnbh.js} +29 -23
- package/dist/{openai-completions-mtqge3nf.js → openai-completions-tdbyts0x.js} +21 -20
- package/dist/{openai-decisions-ygk4kkxd.js → openai-decisions-bqhnjyfz.js} +2 -1
- package/dist/{openai-responses-dkyng71g.js → openai-responses-dm3t5vqp.js} +26 -17
- package/dist/{opencode-adapter-x4tqzhkh.js → opencode-adapter-e56086m1.js} +7 -5
- package/dist/{openrouter-images-wvv7camj.js → openrouter-images-ssttr1y4.js} +6 -3
- package/dist/{otel-impl-w3fbhpn0.js → otel-impl-h9yyza47.js} +2 -2
- package/dist/{pi-messages-nhh1pkz2.js → pi-messages-3nf8bq1a.js} +6 -4
- package/dist/{pi-mono-adapter-mcfkn9vf.js → pi-mono-adapter-tzps8f38.js} +9 -37
- package/dist/{pricing-refresh-j38dehfs.js → pricing-refresh-nevhat8h.js} +3 -3
- package/dist/{rbac-roles-ae8yttq4.js → rbac-roles-4y6ngvzq.js} +1 -1
- package/dist/{rbac-roles-ejxx9b35.js → rbac-roles-exbg8xb6.js} +2 -2
- package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
- package/dist/{render-v2-n16n0phe.js → render-v2-cfsh13z3.js} +3 -3
- package/dist/{resume-z0fgjc38.js → resume-5s2t0dbs.js} +10 -8
- package/dist/{runtime-xe3aqy4x.js → runtime-bt5pkpm1.js} +1 -1
- package/dist/{session-access-cv3mq6mc.js → session-access-dnbyp3cn.js} +6 -4
- package/dist/{setup-mafhj3yt.js → setup-1wj1y77j.js} +1 -1
- package/dist/{typesafe-system-one-2hgpayzy.js → typesafe-system-one-bre38hvs.js} +4 -2
- package/dist/{virtual-modules-4r3zkc20.js → virtual-modules-mnfbh5mr.js} +8 -2
- package/dist/{work-objects-8b86mgjp.js → work-objects-7mg6216g.js} +26 -24
- package/dist/worker-nwb789mg.js +79 -0
- package/dist/{x-y45mthwn.js → x-pbpezr0s.js} +2 -2
- package/openapi.json +793 -6
- package/package.json +11 -11
- package/src/apps/catalog.generated.json +206 -4
- package/src/apps/page-validator.ts +13 -1
- package/src/assets/key.ts +17 -0
- package/src/azure-devops/handlers.ts +1 -1
- package/src/be/chunking.ts +42 -9
- package/src/be/db/approvals.ts +18 -0
- package/src/be/db/session-moves.ts +164 -0
- package/src/be/db/session-tree.ts +384 -0
- package/src/be/db/tasks/draft-text.ts +15 -0
- package/src/be/db/tasks/read.ts +54 -5
- package/src/be/db/workflow-runs.ts +96 -0
- package/src/be/db.ts +85 -237
- package/src/be/memory/index-content.ts +36 -17
- package/src/be/memory/providers/openai-embedding.ts +115 -61
- package/src/be/memory/providers/sqlite-store.ts +69 -23
- package/src/be/memory/reranker.ts +31 -1
- package/src/be/memory/search-candidates.ts +47 -0
- package/src/be/memory/types.ts +14 -0
- package/src/be/onboarding.ts +3 -0
- package/src/be/scripts/typecheck.ts +2 -0
- package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +106 -54
- package/src/be/seed-scripts/index.ts +2 -2
- package/src/be/skill-sync.ts +43 -2
- package/src/be/swarm-config-guard.ts +25 -1
- package/src/be/task-attachment-store.ts +30 -0
- package/src/bitbucket/api.ts +81 -0
- package/src/bitbucket/auth.ts +64 -0
- package/src/bitbucket/handlers.ts +434 -0
- package/src/bitbucket/index.ts +28 -0
- package/src/bitbucket/templates.ts +134 -0
- package/src/bitbucket/types.ts +65 -0
- package/src/commands/runner.ts +55 -3
- package/src/e2b/env.ts +1 -0
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +9 -2
- package/src/extensions/post-bridge.ts +1 -0
- package/src/fs/provider.ts +13 -1
- package/src/github/handlers.ts +6 -6
- package/src/gitlab/handlers.ts +5 -5
- package/src/heartbeat/heartbeat.ts +31 -11
- package/src/http/assets.ts +14 -2
- package/src/http/core.ts +12 -0
- package/src/http/fs.ts +54 -34
- package/src/http/index.ts +4 -0
- package/src/http/mcp-body.ts +37 -0
- package/src/http/mcp-user.ts +3 -5
- package/src/http/mcp.ts +6 -7
- package/src/http/memory.ts +26 -15
- package/src/http/onboarding.ts +1 -0
- package/src/http/page-proxy.ts +61 -5
- package/src/http/pages-public.ts +27 -44
- package/src/http/pages.ts +4 -4
- package/src/http/script-runs.ts +2 -0
- package/src/http/sessions.ts +366 -12
- package/src/http/skills.ts +48 -1
- package/src/http/tasks.ts +1 -1
- package/src/http/users.ts +13 -1
- package/src/http/webhooks.ts +141 -0
- package/src/integrations/chat-sdk/adapters.ts +24 -4
- package/src/integrations/chat-sdk/attachments.ts +90 -12
- package/src/integrations/chat-sdk/facts.ts +138 -0
- package/src/integrations/chat-sdk/inbound.ts +156 -8
- package/src/integrations/chat-sdk/limits.ts +24 -0
- package/src/integrations/chat-sdk/outbound-files.ts +186 -0
- package/src/integrations/chat-sdk/outbound.ts +272 -5
- package/src/integrations/chat-sdk/relay.ts +4 -1
- package/src/integrations/chat-sdk/router.ts +28 -2
- package/src/integrations/chat-sdk/steering.ts +9 -0
- package/src/integrations/chat-sdk/templates.ts +14 -0
- package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
- package/src/mcp-limits.ts +20 -0
- package/src/pages/browser-sdk.ts +8 -3
- package/src/pages/feedback-overlay.ts +1 -1
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +106 -119
- package/src/providers/codex-agents-md.ts +35 -229
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +5 -53
- package/src/realtime/transport.ts +1 -2
- package/src/script-workflows/workflow-ctx.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +2 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +14 -4
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
- package/src/server-user.ts +2 -0
- package/src/server.ts +2 -0
- package/src/slack/assistant.ts +2 -2
- package/src/slack/files.ts +2 -0
- package/src/slack/handlers.test.ts +158 -0
- package/src/slack/handlers.ts +23 -32
- package/src/slack/inbound-files.ts +39 -6
- package/src/slack/thread-buffer.ts +2 -1
- package/src/tasks/context-key.ts +61 -1
- package/src/tasks/task-terminal-effects.ts +21 -17
- package/src/tests/apps-card-grid.test.ts +197 -0
- package/src/tests/apps-catalog-additions.test.ts +120 -0
- package/src/tests/apps-tree.test.ts +129 -0
- package/src/tests/bitbucket-auth.test.ts +156 -0
- package/src/tests/bitbucket-handlers.test.ts +425 -0
- package/src/tests/bitbucket-webhook.test.ts +220 -0
- package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
- package/src/tests/chat-sdk-config.test.ts +11 -0
- package/src/tests/chat-sdk-facts.test.ts +23 -0
- package/src/tests/chat-sdk-inbound.test.ts +597 -4
- package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
- package/src/tests/chat-sdk-telegram-markdown.test.ts +92 -0
- package/src/tests/codex-adapter.test.ts +167 -242
- package/src/tests/config-api-only-keys.test.ts +9 -0
- package/src/tests/context-key.test.ts +50 -0
- package/src/tests/core-auth.test.ts +5 -0
- package/src/tests/cors-allowlist.test.ts +1 -1
- package/src/tests/db-task-read-contract.test.ts +7 -0
- package/src/tests/db-task-write-contract.test.ts +6 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
- package/src/tests/fixtures/bitbucket/pullrequest-comment-created-inline.json +174 -0
- package/src/tests/fixtures/bitbucket/pullrequest-comment-created.json +170 -0
- package/src/tests/fixtures/bitbucket/pullrequest-created-mention.json +132 -0
- package/src/tests/fixtures/bitbucket/pullrequest-created-reviewer.json +140 -0
- package/src/tests/fixtures/bitbucket/pullrequest-fulfilled.json +134 -0
- package/src/tests/fixtures/bitbucket/pullrequest-rejected.json +133 -0
- package/src/tests/generate-mcp-docs.test.ts +101 -0
- package/src/tests/gitlab-handlers.test.ts +3 -3
- package/src/tests/gitlab-vcs-db.test.ts +4 -4
- package/src/tests/heartbeat-checklist.test.ts +33 -0
- package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
- package/src/tests/heartbeat-jev-gate.test.ts +242 -1
- package/src/tests/integration-disable-flags.test.ts +36 -0
- package/src/tests/kv-page-proxy.test.ts +91 -5
- package/src/tests/launch-password-rejection.test.ts +2 -2
- package/src/tests/mcp-input-caps.test.ts +294 -0
- package/src/tests/mcp-tools.test.ts +1 -1
- package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
- package/src/tests/memory-embedding-input-cap.test.ts +114 -0
- package/src/tests/memory.test.ts +14 -0
- package/src/tests/onboarding.test.ts +12 -0
- package/src/tests/page-guest-session.test.ts +19 -8
- package/src/tests/page-proxy-authed.test.ts +1 -1
- package/src/tests/page-proxy.test.ts +17 -10
- package/src/tests/page-session.test.ts +102 -1
- package/src/tests/pages-authed-mode.test.ts +82 -12
- package/src/tests/pages-password-mode.test.ts +5 -5
- package/src/tests/pi-codemode.test.ts +8 -2
- package/src/tests/preload-env.test.ts +57 -0
- package/src/tests/preload.ts +21 -0
- package/src/tests/prompt-template-remaining.test.ts +38 -0
- package/src/tests/realtime-transport.test.ts +13 -7
- package/src/tests/run-root-coverage.test.ts +39 -0
- package/src/tests/runner-bitbucket-vcs.test.ts +191 -0
- package/src/tests/runner-skills-refresh.test.ts +58 -0
- package/src/tests/script-runs-http.test.ts +84 -0
- package/src/tests/scripts-http.test.ts +10 -1
- package/src/tests/secret-scrubber.test.ts +15 -0
- package/src/tests/sessions-move.test.ts +576 -0
- package/src/tests/sessions-tree.test.ts +493 -0
- package/src/tests/skill-files-http.test.ts +50 -0
- package/src/tests/skills-bundle.test.ts +99 -0
- package/src/tests/swarm-config-export-to-env.test.ts +23 -3
- package/src/tests/task-audio-transcription.test.ts +299 -0
- package/src/tests/task-completion-memory-chunking.test.ts +434 -0
- package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
- package/src/tests/transcription.test.ts +299 -0
- package/src/tests/vcs-bitbucket.test.ts +91 -0
- package/src/tests/vcs-provider-isolation.test.ts +123 -0
- package/src/tests/vcs-provider.test.ts +16 -1
- package/src/tests/vcs-tracking.test.ts +3 -3
- package/src/tests/workflow-foreach-bodies.test.ts +637 -0
- package/src/tests/workflow-foreach-concurrency.test.ts +342 -0
- package/src/tests/workflow-foreach-retry.test.ts +537 -0
- package/src/tests/workflow-foreach.test.ts +44 -23
- package/src/tests/workflow-step-claims.test.ts +6 -6
- package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
- package/src/tests/workflow-system-one-decision.test.ts +2 -1
- package/src/tests/workflow-tla-races.test.ts +2 -2
- package/src/tools/chat-access.ts +10 -0
- package/src/tools/chat-post.ts +31 -11
- package/src/tools/chat-reply.ts +32 -10
- package/src/tools/memory-search.ts +3 -1
- package/src/tools/script-delete.ts +1 -1
- package/src/tools/system-one-decide.ts +17 -4
- package/src/tools/workflows/create-workflow.ts +5 -3
- package/src/types.ts +9 -2
- package/src/utils/config-env-export.ts +4 -1
- package/src/utils/page-session.ts +133 -20
- package/src/utils/secret-scrubber.ts +4 -0
- package/src/utils/skills-refresh.ts +83 -43
- package/src/utils/transcription.ts +246 -0
- package/src/vcs/bitbucket.ts +58 -0
- package/src/vcs/index.ts +2 -0
- package/src/vcs/types.ts +2 -2
- package/src/workflows/approval-resolution.ts +4 -1
- package/src/workflows/checkpoint.ts +9 -1
- package/src/workflows/constants.ts +3 -0
- package/src/workflows/definition.ts +48 -17
- package/src/workflows/engine.ts +25 -15
- package/src/workflows/executors/foreach.ts +45 -99
- package/src/workflows/executors/system-one-decision.ts +28 -7
- package/src/workflows/executors/system-one-providers.ts +39 -4
- package/src/workflows/foreach-body.ts +146 -0
- package/src/workflows/foreach-driver.ts +552 -0
- package/src/workflows/foreach-join.ts +206 -7
- package/src/workflows/node-timeout.ts +21 -0
- package/src/workflows/recovery.ts +68 -3
- package/src/workflows/resume.ts +221 -43
- package/src/workflows/retry-poller.ts +39 -24
- package/src/workflows/step-input.ts +59 -0
- package/src/workflows/task-step-routing.ts +114 -56
- package/templates/extensions/slack-reply-gate/README.md +24 -22
- package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
- package/templates/extensions/slack-reply-gate/manifest.json +2 -2
- package/templates/skills/apps/SKILL.md +28 -6
- package/templates/skills/apps/content.md +28 -6
- package/templates/skills/chat-sdk/content.md +6 -2
- package/templates/skills/code-quality/content.md +2 -1
- package/templates/skills/heartbeat-runbook/content.md +5 -5
- package/templates/skills/pages/content.md +6 -4
- package/templates/skills/slack-interaction/content.md +5 -0
- package/templates/skills/swarm-scripts/SKILL.md +2 -2
- package/templates/skills/swarm-scripts/content.md +2 -2
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/dist/cli-51y09c6p.js +0 -2657
- package/dist/cli-yw91rnmw.js +0 -13
- package/dist/handlers-0pvee9nw.js +0 -39
- package/dist/index-gdh8jrdm.js +0 -43
- package/dist/index-hg0x2cdv.js +0 -40
- package/dist/index-thm0y6a8.js +0 -41
- package/dist/lead-ee8vazz2.js +0 -77
- package/dist/worker-16s75hm5.js +0 -77
package/src/commands/runner.ts
CHANGED
|
@@ -108,6 +108,7 @@ import {
|
|
|
108
108
|
isAzureDevOpsUrl,
|
|
109
109
|
parseAzureDevOpsRepoUrl,
|
|
110
110
|
} from "../vcs/azure-devops.ts";
|
|
111
|
+
import { bitbucketFullName, isBitbucketUrl, parseBitbucketRepoUrl } from "../vcs/bitbucket.ts";
|
|
111
112
|
import { detectVcsProvider, type VcsProvider } from "../vcs/index.ts";
|
|
112
113
|
import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
|
|
113
114
|
import { buildAttachmentsSection } from "./attachments-section.ts";
|
|
@@ -311,6 +312,7 @@ const CONTINUATION_TASK_TYPES = new Set([
|
|
|
311
312
|
"gitlab-comment",
|
|
312
313
|
"gitlab-ci",
|
|
313
314
|
"azure-devops-comment",
|
|
315
|
+
"bitbucket-comment",
|
|
314
316
|
]);
|
|
315
317
|
|
|
316
318
|
/**
|
|
@@ -2946,11 +2948,51 @@ const vcsDetectedTasks = new Set<string>();
|
|
|
2946
2948
|
const vcsCheckTimestamps = new Map<string, number>();
|
|
2947
2949
|
const VCS_CHECK_INTERVAL = 60_000; // 60 seconds
|
|
2948
2950
|
|
|
2951
|
+
/**
|
|
2952
|
+
* Find the open Bitbucket Cloud pull request whose source branch is `branch`.
|
|
2953
|
+
* There is no Bitbucket CLI in the worker image, so this calls the REST API
|
|
2954
|
+
* with `BITBUCKET_TOKEN` as a Bearer token. Returns null when the token is
|
|
2955
|
+
* unset, the remote is not a Bitbucket Cloud repo, the API answers non-2xx,
|
|
2956
|
+
* no PR matches, or the request fails. Never throws.
|
|
2957
|
+
*/
|
|
2958
|
+
export async function findBitbucketOpenPullRequest(
|
|
2959
|
+
remoteUrl: string,
|
|
2960
|
+
branch: string,
|
|
2961
|
+
opts: { token?: string; fetchImpl?: typeof fetch } = {},
|
|
2962
|
+
): Promise<{ number: number; url: string } | null> {
|
|
2963
|
+
const token = opts.token ?? process.env.BITBUCKET_TOKEN;
|
|
2964
|
+
if (!token) return null;
|
|
2965
|
+
const repo = parseBitbucketRepoUrl(remoteUrl);
|
|
2966
|
+
if (!repo) return null;
|
|
2967
|
+
const query = `source.branch.name="${branch.replace(/["\\]/g, "\\$&")}" AND state="OPEN"`;
|
|
2968
|
+
const url =
|
|
2969
|
+
`https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(repo.workspace)}` +
|
|
2970
|
+
`/${encodeURIComponent(repo.repoSlug)}/pullrequests?q=${encodeURIComponent(query)}&pagelen=1`;
|
|
2971
|
+
try {
|
|
2972
|
+
const res = await (opts.fetchImpl ?? fetch)(url, {
|
|
2973
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
2974
|
+
signal: AbortSignal.timeout(15_000),
|
|
2975
|
+
});
|
|
2976
|
+
if (!res.ok) return null;
|
|
2977
|
+
const body = (await res.json()) as {
|
|
2978
|
+
values?: Array<{ id?: unknown; links?: { html?: { href?: unknown } } }>;
|
|
2979
|
+
};
|
|
2980
|
+
const pr = body.values?.[0];
|
|
2981
|
+
const href = pr?.links?.html?.href;
|
|
2982
|
+
if (typeof pr?.id !== "number" || pr.id <= 0 || typeof href !== "string" || !href) {
|
|
2983
|
+
return null;
|
|
2984
|
+
}
|
|
2985
|
+
return { number: pr.id, url: href };
|
|
2986
|
+
} catch {
|
|
2987
|
+
return null;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2949
2991
|
/**
|
|
2950
2992
|
* Detect if the task's working directory has an open PR for the current branch.
|
|
2951
2993
|
* If found, report VCS info to the API so webhook events can link back to this task.
|
|
2952
2994
|
*/
|
|
2953
|
-
async function detectVcsForTask(
|
|
2995
|
+
export async function detectVcsForTask(
|
|
2954
2996
|
apiUrl: string,
|
|
2955
2997
|
apiKey: string,
|
|
2956
2998
|
taskId: string,
|
|
@@ -2974,6 +3016,7 @@ async function detectVcsForTask(
|
|
|
2974
3016
|
let vcsProvider: VcsProvider;
|
|
2975
3017
|
let prJson: string;
|
|
2976
3018
|
let azureRepo: string | null = null;
|
|
3019
|
+
let bitbucketRepo: string | null = null;
|
|
2977
3020
|
|
|
2978
3021
|
if (isAzureDevOpsUrl(remoteUrl)) {
|
|
2979
3022
|
const parsed = parseAzureDevOpsRepoUrl(remoteUrl);
|
|
@@ -2991,6 +3034,14 @@ async function detectVcsForTask(
|
|
|
2991
3034
|
url: `${azureRepo}/pullrequest/${pr.pullRequestId}`,
|
|
2992
3035
|
})),
|
|
2993
3036
|
);
|
|
3037
|
+
} else if (isBitbucketUrl(remoteUrl)) {
|
|
3038
|
+
// Checked before the loose "github" substring match below, so a
|
|
3039
|
+
// Bitbucket repo with "github" in its slug is not sent to `gh`.
|
|
3040
|
+
bitbucketRepo = bitbucketFullName(remoteUrl);
|
|
3041
|
+
if (!bitbucketRepo) return;
|
|
3042
|
+
vcsProvider = "bitbucket";
|
|
3043
|
+
const pr = await findBitbucketOpenPullRequest(remoteUrl, branch);
|
|
3044
|
+
prJson = JSON.stringify(pr ? [pr] : []);
|
|
2994
3045
|
} else if (remoteUrl.includes("github.com") || remoteUrl.includes("github")) {
|
|
2995
3046
|
vcsProvider = "github";
|
|
2996
3047
|
prJson = (
|
|
@@ -3017,9 +3068,10 @@ async function detectVcsForTask(
|
|
|
3017
3068
|
if (!vcsNumber || !vcsUrl) return;
|
|
3018
3069
|
|
|
3019
3070
|
// 6. Extract repo from remote URL (Azure Repos keep the canonical clone URL,
|
|
3020
|
-
// matching the vcsRepo their webhook tasks carry
|
|
3071
|
+
// matching the vcsRepo their webhook tasks carry; Bitbucket uses the
|
|
3072
|
+
// `workspace/repo_slug` full name its webhook tasks carry)
|
|
3021
3073
|
const repoMatch = remoteUrl.match(/[:/]([^/]+\/[^/.]+?)(?:\.git)?$/);
|
|
3022
|
-
const vcsRepo = azureRepo ?? repoMatch?.[1];
|
|
3074
|
+
const vcsRepo = azureRepo ?? bitbucketRepo ?? repoMatch?.[1];
|
|
3023
3075
|
if (!vcsRepo) return;
|
|
3024
3076
|
|
|
3025
3077
|
// 7. Report to API
|
package/src/e2b/env.ts
CHANGED
|
@@ -106,17 +106,17 @@
|
|
|
106
106
|
"manifest": {
|
|
107
107
|
"$schema": "../manifest.schema.json",
|
|
108
108
|
"name": "slack-reply-gate",
|
|
109
|
-
"description": "Asks
|
|
110
|
-
"version": "1.
|
|
109
|
+
"description": "Asks a SystemOne decision model (Jev or Clef) whether Lead should answer a buffered Slack thread follow-up, and drops the wake-up when it should not",
|
|
110
|
+
"version": "1.3.0",
|
|
111
111
|
"runtime": "api",
|
|
112
112
|
"assets": {
|
|
113
113
|
"hooks": "hooks.ts"
|
|
114
114
|
}
|
|
115
115
|
},
|
|
116
116
|
"files": {
|
|
117
|
-
"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"
|
|
117
|
+
"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 a SystemOne decision\n * model (Jev or Clef) whether Lead should answer, and drops the wake-up when the\n * 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 decision call.\n * - Otherwise one `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 * Provider (config.provider): the decision is made server-side by the\n * `system-one-decide` tool (`ctx.swarm.systemOne_decide`), the same path as a\n * `system-one-decision` workflow node, so every SystemOne host works and no key\n * enters this hook. `auto` picks the first host with a configured credential:\n * typesafe (Jev, TYPESAFE_API_KEY), openrouter (Jev, OPENROUTER_API_KEY), then\n * cloudflare (Clef, CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_KEY, plus\n * CLOUDFLARE_ACCOUNT_ID). laya and openai are explicit choices only.\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\n .enum([\"auto\", \"typesafe\", \"openrouter\", \"cloudflare\", \"laya\", \"openai\"])\n .default(\"auto\"),\n /** Jev model on TypeSafe. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on OpenRouter. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n /** Clef model on Cloudflare Workers AI: clef or clef-flash. */\n cloudflareModel: z.string().default(\"clef\"),\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 /**\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 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// The smallest time budget system-one-decide accepts.\nconst MIN_DECIDE_TIMEOUT_MS = 1000;\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 from the API process env. The API process loads global\n * 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): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = usable(envOf(name)) ? envOf(name) : \"\";\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 ProviderId = Exclude<Cfg[\"provider\"], \"auto\">;\n\n/** Where the decision is made. No model means the provider's own default. */\nexport type DecisionTarget = { provider: ProviderId; model?: string };\n\n/** What `auto` needs per provider: every group must have one usable value. */\nconst AUTO_CREDENTIALS: ReadonlyArray<[ProviderId, string[][]]> = [\n [\"typesafe\", [[\"TYPESAFE_API_KEY\"]]],\n [\"openrouter\", [[\"OPENROUTER_API_KEY\"]]],\n [\"cloudflare\", [[\"CLOUDFLARE_API_TOKEN\", \"CLOUDFLARE_API_KEY\"], [\"CLOUDFLARE_ACCOUNT_ID\"]]],\n];\n\nconst modelFor = (provider: ProviderId, cfg: Cfg): string | undefined =>\n provider === \"typesafe\"\n ? cfg.model\n : provider === \"openrouter\"\n ? cfg.openrouterModel\n : provider === \"cloudflare\"\n ? cfg.cloudflareModel\n : undefined;\n\n/**\n * The decision providers to try, in order. An explicit one is used as is: the\n * server checks its credential. `auto` lists every provider in `AUTO_CREDENTIALS`\n * whose credential is set and throws when none is; the handler then fails open.\n */\nexport const resolveProviders = (cfg: Cfg): DecisionTarget[] => {\n if (cfg.provider !== \"auto\") {\n return [{ provider: cfg.provider, model: modelFor(cfg.provider, cfg) }];\n }\n const targets = AUTO_CREDENTIALS.filter(([, groups]) =>\n groups.every((keys) => keys.some((key) => usable(secret(key)))),\n ).map(([provider]) => ({ provider, model: modelFor(provider, cfg) }));\n if (targets.length > 0) return targets;\n throw new Error(\n \"no TYPESAFE_API_KEY, OPENROUTER_API_KEY, or CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID\",\n );\n};\n\n/** The first provider `resolveProviders` would try. */\nexport const resolveProvider = (cfg: Cfg): DecisionTarget => resolveProviders(cfg)[0]!;\n\n/** The server refused the provider for a deployment setting and sent nothing. */\nexport class ProviderUnavailableError extends Error {}\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 Verdict = {\n reason: string;\n confidence: number | null;\n probabilities: Record<string, number> | null;\n latencyMs: number;\n};\n\n/**\n * One decision: classify the newest message into a reason. Throws on any failure.\n * The server makes the call (`system-one-decide`), so every provider returns the\n * same `answers` shape. The time budget is enforced here and on the server.\n */\nexport const askModel = async (\n swarm: Swarm,\n input: {\n messages: string[];\n context: string[];\n sender: string;\n leadNames: readonly string[];\n },\n target: DecisionTarget,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<Verdict> => {\n const budgetMs = Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS);\n let timer: ReturnType<typeof setTimeout> | undefined;\n let onOuterAbort: (() => void) | undefined;\n const stop = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(() => reject(new Error(`timed out after ${budgetMs}ms`)), budgetMs);\n onOuterAbort = () => reject(new Error(\"aborted\"));\n outer?.addEventListener(\"abort\", onOuterAbort);\n });\n const t0 = Date.now();\n try {\n const call = swarm.systemOne_decide({\n provider: target.provider,\n ...(target.model ? { model: target.model } : {}),\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 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 timeoutMs: Math.max(budgetMs, MIN_DECIDE_TIMEOUT_MS),\n maxRetries: 0,\n });\n // After a timeout nobody awaits the call; its late failure must not go unhandled.\n call.catch(() => {});\n const res = (await Promise.race([call, stop])) as {\n success?: boolean;\n status?: number;\n data?: unknown;\n } | null;\n const body = (res?.data ?? res) as {\n success?: boolean;\n message?: unknown;\n error?: unknown;\n providerUnavailable?: unknown;\n answers?: { reason?: { choice?: unknown; confidence?: unknown; probabilities?: unknown } };\n } | null;\n if (res?.success === false || body?.success === false) {\n const why = `${target.provider}: ${String(body?.message ?? body?.error ?? `HTTP ${res?.status}`).slice(0, 200)}`;\n throw body?.providerUnavailable === true\n ? new ProviderUnavailableError(why)\n : new Error(why);\n }\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 if (onOuterAbort) outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\n/**\n * `askModel` on each target in turn, moving on only when the server refused the\n * provider for a deployment setting (no request was sent). Any other failure, or\n * a refusal of the last target, throws.\n */\nexport const askFirstAvailable = async (\n swarm: Swarm,\n input: Parameters<typeof askModel>[1],\n targets: readonly DecisionTarget[],\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<{ verdict: Verdict; target: DecisionTarget }> => {\n for (const [i, target] of targets.entries()) {\n try {\n return { verdict: await askModel(swarm, input, target, cfg, outer), target };\n } catch (err) {\n if (!(err instanceof ProviderUnavailableError) || i === targets.length - 1) throw err;\n }\n }\n throw new Error(\"no decision provider\");\n};\n\n/** Probability that the message needs no reply: the summed mass of the skip labels. */\nexport const skipProbability = (verdict: Verdict): 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 verdict to the typed decision. An unsure skip verdict replies. */\nexport const decide = (\n verdict: Verdict,\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 targets = resolveProviders(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, target } = await askFirstAvailable(\n ctx.swarm,\n {\n messages,\n context: context.slice(-cfg.contextMessages).map((m) => m.slice(0, 500)),\n sender,\n leadNames: cfg.leadAliases,\n },\n targets,\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: target.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"
|
|
118
118
|
},
|
|
119
|
-
"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
|
|
119
|
+
"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 a SystemOne decision model, Jev ([TypeSafe](https://typesafe.ai)) or Clef (Cloudflare Workers AI), 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 the model and records the verdict. Never blocks. |\n| `enforce` | Asks the model, 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 decision call.\n2. **@mention pass-through.** A new message that @mentions Lead passes without a decision 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 decision call (one `choice` question, see [Provider](#provider)) 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## Provider\n\nThe decision is made on the API server by the `system-one-decide` tool (`ctx.swarm.systemOne_decide`), the same path a `system-one-decision` workflow node takes. The server reads the provider's credential, sends the request, and validates the answer, so every SystemOne host works and no key enters the hook.\n\n| `provider` | Model | Credential |\n|---|---|---|\n| `typesafe` | Jev, `model` (default `jev-latest`) | `TYPESAFE_API_KEY` |\n| `openrouter` | Jev, `openrouterModel` (default `typesafe/jev-1.13`) | `OPENROUTER_API_KEY` |\n| `cloudflare` | Clef, `cloudflareModel` (default `clef`, or `clef-flash`) | `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`) and `CLOUDFLARE_ACCOUNT_ID` |\n| `laya`, `openai` | The host's default model | See the workflows guide |\n| `auto` (default) | The first of typesafe, openrouter, cloudflare whose credential is set. A provider the server refuses for a deployment setting (openrouter when `OPENROUTER_BASE_URL` points at a gateway) is skipped for the next one | |\n\nWith no credential for `auto`, every message fails open and the KV row names the secrets. Each decision row records the `provider` that answered. `minConfidence` was tuned on Jev; check Clef's verdicts in `shadow` before you enforce on it.\n\n## Fail open\n\nEvery error lets the task through: a missing key, a provider error relayed by the server, 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` | Decision call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `provider` | `auto` | `auto`, `typesafe`, `openrouter`, `cloudflare`, `laya`, or `openai`. See [Provider](#provider) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `cloudflareModel` | `clef` | Clef model on Cloudflare Workers AI |\n| `minConfidence` | `0.55` | Minimum summed skip probability to skip |\n| `contextMessages` | `6` | Thread messages sent to the model, 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 the model |\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\nSet them as global `swarm_config` values. The server reads the provider credential from the global row when it makes the decision. `auto` and the Slack token read the API process environment, which loads global rows at boot and on config reload.\n\n- One provider credential from the [Provider](#provider) table is required for any evaluation. Without one, every message fails open. The `apiKey` config field of earlier versions is gone: the hook never holds a key, so store it as the global `TYPESAFE_API_KEY` secret.\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"
|
|
120
120
|
},
|
|
121
121
|
"task-digest": {
|
|
122
122
|
"manifestFile": "manifest.yaml",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Generated by scripts/bundle-extension-types.ts. Do not edit.
|
|
2
2
|
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: Type declarations contain literal template placeholders.
|
|
3
3
|
export const EXTENSION_TYPE_DEFINITIONS =
|
|
4
|
-
'// biome-ignore-all lint/suspicious/noConfusingVoidType: Extension handlers may explicitly return no result.\ndeclare module "swarm-extension" {\n export type CreateTaskOptions = {\n key?: string | undefined;\n agentId?: string | null | undefined;\n creatorAgentId?: string | undefined;\n source?:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat"\n | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n taskType?: string | undefined;\n tags?: string[] | undefined;\n priority?: number | undefined;\n dependsOn?: string[] | undefined;\n offeredTo?: string | undefined;\n status?: "draft" | "backlog" | "unassigned" | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n overrideSlackContext?: boolean | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | undefined;\n workflowRunStepId?: string | undefined;\n sourceTaskId?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n inheritParentOutputSchema?: boolean | undefined;\n inheritParentRoutingAffinity?: boolean | undefined;\n inheritParentFollowUpConfig?: boolean | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n requestedByUserId?: string | undefined;\n contextKey?: string | undefined;\n bypassTrackerContextDedup?: boolean | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n };\n\n export type AgentTask = {\n id: string;\n key: string;\n agentId: string | null;\n task: string;\n status:\n | "draft"\n | "backlog"\n | "unassigned"\n | "offered"\n | "reviewing"\n | "pending"\n | "in_progress"\n | "paused"\n | "completed"\n | "failed"\n | "cancelled"\n | "superseded";\n source:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat";\n tags: string[];\n priority: number;\n dependsOn: string[];\n createdAt: string;\n lastUpdatedAt: string;\n slackReplySent: boolean;\n wasPaused: boolean;\n creatorAgentId?: string | undefined;\n title?: string | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n poolWakeAt?: string | undefined;\n taskType?: string | undefined;\n offeredTo?: string | undefined;\n offeredAt?: string | undefined;\n acceptedAt?: string | undefined;\n rejectionReason?: string | undefined;\n finishedAt?: string | undefined;\n notifiedAt?: string | undefined;\n failureReason?: string | undefined;\n output?: string | undefined;\n progress?: string | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n slackProgressMessageTs?: string | undefined;\n slackTreeRootMessageTs?: string | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n claudeSessionId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n resolvedModel?: string | undefined;\n modelSource?: string | undefined;\n modelAlias?: string | undefined;\n resolvedEffort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n resolvedProfile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | null | undefined;\n workflowRunStepId?: string | null | undefined;\n contextKey?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n deferredAt?: string | undefined;\n compactionCount?: number | undefined;\n peakContextPercent?: number | undefined;\n peakContextTokens?: number | undefined;\n contextWindowSize?: number | undefined;\n credentialKeySuffix?: string | undefined;\n credentialKeyType?: string | undefined;\n requestedByUserId?: string | undefined;\n swarmVersion?: string | undefined;\n provider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n providerMeta?: Record<string, unknown> | undefined;\n harnessVariant?: string | undefined;\n harnessVariantMeta?: Record<string, unknown> | undefined;\n totalCostUsd?: number | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n routingAffinityInvalid?: boolean | undefined;\n };\n\n export type ActiveSession = {\n id: string;\n agentId: string;\n taskId: string | null;\n triggerType: string;\n inboxMessageId: string | null;\n taskDescription: string | null;\n runnerSessionId: string | null;\n providerSessionId: string | null;\n startedAt: string;\n lastHeartbeatAt: string;\n runtimeInstanceId?: string | null | undefined;\n };\n\n export type RequestInfo = {\n sessionId: string | undefined;\n agentId: string | undefined;\n runtimeInstanceId: string | undefined;\n sourceTaskId: string | undefined;\n contextKey: string | undefined;\n callOrigin: "mcp" | "extension" | "script-sdk";\n };\n\n import type { SwarmSdk } from "swarm-sdk";\n import type { z } from "zod";\n export type Runtime = "api" | "worker";\n export interface ExtensionScriptAsset {\n name: string;\n file: string;\n description: string;\n intent?: string;\n }\n export interface ExtensionScheduleAsset {\n name: string;\n description?: string;\n script: string;\n cronExpression?: string;\n intervalMs?: number;\n timezone?: string;\n args?: Record<string, unknown>;\n }\n export interface ExtensionWorkflowAsset {\n file: string;\n }\n export interface ExtensionSkillAsset {\n dir: string;\n }\n export interface ExtensionManifest {\n $schema?: string;\n name: string;\n description: string;\n version: string;\n runtime: Runtime;\n assets: {\n hooks: string;\n scripts?: readonly ExtensionScriptAsset[];\n schedules?: readonly ExtensionScheduleAsset[];\n workflows?: readonly ExtensionWorkflowAsset[];\n skills?: readonly ExtensionSkillAsset[];\n };\n homepage?: string;\n author?: string;\n }\n export interface HooksModule {\n default: SwarmExtension;\n config?: z.ZodTypeAny;\n }\n export interface ExtensionState {\n get<T = unknown>(key: string): Promise<T | null>;\n set<T>(key: string, value: T): Promise<void>;\n incr(key: string, by?: number): Promise<number>;\n del(key: string): Promise<void>;\n }\n export interface ExtensionLogger {\n debug(message: string, data?: unknown): void;\n info(message: string, data?: unknown): void;\n warn(message: string, data?: unknown): void;\n error(message: string, data?: unknown): void;\n }\n type ConfigFor<M extends ExtensionManifest> = M extends {\n config: infer C extends z.ZodTypeAny;\n }\n ? z.infer<C>\n : Record<string, unknown>;\n export interface ApiCtx<Config = Record<string, unknown>> {\n swarm: SwarmSdk;\n state: ExtensionState;\n config: Config;\n log: ExtensionLogger;\n signal: AbortSignal;\n event: {\n name: keyof SwarmEventMap;\n at: string;\n extension: {\n id: string;\n version: number;\n };\n };\n }\n export interface WorkerCtx<Config = Record<string, unknown>>\n extends Omit<ApiCtx<Config>, "swarm"> {\n swarm: SwarmSdk;\n worker: {\n agentId: string;\n taskId: string;\n harness: string;\n };\n }\n export type CtxFor<M extends ExtensionManifest> = M["runtime"] extends "api"\n ? ApiCtx<ConfigFor<M>>\n : WorkerCtx<ConfigFor<M>>;\n export type PreResult<Modify> =\n | void\n | {\n action: "continue";\n }\n | {\n action: "modify";\n data: Modify;\n }\n | {\n action: "block";\n reason: string;\n };\n export type TaskCreateOrigin =\n | "rest"\n | "app"\n | "mcp"\n | "slack"\n | "schedule"\n | "workflow"\n | "webhook"\n | "followUp"\n | `extension:${string}`;\n export interface TaskCreateEvent {\n options: CreateTaskOptions;\n description: string;\n origin: TaskCreateOrigin;\n requestInfo?: RequestInfo;\n }\n export type TaskCreateModify = Partial<\n Pick<\n CreateTaskOptions,\n | "agentId"\n | "creatorAgentId"\n | "source"\n | "taskType"\n | "tags"\n | "priority"\n | "dependsOn"\n | "offeredTo"\n | "vcsProvider"\n | "vcsRepo"\n | "vcsEventType"\n | "vcsNumber"\n | "vcsCommentId"\n | "vcsAuthor"\n | "vcsUrl"\n | "vcsInstallationId"\n | "vcsNodeId"\n | "agentmailInboxId"\n | "agentmailMessageId"\n | "agentmailThreadId"\n | "mentionMessageId"\n | "mentionChannelId"\n | "dir"\n | "model"\n | "modelTier"\n | "effort"\n | "outputSchema"\n | "followUpConfig"\n | "bypassTrackerContextDedup"\n >\n > & {\n description?: string;\n };\n export interface TaskFollowUpEvent {\n completedTask: AgentTask;\n status: "completed" | "failed";\n output?: string;\n failureReason?: string;\n workerAgentId: string;\n leadAgentId: string;\n summary: string;\n }\n export type TaskFollowUpModify = Partial<\n Pick<CreateTaskOptions, "agentId" | "priority" | "followUpConfig">\n > & {\n description?: string;\n };\n export interface SlackRouteEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n botMentioned: boolean;\n threadContext?: {\n channelId: string;\n threadTs: string;\n };\n }\n export interface SlackRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n };\n }\n /** A Chat SDK message after its thread buffer flushed, before routing. */\n export interface ChatRouteEvent {\n /** Adapter name, such as `telegram` or `slack`. */\n adapter: string;\n channelId: string;\n /** The Chat SDK thread id, prefixed with the adapter name. */\n threadId: string;\n /** The first buffered sender. */\n userId: string;\n userName?: string;\n /** The buffered messages, joined. */\n text: string;\n botMentioned: boolean;\n isDirectMessage: boolean;\n /** Set when earlier swarm tasks exist in this thread. */\n threadContext?: {\n adapter: string;\n threadId: string;\n };\n }\n export interface ChatRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n }\n | {\n kind: "workflow";\n workflowId: string;\n };\n }\n export type HeartbeatClassification = "no-session" | "stale-session" | "fresh-stalled";\n export type HeartbeatAction = "supersede-resume" | "fail" | "record";\n export interface HeartbeatRemediateEvent {\n task: AgentTask;\n session?: ActiveSession;\n classification: HeartbeatClassification;\n proposedAction: HeartbeatAction;\n reason: string;\n taskAgeMs: number;\n sessionHeartbeatAgeMs?: number;\n }\n export interface HeartbeatRemediateModify {\n proposedAction: HeartbeatAction;\n }\n export interface ToolCallEvent {\n tool: string;\n args: unknown;\n requestInfo: RequestInfo;\n }\n export interface ToolCallModify {\n args: unknown;\n }\n export interface TaskEvent {\n task: AgentTask;\n }\n export interface TaskCompletedEvent extends TaskEvent {\n output: string;\n }\n export interface TaskFailedEvent extends TaskEvent {\n failureReason: string;\n }\n export interface TaskSupersededEvent extends TaskEvent {\n supersededBy: string;\n }\n export interface TaskProgressEvent extends TaskEvent {\n progress: string;\n }\n export interface SlackMessageEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n taskId?: string;\n }\n /** A Chat SDK message the swarm received or sent. */\n export interface ChatMessageEvent {\n adapter: string;\n channelId: string;\n threadId: string;\n /** The sender, for an inbound message. */\n userId?: string;\n text: string;\n direction: "inbound" | "outbound";\n taskId?: string;\n messageId?: string;\n }\n /**\n * A person answered an approval request. Fires for workflow and standalone\n * requests. Timeouts and cancellations do not emit it today.\n */\n export interface ApprovalResolvedEvent {\n requestId: string;\n status: "approved" | "rejected" | "timeout" | "cancelled";\n responses: Record<string, unknown> | null;\n /** Set when a workflow human-in-the-loop step asked. Absent for a standalone request. */\n workflowRunId?: string;\n workflowRunStepId?: string;\n /** The task whose agent asked, for a standalone request. */\n sourceTaskId?: string;\n }\n /** A task was refused because a daily spend budget is used up. */\n export interface TaskBudgetRefusedEvent extends TaskEvent {\n /** The agent the task was refused for. */\n agentId: string;\n /** Which budget refused it. The matching spend and budget fields are set. */\n cause: "agent" | "global" | "user";\n agentSpendUsd?: number;\n agentBudgetUsd?: number;\n globalSpendUsd?: number;\n globalBudgetUsd?: number;\n userSpendUsd?: number;\n userBudgetUsd?: number;\n /** ISO 8601 time the daily budget resets. */\n resetAt: string;\n }\n /** An inbound AgentMail message. */\n export interface EmailReceivedEvent {\n inboxId: string;\n /** The From header as sent. */\n from: string;\n subject: string;\n /** The text body, or the HTML when there is no text, cut to 500 characters with `...` appended. */\n body: string;\n threadId: string;\n messageId: string;\n }\n /** An inbound Kapso (WhatsApp) message. */\n export interface KapsoMessageEvent {\n phoneNumberId: string;\n conversationId?: string;\n messageId: string;\n from?: string;\n /** Kapso message type, such as `text` or `image`. */\n type?: string;\n /** The text, or a placeholder naming the type for a non-text message. */\n text: string;\n }\n export type VcsProvider = "github" | "gitlab" | "azure-devops";\n /**\n * The provider object the event is about, as the provider names it. GitLab\n * calls a pull request a merge request, and a comment a `note`.\n */\n export type VcsEventKind =\n | "pull_request"\n | "merge_request"\n | "issue"\n | "issue_comment"\n | "note"\n | "pull_request_review"\n | "pipeline";\n /**\n * A webhook event from a connected code host. `action` is the provider\'s own\n * value: `opened`, `closed`, `synchronize`, `merge`, `created`, `commented`, or,\n * for a pipeline, its status such as `success` or `failed`. Fields a kind does\n * not carry are absent.\n */\n export interface VcsEvent {\n provider: VcsProvider;\n kind: VcsEventKind;\n action: string;\n /** `owner/name` on GitHub and GitLab, the repository URL on Azure DevOps. */\n repo: string;\n /** The PR, merge request, or issue number. Absent for a pipeline outside a merge request. */\n number?: number;\n title?: string;\n body?: string | null;\n /** Login of the user behind the event. Set for pull requests, merge requests, and Azure DevOps comments. */\n author?: string;\n url?: string;\n merged?: boolean;\n changedFiles?: number;\n /** The review verdict, on a pull request review. */\n reviewState?: string;\n }\n export interface ToolCallCompletedEvent extends ToolCallEvent {\n result: unknown;\n durationMs: number;\n }\n export interface SwarmEventMap {\n "pre.task.create": {\n event: TaskCreateEvent;\n modify: TaskCreateModify;\n result: PreResult<TaskCreateModify>;\n };\n "pre.task.followUp": {\n event: TaskFollowUpEvent;\n modify: TaskFollowUpModify;\n result: PreResult<TaskFollowUpModify>;\n };\n "pre.slack.route": {\n event: SlackRouteEvent;\n modify: SlackRouteModify;\n result: PreResult<SlackRouteModify>;\n };\n "pre.chat.route": {\n event: ChatRouteEvent;\n modify: ChatRouteModify;\n result: PreResult<ChatRouteModify>;\n };\n "pre.heartbeat.remediate": {\n event: HeartbeatRemediateEvent;\n modify: HeartbeatRemediateModify;\n result: PreResult<HeartbeatRemediateModify>;\n };\n "pre.tool.call": {\n event: ToolCallEvent;\n modify: ToolCallModify;\n result: PreResult<ToolCallModify>;\n };\n "post.task.created": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.completed": {\n event: TaskCompletedEvent;\n modify: never;\n result: void;\n };\n "post.task.failed": {\n event: TaskFailedEvent;\n modify: never;\n result: void;\n };\n "post.task.cancelled": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.superseded": {\n event: TaskSupersededEvent;\n modify: never;\n result: void;\n };\n "post.task.progress": {\n event: TaskProgressEvent;\n modify: never;\n result: void;\n };\n "post.slack.message": {\n event: SlackMessageEvent;\n modify: never;\n result: void;\n };\n "post.chat.message": {\n event: ChatMessageEvent;\n modify: never;\n result: void;\n };\n "post.approval.resolved": {\n event: ApprovalResolvedEvent;\n modify: never;\n result: void;\n };\n "post.task.budgetRefused": {\n event: TaskBudgetRefusedEvent;\n modify: never;\n result: void;\n };\n "post.email.received": {\n event: EmailReceivedEvent;\n modify: never;\n result: void;\n };\n "post.kapso.message": {\n event: KapsoMessageEvent;\n modify: never;\n result: void;\n };\n "post.vcs.event": {\n event: VcsEvent;\n modify: never;\n result: void;\n };\n "post.tool.call": {\n event: ToolCallCompletedEvent;\n modify: never;\n result: void;\n };\n }\n export interface ExtensionApi<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > {\n on<E extends keyof SwarmEventMap>(\n event: E,\n handler: (\n event: SwarmEventMap[E]["event"],\n ctx: CtxFor<M>,\n ) => Promise<SwarmEventMap[E]["result"]> | SwarmEventMap[E]["result"],\n opts?: {\n priority?: number;\n },\n ): void;\n }\n export type SwarmExtension<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > = (api: ExtensionApi<M>) => void;\n export { block, modify } from "./contract-runtime";\n}\n';
|
|
4
|
+
'// biome-ignore-all lint/suspicious/noConfusingVoidType: Extension handlers may explicitly return no result.\ndeclare module "swarm-extension" {\n export type CreateTaskOptions = {\n key?: string | undefined;\n agentId?: string | null | undefined;\n creatorAgentId?: string | undefined;\n source?:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "bitbucket"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat"\n | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n taskType?: string | undefined;\n tags?: string[] | undefined;\n priority?: number | undefined;\n dependsOn?: string[] | undefined;\n offeredTo?: string | undefined;\n status?: "draft" | "backlog" | "unassigned" | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n overrideSlackContext?: boolean | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | undefined;\n workflowRunStepId?: string | undefined;\n sourceTaskId?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n inheritParentOutputSchema?: boolean | undefined;\n inheritParentRequester?: boolean | undefined;\n inheritParentRoutingAffinity?: boolean | undefined;\n inheritParentFollowUpConfig?: boolean | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n requestedByUserId?: string | undefined;\n contextKey?: string | undefined;\n bypassTrackerContextDedup?: boolean | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n };\n\n export type AgentTask = {\n id: string;\n key: string;\n agentId: string | null;\n task: string;\n status:\n | "draft"\n | "backlog"\n | "unassigned"\n | "offered"\n | "reviewing"\n | "pending"\n | "in_progress"\n | "paused"\n | "completed"\n | "failed"\n | "cancelled"\n | "superseded";\n source:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "bitbucket"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat";\n tags: string[];\n priority: number;\n dependsOn: string[];\n createdAt: string;\n lastUpdatedAt: string;\n slackReplySent: boolean;\n wasPaused: boolean;\n creatorAgentId?: string | undefined;\n title?: string | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n poolWakeAt?: string | undefined;\n taskType?: string | undefined;\n offeredTo?: string | undefined;\n offeredAt?: string | undefined;\n acceptedAt?: string | undefined;\n rejectionReason?: string | undefined;\n finishedAt?: string | undefined;\n notifiedAt?: string | undefined;\n failureReason?: string | undefined;\n output?: string | undefined;\n progress?: string | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n slackProgressMessageTs?: string | undefined;\n slackTreeRootMessageTs?: string | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n claudeSessionId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n resolvedModel?: string | undefined;\n modelSource?: string | undefined;\n modelAlias?: string | undefined;\n resolvedEffort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n resolvedProfile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | null | undefined;\n workflowRunStepId?: string | null | undefined;\n contextKey?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n deferredAt?: string | undefined;\n compactionCount?: number | undefined;\n peakContextPercent?: number | undefined;\n peakContextTokens?: number | undefined;\n contextWindowSize?: number | undefined;\n credentialKeySuffix?: string | undefined;\n credentialKeyType?: string | undefined;\n requestedByUserId?: string | undefined;\n swarmVersion?: string | undefined;\n provider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n providerMeta?: Record<string, unknown> | undefined;\n harnessVariant?: string | undefined;\n harnessVariantMeta?: Record<string, unknown> | undefined;\n totalCostUsd?: number | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n routingAffinityInvalid?: boolean | undefined;\n };\n\n export type ActiveSession = {\n id: string;\n agentId: string;\n taskId: string | null;\n triggerType: string;\n inboxMessageId: string | null;\n taskDescription: string | null;\n runnerSessionId: string | null;\n providerSessionId: string | null;\n startedAt: string;\n lastHeartbeatAt: string;\n runtimeInstanceId?: string | null | undefined;\n };\n\n export type RequestInfo = {\n sessionId: string | undefined;\n agentId: string | undefined;\n runtimeInstanceId: string | undefined;\n sourceTaskId: string | undefined;\n contextKey: string | undefined;\n callOrigin: "mcp" | "extension" | "script-sdk";\n };\n\n import type { SwarmSdk } from "swarm-sdk";\n import type { z } from "zod";\n export type Runtime = "api" | "worker";\n export interface ExtensionScriptAsset {\n name: string;\n file: string;\n description: string;\n intent?: string;\n }\n export interface ExtensionScheduleAsset {\n name: string;\n description?: string;\n script: string;\n cronExpression?: string;\n intervalMs?: number;\n timezone?: string;\n args?: Record<string, unknown>;\n }\n export interface ExtensionWorkflowAsset {\n file: string;\n }\n export interface ExtensionSkillAsset {\n dir: string;\n }\n export interface ExtensionManifest {\n $schema?: string;\n name: string;\n description: string;\n version: string;\n runtime: Runtime;\n assets: {\n hooks: string;\n scripts?: readonly ExtensionScriptAsset[];\n schedules?: readonly ExtensionScheduleAsset[];\n workflows?: readonly ExtensionWorkflowAsset[];\n skills?: readonly ExtensionSkillAsset[];\n };\n homepage?: string;\n author?: string;\n }\n export interface HooksModule {\n default: SwarmExtension;\n config?: z.ZodTypeAny;\n }\n export interface ExtensionState {\n get<T = unknown>(key: string): Promise<T | null>;\n set<T>(key: string, value: T): Promise<void>;\n incr(key: string, by?: number): Promise<number>;\n del(key: string): Promise<void>;\n }\n export interface ExtensionLogger {\n debug(message: string, data?: unknown): void;\n info(message: string, data?: unknown): void;\n warn(message: string, data?: unknown): void;\n error(message: string, data?: unknown): void;\n }\n type ConfigFor<M extends ExtensionManifest> = M extends {\n config: infer C extends z.ZodTypeAny;\n }\n ? z.infer<C>\n : Record<string, unknown>;\n export interface ApiCtx<Config = Record<string, unknown>> {\n swarm: SwarmSdk;\n state: ExtensionState;\n config: Config;\n log: ExtensionLogger;\n signal: AbortSignal;\n event: {\n name: keyof SwarmEventMap;\n at: string;\n extension: {\n id: string;\n version: number;\n };\n };\n }\n export interface WorkerCtx<Config = Record<string, unknown>>\n extends Omit<ApiCtx<Config>, "swarm"> {\n swarm: SwarmSdk;\n worker: {\n agentId: string;\n taskId: string;\n harness: string;\n };\n }\n export type CtxFor<M extends ExtensionManifest> = M["runtime"] extends "api"\n ? ApiCtx<ConfigFor<M>>\n : WorkerCtx<ConfigFor<M>>;\n export type PreResult<Modify> =\n | void\n | {\n action: "continue";\n }\n | {\n action: "modify";\n data: Modify;\n }\n | {\n action: "block";\n reason: string;\n };\n export type TaskCreateOrigin =\n | "rest"\n | "app"\n | "mcp"\n | "slack"\n | "schedule"\n | "workflow"\n | "webhook"\n | "followUp"\n | `extension:${string}`;\n export interface TaskCreateEvent {\n options: CreateTaskOptions;\n description: string;\n origin: TaskCreateOrigin;\n requestInfo?: RequestInfo;\n }\n export type TaskCreateModify = Partial<\n Pick<\n CreateTaskOptions,\n | "agentId"\n | "creatorAgentId"\n | "source"\n | "taskType"\n | "tags"\n | "priority"\n | "dependsOn"\n | "offeredTo"\n | "vcsProvider"\n | "vcsRepo"\n | "vcsEventType"\n | "vcsNumber"\n | "vcsCommentId"\n | "vcsAuthor"\n | "vcsUrl"\n | "vcsInstallationId"\n | "vcsNodeId"\n | "agentmailInboxId"\n | "agentmailMessageId"\n | "agentmailThreadId"\n | "mentionMessageId"\n | "mentionChannelId"\n | "dir"\n | "model"\n | "modelTier"\n | "effort"\n | "outputSchema"\n | "followUpConfig"\n | "bypassTrackerContextDedup"\n >\n > & {\n description?: string;\n };\n export interface TaskFollowUpEvent {\n completedTask: AgentTask;\n status: "completed" | "failed";\n output?: string;\n failureReason?: string;\n workerAgentId: string;\n leadAgentId: string;\n summary: string;\n }\n export type TaskFollowUpModify = Partial<\n Pick<CreateTaskOptions, "agentId" | "priority" | "followUpConfig">\n > & {\n description?: string;\n };\n export interface SlackRouteEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n botMentioned: boolean;\n threadContext?: {\n channelId: string;\n threadTs: string;\n };\n }\n export interface SlackRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n };\n }\n /** A Chat SDK message after its thread buffer flushed, before routing. */\n export interface ChatRouteEvent {\n /** Adapter name, such as `telegram` or `slack`. */\n adapter: string;\n channelId: string;\n /** The Chat SDK thread id, prefixed with the adapter name. */\n threadId: string;\n /** The first buffered sender. */\n userId: string;\n userName?: string;\n /** The buffered messages, joined. */\n text: string;\n /** True for an @mention and for a reply to one of the bot\'s messages. */\n botMentioned: boolean;\n isDirectMessage: boolean;\n /** Platform chat type, such as Telegram `private`, `group`, `supergroup` or `channel`. */\n chatType?: string;\n /** Set for a Telegram supergroup with topics. */\n isForum?: boolean;\n /** A buffered message replies to one of the bot\'s own messages. */\n replyToBot: boolean;\n /** Set when earlier swarm tasks exist in this thread. */\n threadContext?: {\n adapter: string;\n threadId: string;\n };\n }\n export interface ChatRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n }\n | {\n kind: "workflow";\n workflowId: string;\n };\n }\n export type HeartbeatClassification = "no-session" | "stale-session" | "fresh-stalled";\n export type HeartbeatAction = "supersede-resume" | "fail" | "record";\n export interface HeartbeatRemediateEvent {\n task: AgentTask;\n session?: ActiveSession;\n classification: HeartbeatClassification;\n proposedAction: HeartbeatAction;\n reason: string;\n taskAgeMs: number;\n sessionHeartbeatAgeMs?: number;\n }\n export interface HeartbeatRemediateModify {\n proposedAction: HeartbeatAction;\n }\n export interface ToolCallEvent {\n tool: string;\n args: unknown;\n requestInfo: RequestInfo;\n }\n export interface ToolCallModify {\n args: unknown;\n }\n export interface TaskEvent {\n task: AgentTask;\n }\n export interface TaskCompletedEvent extends TaskEvent {\n output: string;\n }\n export interface TaskFailedEvent extends TaskEvent {\n failureReason: string;\n }\n export interface TaskSupersededEvent extends TaskEvent {\n supersededBy: string;\n }\n export interface TaskProgressEvent extends TaskEvent {\n progress: string;\n }\n export interface SlackMessageEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n taskId?: string;\n }\n /** A Chat SDK message the swarm received or sent. */\n export interface ChatMessageEvent {\n adapter: string;\n channelId: string;\n threadId: string;\n /** The sender, for an inbound message. */\n userId?: string;\n text: string;\n direction: "inbound" | "outbound";\n taskId?: string;\n messageId?: string;\n }\n /**\n * A person answered an approval request. Fires for workflow and standalone\n * requests. Timeouts and cancellations do not emit it today.\n */\n export interface ApprovalResolvedEvent {\n requestId: string;\n status: "approved" | "rejected" | "timeout" | "cancelled";\n responses: Record<string, unknown> | null;\n /** Set when a workflow human-in-the-loop step asked. Absent for a standalone request. */\n workflowRunId?: string;\n workflowRunStepId?: string;\n /** The task whose agent asked, for a standalone request. */\n sourceTaskId?: string;\n }\n /** A task was refused because a daily spend budget is used up. */\n export interface TaskBudgetRefusedEvent extends TaskEvent {\n /** The agent the task was refused for. */\n agentId: string;\n /** Which budget refused it. The matching spend and budget fields are set. */\n cause: "agent" | "global" | "user";\n agentSpendUsd?: number;\n agentBudgetUsd?: number;\n globalSpendUsd?: number;\n globalBudgetUsd?: number;\n userSpendUsd?: number;\n userBudgetUsd?: number;\n /** ISO 8601 time the daily budget resets. */\n resetAt: string;\n }\n /** An inbound AgentMail message. */\n export interface EmailReceivedEvent {\n inboxId: string;\n /** The From header as sent. */\n from: string;\n subject: string;\n /** The text body, or the HTML when there is no text, cut to 500 characters with `...` appended. */\n body: string;\n threadId: string;\n messageId: string;\n }\n /** An inbound Kapso (WhatsApp) message. */\n export interface KapsoMessageEvent {\n phoneNumberId: string;\n conversationId?: string;\n messageId: string;\n from?: string;\n /** Kapso message type, such as `text` or `image`. */\n type?: string;\n /** The text, or a placeholder naming the type for a non-text message. */\n text: string;\n }\n export type VcsProvider = "github" | "gitlab" | "azure-devops" | "bitbucket";\n /**\n * The provider object the event is about, as the provider names it. GitLab\n * calls a pull request a merge request, and a comment a `note`.\n */\n export type VcsEventKind =\n | "pull_request"\n | "merge_request"\n | "issue"\n | "issue_comment"\n | "note"\n | "pull_request_review"\n | "pipeline";\n /**\n * A webhook event from a connected code host. `action` is the provider\'s own\n * value: `opened`, `closed`, `synchronize`, `merge`, `created`, `commented`, or,\n * for a pipeline, its status such as `success` or `failed`. Fields a kind does\n * not carry are absent.\n */\n export interface VcsEvent {\n provider: VcsProvider;\n kind: VcsEventKind;\n action: string;\n /** `owner/name` on GitHub and GitLab, `workspace/repo_slug` on Bitbucket, the repository URL on Azure DevOps. */\n repo: string;\n /** The PR, merge request, or issue number. Absent for a pipeline outside a merge request. */\n number?: number;\n title?: string;\n body?: string | null;\n /** Login of the user behind the event. Set for pull requests, merge requests, and Azure DevOps comments. */\n author?: string;\n url?: string;\n merged?: boolean;\n changedFiles?: number;\n /** The review verdict, on a pull request review. */\n reviewState?: string;\n }\n export interface ToolCallCompletedEvent extends ToolCallEvent {\n result: unknown;\n durationMs: number;\n }\n export interface SwarmEventMap {\n "pre.task.create": {\n event: TaskCreateEvent;\n modify: TaskCreateModify;\n result: PreResult<TaskCreateModify>;\n };\n "pre.task.followUp": {\n event: TaskFollowUpEvent;\n modify: TaskFollowUpModify;\n result: PreResult<TaskFollowUpModify>;\n };\n "pre.slack.route": {\n event: SlackRouteEvent;\n modify: SlackRouteModify;\n result: PreResult<SlackRouteModify>;\n };\n "pre.chat.route": {\n event: ChatRouteEvent;\n modify: ChatRouteModify;\n result: PreResult<ChatRouteModify>;\n };\n "pre.heartbeat.remediate": {\n event: HeartbeatRemediateEvent;\n modify: HeartbeatRemediateModify;\n result: PreResult<HeartbeatRemediateModify>;\n };\n "pre.tool.call": {\n event: ToolCallEvent;\n modify: ToolCallModify;\n result: PreResult<ToolCallModify>;\n };\n "post.task.created": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.completed": {\n event: TaskCompletedEvent;\n modify: never;\n result: void;\n };\n "post.task.failed": {\n event: TaskFailedEvent;\n modify: never;\n result: void;\n };\n "post.task.cancelled": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.superseded": {\n event: TaskSupersededEvent;\n modify: never;\n result: void;\n };\n "post.task.progress": {\n event: TaskProgressEvent;\n modify: never;\n result: void;\n };\n "post.slack.message": {\n event: SlackMessageEvent;\n modify: never;\n result: void;\n };\n "post.chat.message": {\n event: ChatMessageEvent;\n modify: never;\n result: void;\n };\n "post.approval.resolved": {\n event: ApprovalResolvedEvent;\n modify: never;\n result: void;\n };\n "post.task.budgetRefused": {\n event: TaskBudgetRefusedEvent;\n modify: never;\n result: void;\n };\n "post.email.received": {\n event: EmailReceivedEvent;\n modify: never;\n result: void;\n };\n "post.kapso.message": {\n event: KapsoMessageEvent;\n modify: never;\n result: void;\n };\n "post.vcs.event": {\n event: VcsEvent;\n modify: never;\n result: void;\n };\n "post.tool.call": {\n event: ToolCallCompletedEvent;\n modify: never;\n result: void;\n };\n }\n export interface ExtensionApi<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > {\n on<E extends keyof SwarmEventMap>(\n event: E,\n handler: (\n event: SwarmEventMap[E]["event"],\n ctx: CtxFor<M>,\n ) => Promise<SwarmEventMap[E]["result"]> | SwarmEventMap[E]["result"],\n opts?: {\n priority?: number;\n },\n ): void;\n }\n export type SwarmExtension<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > = (api: ExtensionApi<M>) => void;\n export { block, modify } from "./contract-runtime";\n}\n';
|
|
@@ -193,8 +193,15 @@ export interface ChatRouteEvent {
|
|
|
193
193
|
userName?: string;
|
|
194
194
|
/** The buffered messages, joined. */
|
|
195
195
|
text: string;
|
|
196
|
+
/** True for an @mention and for a reply to one of the bot's messages. */
|
|
196
197
|
botMentioned: boolean;
|
|
197
198
|
isDirectMessage: boolean;
|
|
199
|
+
/** Platform chat type, such as Telegram `private`, `group`, `supergroup` or `channel`. */
|
|
200
|
+
chatType?: string;
|
|
201
|
+
/** Set for a Telegram supergroup with topics. */
|
|
202
|
+
isForum?: boolean;
|
|
203
|
+
/** A buffered message replies to one of the bot's own messages. */
|
|
204
|
+
replyToBot: boolean;
|
|
198
205
|
/** Set when earlier swarm tasks exist in this thread. */
|
|
199
206
|
threadContext?: { adapter: string; threadId: string };
|
|
200
207
|
}
|
|
@@ -330,7 +337,7 @@ export interface KapsoMessageEvent {
|
|
|
330
337
|
text: string;
|
|
331
338
|
}
|
|
332
339
|
|
|
333
|
-
export type VcsProvider = "github" | "gitlab" | "azure-devops";
|
|
340
|
+
export type VcsProvider = "github" | "gitlab" | "azure-devops" | "bitbucket";
|
|
334
341
|
|
|
335
342
|
/**
|
|
336
343
|
* The provider object the event is about, as the provider names it. GitLab
|
|
@@ -355,7 +362,7 @@ export interface VcsEvent {
|
|
|
355
362
|
provider: VcsProvider;
|
|
356
363
|
kind: VcsEventKind;
|
|
357
364
|
action: string;
|
|
358
|
-
/** `owner/name` on GitHub and GitLab, the repository URL on Azure DevOps. */
|
|
365
|
+
/** `owner/name` on GitHub and GitLab, `workspace/repo_slug` on Bitbucket, the repository URL on Azure DevOps. */
|
|
359
366
|
repo: string;
|
|
360
367
|
/** The PR, merge request, or issue number. Absent for a pipeline outside a merge request. */
|
|
361
368
|
number?: number;
|
package/src/fs/provider.ts
CHANGED
|
@@ -117,12 +117,24 @@ export function normalizeStoredKey(key: string): string {
|
|
|
117
117
|
if (!trimmed || trimmed.includes("\0")) {
|
|
118
118
|
throw new FilesError("Provider", "Stored file key must be a non-empty path");
|
|
119
119
|
}
|
|
120
|
-
if (trimmed.split("/").some(
|
|
120
|
+
if (trimmed.split("/").some(isParentSegment)) {
|
|
121
121
|
throw new FilesError("Provider", "Stored file key must not contain parent path segments");
|
|
122
122
|
}
|
|
123
123
|
return trimmed;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// `..` itself, or a segment a URL decoder turns into `..` or a separator
|
|
127
|
+
// (`%2e%2e`, `..%2f..`); normalizeName never produces either form.
|
|
128
|
+
function isParentSegment(part: string): boolean {
|
|
129
|
+
if (part === "..") return true;
|
|
130
|
+
try {
|
|
131
|
+
const decoded = decodeURIComponent(part);
|
|
132
|
+
return decoded === ".." || decoded.includes("/");
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
126
138
|
export function normalizeName(name: string): string {
|
|
127
139
|
const trimmed = name.trim().replace(/^\/+/, "");
|
|
128
140
|
if (!trimmed || trimmed === "." || trimmed.includes("\0")) {
|
package/src/github/handlers.ts
CHANGED
|
@@ -307,7 +307,7 @@ export async function handlePullRequest(
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
// Find the related task
|
|
310
|
-
const task = await findTaskByVcs(repository.full_name, pr.number);
|
|
310
|
+
const task = await findTaskByVcs(repository.full_name, pr.number, "github");
|
|
311
311
|
if (!task) {
|
|
312
312
|
console.log(`[GitHub] No active task found for PR #${pr.number} to cancel`);
|
|
313
313
|
return { created: false };
|
|
@@ -337,7 +337,7 @@ export async function handlePullRequest(
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
// Check if there's an existing active task for this PR — skip duplicate review tasks
|
|
340
|
-
const existingTask = await findTaskByVcs(repository.full_name, pr.number);
|
|
340
|
+
const existingTask = await findTaskByVcs(repository.full_name, pr.number, "github");
|
|
341
341
|
if (existingTask) {
|
|
342
342
|
console.log(
|
|
343
343
|
`[GitHub] Skipping review task for PR #${pr.number} — active task ${existingTask.id} already exists`,
|
|
@@ -419,7 +419,7 @@ export async function handlePullRequest(
|
|
|
419
419
|
}
|
|
420
420
|
|
|
421
421
|
// Find the related task
|
|
422
|
-
const task = await findTaskByVcs(repository.full_name, pr.number);
|
|
422
|
+
const task = await findTaskByVcs(repository.full_name, pr.number, "github");
|
|
423
423
|
if (!task) {
|
|
424
424
|
console.log(`[GitHub] No active task found for PR #${pr.number} to cancel`);
|
|
425
425
|
return { created: false };
|
|
@@ -703,7 +703,7 @@ export async function handleIssue(
|
|
|
703
703
|
}
|
|
704
704
|
|
|
705
705
|
// Find the related task
|
|
706
|
-
const task = await findTaskByVcs(repository.full_name, issue.number);
|
|
706
|
+
const task = await findTaskByVcs(repository.full_name, issue.number, "github");
|
|
707
707
|
if (!task) {
|
|
708
708
|
console.log(`[GitHub] No active task found for issue #${issue.number} to cancel`);
|
|
709
709
|
return { created: false };
|
|
@@ -913,7 +913,7 @@ export async function handleComment(
|
|
|
913
913
|
|
|
914
914
|
// Check if there's an existing task for this PR/Issue
|
|
915
915
|
const existingTask = targetNumber
|
|
916
|
-
? await findTaskByVcs(repository.full_name, targetNumber)
|
|
916
|
+
? await findTaskByVcs(repository.full_name, targetNumber, "github")
|
|
917
917
|
: null;
|
|
918
918
|
|
|
919
919
|
// Build task description
|
|
@@ -1365,7 +1365,7 @@ export async function handlePullRequestReview(
|
|
|
1365
1365
|
}
|
|
1366
1366
|
|
|
1367
1367
|
// Find any existing task for this PR
|
|
1368
|
-
const existingTask = await findTaskByVcs(repository.full_name, pr.number);
|
|
1368
|
+
const existingTask = await findTaskByVcs(repository.full_name, pr.number, "github");
|
|
1369
1369
|
|
|
1370
1370
|
// Only notify for PRs where bot is creator or already has a task
|
|
1371
1371
|
const isBotCreator = isBotAssignee(pr.user.login);
|
package/src/gitlab/handlers.ts
CHANGED
|
@@ -207,7 +207,7 @@ export async function handleMergeRequest(
|
|
|
207
207
|
case "close":
|
|
208
208
|
case "merge": {
|
|
209
209
|
// Cancel existing tasks for this MR
|
|
210
|
-
const existingTask = await findTaskByVcs(repo, mr.iid);
|
|
210
|
+
const existingTask = await findTaskByVcs(repo, mr.iid, "gitlab");
|
|
211
211
|
if (existingTask) {
|
|
212
212
|
const reason = action === "merge" ? "MR was merged" : "MR was closed";
|
|
213
213
|
console.log(`[GitLab] Cancelling task ${existingTask.id} — ${reason}`);
|
|
@@ -218,7 +218,7 @@ export async function handleMergeRequest(
|
|
|
218
218
|
|
|
219
219
|
case "update": {
|
|
220
220
|
// Check if there's an active task for this MR — if so, notify about the update
|
|
221
|
-
const task = await findTaskByVcs(repo, mr.iid);
|
|
221
|
+
const task = await findTaskByVcs(repo, mr.iid, "gitlab");
|
|
222
222
|
if (task) {
|
|
223
223
|
console.log(`[GitLab] MR #${mr.iid} updated, active task exists: ${task.id}`);
|
|
224
224
|
// Don't create a new task — the worker will see the changes
|
|
@@ -318,7 +318,7 @@ export async function handleIssue(
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
case "close": {
|
|
321
|
-
const existingTask = await findTaskByVcs(repo, issue.iid);
|
|
321
|
+
const existingTask = await findTaskByVcs(repo, issue.iid, "gitlab");
|
|
322
322
|
if (existingTask) {
|
|
323
323
|
console.log(`[GitLab] Cancelling task ${existingTask.id} — issue closed`);
|
|
324
324
|
await failTask(existingTask.id, "Issue was closed");
|
|
@@ -375,7 +375,7 @@ export async function handleNote(event: NoteEvent): Promise<{ created: boolean;
|
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
// Check if there's already an active task for this entity
|
|
378
|
-
const existingTask = targetNumber ? await findTaskByVcs(repo, targetNumber) : null;
|
|
378
|
+
const existingTask = targetNumber ? await findTaskByVcs(repo, targetNumber, "gitlab") : null;
|
|
379
379
|
|
|
380
380
|
const existingTaskNote = existingTask
|
|
381
381
|
? `\n\n_Note: There's an active task (${existingTask.id}) for this ${entityLabel}._`
|
|
@@ -462,7 +462,7 @@ export async function handlePipeline(
|
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
// Only create task if there's already an active task for this MR
|
|
465
|
-
const existingTask = await findTaskByVcs(repo, mrIid);
|
|
465
|
+
const existingTask = await findTaskByVcs(repo, mrIid, "gitlab");
|
|
466
466
|
if (!existingTask) {
|
|
467
467
|
return { created: false };
|
|
468
468
|
}
|