@desplega.ai/agent-swarm 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/dist/{acp-adapter-sj2g0m84.js → acp-adapter-kr4abrsk.js} +10 -10
  2. package/dist/{actions-fegk44gr.js → actions-eh4g6w2r.js} +27 -27
  3. package/dist/{amp-adapter-ad8ft89x.js → amp-adapter-7h7wdt8q.js} +5 -5
  4. package/dist/{app-kfvgbvzy.js → app-7k8s9w5p.js} +9 -9
  5. package/dist/{assistant-z7zdy9ja.js → assistant-wswt2y7p.js} +28 -28
  6. package/dist/{boot-reembed-j8xa181e.js → boot-reembed-9apm9qw6.js} +7 -7
  7. package/dist/{boot-reembed-5c2z0mbe.js → boot-reembed-whzbdthj.js} +7 -7
  8. package/dist/{boot-scrub-sweep-hnkn3rgt.js → boot-scrub-sweep-ndzmvmgt.js} +8 -8
  9. package/dist/{boot-seeds-kwjq4d2z.js → boot-seeds-e9sta0yt.js} +13 -11
  10. package/dist/{claude-adapter-9vmvpvgd.js → claude-adapter-4mqd3r6r.js} +11 -11
  11. package/dist/{claude-managed-adapter-k9qae05j.js → claude-managed-adapter-9wswb52m.js} +3 -3
  12. package/dist/{claude-managed-setup-q54zx5q2.js → claude-managed-setup-tpe1effx.js} +4 -4
  13. package/dist/{claude-sdk-session-9jye6cxr.js → claude-sdk-session-q49ghmh9.js} +10340 -10197
  14. package/dist/{cli-ke896fcf.js → cli-01xvj1rq.js} +10 -10
  15. package/dist/{cli-tr4sxp68.js → cli-035vekcw.js} +2 -2
  16. package/dist/{cli-z5jjpaxw.js → cli-0pd8hzhc.js} +3 -3
  17. package/dist/{cli-n5vwg842.js → cli-0veb92a3.js} +6 -1
  18. package/dist/{cli-wayw93nt.js → cli-10jesr25.js} +3 -3
  19. package/dist/{cli-se8w6wny.js → cli-1y7yywmj.js} +1 -1
  20. package/dist/{cli-v1yb0rqb.js → cli-20krdhz5.js} +47 -19
  21. package/dist/{cli-d5f9ed5c.js → cli-29rja109.js} +39 -20
  22. package/dist/{cli-apx87jne.js → cli-2pwa0q4d.js} +15 -4
  23. package/dist/{cli-ee9gtz70.js → cli-4mengw46.js} +348 -25
  24. package/dist/{cli-03gedhng.js → cli-5twdke5x.js} +6 -6
  25. package/dist/{cli-6razj6t4.js → cli-711vwbxn.js} +2 -2
  26. package/dist/{cli-s2sp957d.js → cli-78vv7446.js} +1 -1
  27. package/dist/{cli-3fbmmawm.js → cli-7n6s5man.js} +4 -4
  28. package/dist/{cli-hnhtk6cj.js → cli-8wwxw8a3.js} +1 -1
  29. package/dist/{cli-hnmhtzvh.js → cli-9jzdj90z.js} +2 -0
  30. package/dist/{cli-skxvvpap.js → cli-cvv9k0hh.js} +28 -25
  31. package/dist/{cli-74ahz61x.js → cli-d9286m9h.js} +2 -2
  32. package/dist/{cli-4jxm2qtz.js → cli-gxzkg1de.js} +28 -16
  33. package/dist/{cli-zkpks3r3.js → cli-h1arqn31.js} +1 -1
  34. package/dist/{cli-r62a35ea.js → cli-hqr88ew6.js} +1 -1
  35. package/dist/{cli-ra0xzggj.js → cli-j67mh2vg.js} +2 -2
  36. package/dist/{cli-29jc5q7w.js → cli-ksphgh48.js} +2 -2
  37. package/dist/{cli-eejsgvm4.js → cli-mgz4nbgg.js} +4 -4
  38. package/dist/{cli-vatyhshy.js → cli-mz5as5ha.js} +4 -4
  39. package/dist/{cli-f7kr7svz.js → cli-n8ndw8j7.js} +2 -2
  40. package/dist/{cli-1e4b6bzh.js → cli-naqzr17j.js} +7 -7
  41. package/dist/{cli-q0ke1yww.js → cli-nm1p8jfr.js} +6 -6
  42. package/dist/{cli-m50t4309.js → cli-p3zbc1rw.js} +5 -5
  43. package/dist/{cli-cgtksawx.js → cli-pe05f8yk.js} +23 -3
  44. package/dist/{cli-vdach5a9.js → cli-qmzsbkvy.js} +4 -4
  45. package/dist/{cli-n3hzg89b.js → cli-qnm4e9e4.js} +57 -579
  46. package/dist/{cli-s4setq91.js → cli-qqy4q48b.js} +3 -3
  47. package/dist/{cli-k382tshb.js → cli-r35rw5de.js} +8 -7
  48. package/dist/{cli-k3dp5tpf.js → cli-r610p9bs.js} +2 -2
  49. package/dist/{cli-e6mpjhed.js → cli-rc6pf5kv.js} +1 -1
  50. package/dist/{cli-3dj8k4vy.js → cli-rdxm77k3.js} +1 -1
  51. package/dist/{cli-tqaazemc.js → cli-s69nfyeh.js} +1 -1
  52. package/dist/{cli-6jyj59nb.js → cli-sawv63e8.js} +2 -2
  53. package/dist/{cli-q50942rs.js → cli-sg19gbf0.js} +1 -1
  54. package/dist/{cli-p1x85d2t.js → cli-shk7r5cs.js} +12 -9
  55. package/dist/{cli-przpmdgr.js → cli-sksetb2q.js} +11 -1
  56. package/dist/{cli-d2qr7aj4.js → cli-t02z59f2.js} +1 -1
  57. package/dist/{cli-j0pfptdp.js → cli-v7g551te.js} +39 -43
  58. package/dist/{cli-bzszjf2p.js → cli-vgqzp4em.js} +519 -126
  59. package/dist/cli-wfnemr1t.js +86 -0
  60. package/dist/{cli-ne236afy.js → cli-wms6x9db.js} +8 -8
  61. package/dist/{cli-5hkm7whv.js → cli-x1r545x8.js} +1 -1
  62. package/dist/{cli-kt6zqg0e.js → cli-xczsgshn.js} +2 -2
  63. package/dist/{cli-ef1hg1wv.js → cli-y3167wt1.js} +430 -238
  64. package/dist/{cli-tsde1hdr.js → cli-yjmqpxt3.js} +12 -11
  65. package/dist/{cli-9nfx44eh.js → cli-z780ec7c.js} +1 -1
  66. package/dist/{cli-r2pnvj8g.js → cli-zm9t2vde.js} +2 -2
  67. package/dist/cli.js +24 -23
  68. package/dist/{codex-adapter-y81sssk0.js → codex-adapter-4z16v9n0.js} +8 -8
  69. package/dist/{codex-login-f2rcfpp9.js → codex-login-pkepy2j1.js} +2 -2
  70. package/dist/{codex-session-runner-8vx4j6a9.js → codex-session-runner-rvms9y2x.js} +8 -8
  71. package/dist/{commands-kcpvpcrr.js → commands-2a9rtyat.js} +8 -8
  72. package/dist/{controller-ywrmt6yd.js → controller-3k3madbn.js} +4 -4
  73. package/dist/{controllers-connect-kwpvj3rf.js → controllers-connect-v9yn8zb9.js} +30 -30
  74. package/dist/{cursor-adapter-cehv1qns.js → cursor-adapter-98gbhyzn.js} +4 -4
  75. package/dist/{db-ww5d7jq3.js → db-eywwsnjw.js} +9 -7
  76. package/dist/{db-k8ankj20.js → db-ncypk9np.js} +9 -9
  77. package/dist/{devin-adapter-x1022qb1.js → devin-adapter-zeq8b9gz.js} +2 -2
  78. package/dist/{dispatcher-e8gq5m68.js → dispatcher-w94kc575.js} +26 -26
  79. package/dist/{docker-ff6y1190.js → docker-b1e4cqab.js} +2 -2
  80. package/dist/{dsh-adapter-3epjm3af.js → dsh-adapter-47ztej36.js} +4 -4
  81. package/dist/{e2b-8ygwhg0f.js → e2b-bbt8ey45.js} +3 -3
  82. package/dist/{e2b-swhnpw3n.js → e2b-dp804sen.js} +3 -3
  83. package/dist/{event-bus-6axnngdj.js → event-bus-j2jdg3qb.js} +2 -2
  84. package/dist/{external-channel-h3359cwn.js → external-channel-zrkg8ar9.js} +8 -8
  85. package/dist/{grok-adapter-0d4x7gkx.js → grok-adapter-s9ncp9vy.js} +11 -11
  86. package/dist/{handlers-an1gzv21.js → handlers-kqftvjgw.js} +29 -29
  87. package/dist/{heartbeat-6dakeb61.js → heartbeat-9q96p8v9.js} +31 -31
  88. package/dist/{hook-6v6cv6d5.js → hook-56qe10dp.js} +7 -7
  89. package/dist/{hook-sa5xye4y.js → hook-yy6rw42k.js} +8 -8
  90. package/dist/{http-r7f3eky0.js → http-r2dnb8f8.js} +963 -75
  91. package/dist/{index-7ep4gzdk.js → index-3gtmkhc6.js} +26 -26
  92. package/dist/{index-v1v6n7hg.js → index-3k5hkz7r.js} +31 -31
  93. package/dist/{index-rmpw9a8q.js → index-5e101240.js} +26 -26
  94. package/dist/{index-s2wxgzr0.js → index-5wyfjc2c.js} +392 -15
  95. package/dist/{index-zk96aq1k.js → index-vxvhxxbn.js} +7 -7
  96. package/dist/{keepalive-be9vwy5h.js → keepalive-psj6j54c.js} +9 -9
  97. package/dist/{lead-0861cwrf.js → lead-hajz4688.js} +43 -43
  98. package/dist/{maintenance-mzzgc8bn.js → maintenance-ps02sb5m.js} +7 -7
  99. package/dist/{node-mn1zs7an.js → node-mwb5r697.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-cpdnfwdj.js → oauth-refresh-sweep-0284h93b.js} +8 -8
  101. package/dist/{onboard-fh8qs4r3.js → onboard-rbf6bg01.js} +2 -2
  102. package/dist/{opencode-adapter-nm42wwj8.js → opencode-adapter-13dcatgq.js} +8 -8
  103. package/dist/{otel-impl-x6c1c9g4.js → otel-impl-zvqcbhp7.js} +2 -2
  104. package/dist/{pi-mono-adapter-9n2ya240.js → pi-mono-adapter-xxy47q4v.js} +7 -7
  105. package/dist/{pricing-refresh-qb0rv0ap.js → pricing-refresh-kefx9v3t.js} +9 -9
  106. package/dist/{rbac-roles-1gzdwbh1.js → rbac-roles-3m36rxm2.js} +8 -8
  107. package/dist/{rbac-roles-3mraey52.js → rbac-roles-z76xv0hq.js} +9 -9
  108. package/dist/{render-v2-agpa45zz.js → render-v2-wz73bs25.js} +9 -9
  109. package/dist/{resume-x0ydy9s9.js → resume-k4wzge8w.js} +14 -14
  110. package/dist/{setup-as9ga0d6.js → setup-2fp2pypp.js} +2 -2
  111. package/dist/{work-objects-y5g1y7ev.js → work-objects-6k0ywz3m.js} +29 -29
  112. package/dist/{worker-860c3e38.js → worker-5v3rxsdh.js} +43 -43
  113. package/dist/{x-m477fr0h.js → x-4mtdeym1.js} +2 -2
  114. package/openapi.json +777 -6
  115. package/package.json +8 -7
  116. package/src/agentmail/handlers.ts +21 -11
  117. package/src/apps/definition.ts +16 -0
  118. package/src/azure-devops/api.ts +52 -2
  119. package/src/azure-devops/boards-sync.ts +50 -20
  120. package/src/azure-devops/index.ts +1 -0
  121. package/src/azure-devops/outbound.ts +95 -0
  122. package/src/be/boot-seeds.ts +11 -0
  123. package/src/be/db/model-profiles.ts +315 -0
  124. package/src/be/db/tasks/read.ts +7 -0
  125. package/src/be/db.ts +53 -47
  126. package/src/be/migrations/219_relabel_pi_context_compactions.sql +22 -0
  127. package/src/be/migrations/220_model_profiles.sql +137 -0
  128. package/src/be/model-tier-resolution.ts +145 -11
  129. package/src/be/model-validation.ts +8 -1
  130. package/src/be/scripts/typecheck.ts +12 -0
  131. package/src/be/secret-registry.ts +6 -2
  132. package/src/be/seed-model-profiles.ts +41 -0
  133. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +368 -0
  134. package/src/be/seed-scripts/index.ts +9 -0
  135. package/src/be/swarm-config-guard.ts +1 -0
  136. package/src/commands/runner.ts +3 -17
  137. package/src/extensions/catalog.generated.json +17 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/extensions/loader.ts +10 -5
  140. package/src/heartbeat/heartbeat.ts +2 -1
  141. package/src/http/agents.ts +49 -2
  142. package/src/http/all-routes.ts +1 -0
  143. package/src/http/apps.ts +30 -3
  144. package/src/http/config.ts +26 -2
  145. package/src/http/core.ts +4 -0
  146. package/src/http/index.ts +11 -1
  147. package/src/http/model-profiles.ts +303 -0
  148. package/src/http/pages-public.ts +11 -5
  149. package/src/http/poll.ts +16 -5
  150. package/src/http/script-runs.ts +1 -1
  151. package/src/http/scripts.ts +25 -2
  152. package/src/http/tasks.ts +18 -0
  153. package/src/http/webhooks.ts +1 -2
  154. package/src/pages/feedback-overlay.ts +65 -6
  155. package/src/providers/acp-targets.ts +1 -1
  156. package/src/providers/amp-adapter.ts +1 -1
  157. package/src/providers/dsh-adapter.ts +1 -1
  158. package/src/providers/pi-mono-extension.ts +17 -3
  159. package/src/providers/reasoning-effort.ts +10 -0
  160. package/src/rbac/legacy-policy.ts +8 -0
  161. package/src/rbac/permissions.ts +5 -0
  162. package/src/scheduler/deferred-task-waits.ts +18 -5
  163. package/src/scheduler/scheduler.ts +7 -1
  164. package/src/scripts-runtime/executors/quickjs-prelude.ts +75 -6
  165. package/src/scripts-runtime/executors/quickjs-runner.ts +85 -8
  166. package/src/scripts-runtime/executors/quickjs-web-shims.ts +1041 -0
  167. package/src/scripts-runtime/extract-args-schema.ts +9 -8
  168. package/src/scripts-runtime/quickjs-compat.ts +464 -0
  169. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  170. package/src/scripts-runtime/types/stdlib.d.ts +12 -0
  171. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
  172. package/src/scripts-runtime/types/swarm-sdk.d.ts +12 -0
  173. package/src/server.ts +10 -1
  174. package/src/slack/blocks.ts +4 -2
  175. package/src/slack/reaction-shortcode.ts +14 -0
  176. package/src/slack/render-v2.ts +12 -7
  177. package/src/slack/responses.ts +5 -4
  178. package/src/slack/thread-buffer.ts +41 -22
  179. package/src/tasks/worker-follow-up.ts +11 -2
  180. package/src/tests/agentmail-filters.test.ts +47 -0
  181. package/src/tests/apps-spike2.test.ts +35 -0
  182. package/src/tests/azure-devops-outbound.test.ts +420 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/deferred-task-wake.test.ts +78 -1
  186. package/src/tests/docs-index-footprint.test.js +2 -2
  187. package/src/tests/dsh-adapter.test.ts +1 -1
  188. package/src/tests/env-flag.test.ts +1 -0
  189. package/src/tests/extensions-catalog.test.ts +1 -0
  190. package/src/tests/extensions-example-comms-adherence-gate.test.ts +413 -0
  191. package/src/tests/extensions-loader.test.ts +15 -0
  192. package/src/tests/heartbeat-jev-gate-credentials.test.ts +168 -0
  193. package/src/tests/heartbeat-jev-gate.test.ts +385 -0
  194. package/src/tests/model-profile-backcompat.test.ts +327 -0
  195. package/src/tests/model-profile-providers.test.ts +302 -0
  196. package/src/tests/model-profile-runtime-secrets.test.ts +223 -0
  197. package/src/tests/model-profiles-api.test.ts +614 -0
  198. package/src/tests/pages-feedback-keys.test.ts +121 -0
  199. package/src/tests/pages-feedback.test.ts +3 -0
  200. package/src/tests/pi-context-compaction.test.ts +193 -0
  201. package/src/tests/profile-claim.test.ts +269 -0
  202. package/src/tests/quickjs-compat.test.ts +213 -0
  203. package/src/tests/rbac-engine.test.ts +21 -0
  204. package/src/tests/script-connections.test.ts +4 -0
  205. package/src/tests/script-executor-conformance.test.ts +252 -1
  206. package/src/tests/script-executor-quickjs.test.ts +96 -0
  207. package/src/tests/script-runs-http.test.ts +89 -0
  208. package/src/tests/scripts-http.test.ts +16 -0
  209. package/src/tests/secret-scrubber.test.ts +10 -0
  210. package/src/tests/seed-model-profiles.test.ts +174 -0
  211. package/src/tests/seed-scripts.test.ts +2 -2
  212. package/src/tests/skill-invocations.test.ts +4 -2
  213. package/src/tests/slack-blocks.test.ts +36 -0
  214. package/src/tests/slack-inline-output.test.ts +14 -0
  215. package/src/tests/slack-reaction-config.test.ts +20 -0
  216. package/src/tests/slack-render-v2.test.ts +37 -0
  217. package/src/tests/slack-thread-buffer.test.ts +98 -1
  218. package/src/tests/workflow-llm.test.ts +61 -0
  219. package/src/tools/model-profiles/list-model-profiles.ts +54 -0
  220. package/src/tools/request-human-input.ts +11 -3
  221. package/src/tools/script-apis.ts +5 -1
  222. package/src/tools/script-upsert.ts +4 -1
  223. package/src/tools/send-task.ts +24 -1
  224. package/src/tools/system-one-decide.ts +104 -0
  225. package/src/tools/task-action.ts +22 -1
  226. package/src/tools/tool-config.ts +6 -0
  227. package/src/types.ts +36 -1
  228. package/src/utils/secret-scrubber.ts +19 -12
  229. package/src/utils/vcs-repo-match.ts +29 -0
  230. package/src/workflows/executors/raw-llm.ts +8 -3
  231. package/src/workflows/executors/system-one-decision.ts +21 -11
  232. package/src/workflows/executors/workflow-llm.ts +22 -0
  233. package/src/workflows/index.ts +21 -12
  234. package/templates/extensions/README.md +1 -0
  235. package/templates/extensions/comms-adherence-gate/README.md +88 -0
  236. package/templates/extensions/comms-adherence-gate/hooks.ts +489 -0
  237. package/templates/extensions/comms-adherence-gate/manifest.json +8 -0
  238. package/dist/cli-5t4f29ez.js +0 -33
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Typing in the page feedback overlay must not reach the page's own key
3
+ * handlers. The overlay lives in a shadow root, so outside it the event target
4
+ * is the shadow host and a page's "ignore keys from a textarea" guard misses:
5
+ * Space and arrows typed into a comment used to change slides in a deck page.
6
+ * Page capture listeners on window/document run before anything the overlay
7
+ * registers later, so a key guard is served first in <head>.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { Window } from "happy-dom";
11
+ import { feedbackKeyGuardScript, feedbackOverlayScripts } from "../pages/feedback-overlay";
12
+
13
+ type Target = { dispatchEvent(e: unknown): boolean };
14
+
15
+ function scriptSources(html: string): string[] {
16
+ return [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]!);
17
+ }
18
+
19
+ // Served order: key guard (top of <head>), page scripts, overlay (end of body).
20
+ function overlayPage() {
21
+ const window = new Window({ url: "https://api.example.test/p/abc" });
22
+ const document = window.document;
23
+ document.body.innerHTML = "<h2>Slide title</h2>";
24
+ for (const source of scriptSources(feedbackKeyGuardScript())) window.eval(source);
25
+ // A page's key handlers (e.g. a deck's slide navigation), registered before
26
+ // the overlay, in every phase. They record every key event they see.
27
+ const pageKeys: string[] = [];
28
+ for (const type of ["keydown", "keyup", "keypress"]) {
29
+ const record = (where: string) => (e: Event) =>
30
+ pageKeys.push(`${where}:${type}:${(e as unknown as KeyboardEvent).key}`);
31
+ window.addEventListener(type, record("window-capture"), true);
32
+ document.addEventListener(type, record("document-capture"), true);
33
+ window.addEventListener(type, record("window-bubble"));
34
+ }
35
+ for (const source of scriptSources(feedbackOverlayScripts("abc"))) window.eval(source);
36
+ const root = document.querySelector("swarm-feedback-root")!.shadowRoot!;
37
+ return { window, document, root, pageKeys };
38
+ }
39
+
40
+ function key(window: Window, target: Target, type: string, key: string, init = {}) {
41
+ target.dispatchEvent(
42
+ new window.KeyboardEvent(type, { key, bubbles: true, composed: true, ...init }),
43
+ );
44
+ }
45
+
46
+ function press(window: Window, target: Target, k: string) {
47
+ key(window, target, "keydown", k);
48
+ key(window, target, "keypress", k);
49
+ key(window, target, "keyup", k);
50
+ }
51
+
52
+ function openComposer(window: Window, document: Window["document"], root: ShadowRoot) {
53
+ // Select mode stays on after a comment, so the button is only there the first time.
54
+ const select = [...root.querySelectorAll("button")].find(
55
+ (b) => b.textContent === "Select an element",
56
+ );
57
+ select?.click();
58
+ document.querySelector("h2")!.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
59
+ const comment = root.querySelector("textarea") as unknown as HTMLTextAreaElement | null;
60
+ expect(comment).not.toBeNull();
61
+ return comment!;
62
+ }
63
+
64
+ function savedComments(window: Window): unknown[] {
65
+ return JSON.parse(window.sessionStorage.getItem("swarm-feedback:abc") || "[]");
66
+ }
67
+
68
+ describe("page feedback overlay keys", () => {
69
+ test("keys typed in a comment box never reach page capture or bubble handlers", () => {
70
+ const { window, document, root, pageKeys } = overlayPage();
71
+ const comment = openComposer(window, document, root);
72
+
73
+ for (const k of [" ", "ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "Enter", "PageDown"]) {
74
+ press(window, comment, k);
75
+ }
76
+ expect(pageKeys).toEqual([]);
77
+ // Escape still closes the composer, and the page never sees it.
78
+ key(window, comment, "keydown", "Escape");
79
+ expect(comment.isConnected).toBe(false);
80
+ expect(pageKeys).toEqual([]);
81
+ });
82
+
83
+ test("the release of an overlay key stays in the overlay after the composer closes", () => {
84
+ const { window, document, root, pageKeys } = overlayPage();
85
+
86
+ // Escape: keydown closes the composer, keyup lands on the page body.
87
+ let comment = openComposer(window, document, root);
88
+ key(window, comment, "keydown", "Escape");
89
+ expect(comment.isConnected).toBe(false);
90
+ key(window, document.body, "keyup", "Escape");
91
+
92
+ // Ctrl+Enter: keydown commits and removes the textarea, both keyups land on the body.
93
+ comment = openComposer(window, document, root);
94
+ comment.value = "Bigger title";
95
+ key(window, comment, "keydown", "Control", { ctrlKey: true });
96
+ key(window, comment, "keydown", "Enter", { ctrlKey: true });
97
+ expect(comment.isConnected).toBe(false);
98
+ expect(savedComments(window)).toHaveLength(1);
99
+ key(window, document.body, "keyup", "Enter", { ctrlKey: true });
100
+ key(window, document.body, "keyup", "Control");
101
+ expect(pageKeys).toEqual([]);
102
+
103
+ // The next press that starts on the page is the page's again.
104
+ key(window, document.body, "keydown", "Escape");
105
+ key(window, document.body, "keyup", "Escape");
106
+ expect(pageKeys).toContain("window-bubble:keydown:Escape");
107
+ expect(pageKeys).toContain("window-bubble:keyup:Escape");
108
+ });
109
+
110
+ test("keys outside the overlay still reach the page", () => {
111
+ const { window, document, pageKeys } = overlayPage();
112
+ press(window, document.body, "ArrowRight");
113
+ expect(pageKeys).toEqual(
114
+ ["keydown", "keypress", "keyup"].flatMap((type) => [
115
+ `window-capture:${type}:ArrowRight`,
116
+ `document-capture:${type}:ArrowRight`,
117
+ `window-bubble:${type}:ArrowRight`,
118
+ ]),
119
+ );
120
+ });
121
+ });
@@ -127,6 +127,9 @@ describe("page feedback", () => {
127
127
  const html = await res.text();
128
128
  expect(html).toContain(`window.__swarmFeedback = {"pageId":"${pageId}"};`);
129
129
  expect(html).toContain("swarm-feedback-root");
130
+ // The key guard is served before the page's own markup and scripts.
131
+ expect(html.indexOf("__swarmFeedbackKeys")).toBeGreaterThan(-1);
132
+ expect(html.indexOf("__swarmFeedbackKeys")).toBeLessThan(html.indexOf("<h1>Hello</h1>"));
130
133
  // The agent-authored body and the SDK are still served.
131
134
  expect(html).toContain("<h1>Hello</h1>");
132
135
  expect(html).toContain("class SwarmSDK");
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Pi's `context` hook fires before every LLM call. It must report usage as a
3
+ * `progress` snapshot; only `session_compact` reports a compaction. Migration
4
+ * 219 relabels the trigger-less pi compaction rows written before the fix.
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
9
+ import { unlink } from "node:fs/promises";
10
+ import type {
11
+ ContextEvent,
12
+ ExtensionFactory,
13
+ SessionCompactEvent,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { CreateContextSnapshotInput } from "../be/db";
16
+ import {
17
+ closeDb,
18
+ createAgent,
19
+ createContextSnapshot,
20
+ createTaskExtended,
21
+ getContextSnapshotsByTaskId,
22
+ getContextSummaryByTaskId,
23
+ initDb,
24
+ } from "../be/db";
25
+ import { createSwarmHooksExtension } from "../providers/pi-mono-extension";
26
+
27
+ const TEST_DB_PATH = "./test-pi-context-compaction.sqlite";
28
+ const agentId = "aaaa0000-0000-4000-8000-0000000000a1";
29
+
30
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
31
+
32
+ let db: Database;
33
+ const origFetch = globalThis.fetch;
34
+
35
+ async function removeDbFiles() {
36
+ for (const suffix of ["", "-wal", "-shm"]) {
37
+ try {
38
+ await unlink(TEST_DB_PATH + suffix);
39
+ } catch {
40
+ // File doesn't exist
41
+ }
42
+ }
43
+ }
44
+
45
+ beforeAll(async () => {
46
+ await removeDbFiles();
47
+ db = initDb(TEST_DB_PATH);
48
+ await createAgent({ id: agentId, name: "Pi Worker", isLead: false, status: "idle" });
49
+ });
50
+
51
+ afterEach(() => {
52
+ globalThis.fetch = origFetch;
53
+ });
54
+
55
+ afterAll(async () => {
56
+ closeDb();
57
+ await removeDbFiles();
58
+ });
59
+
60
+ async function createTask(provider: string): Promise<string> {
61
+ const task = await createTaskExtended(`context compaction ${provider}`, {
62
+ agentId,
63
+ source: "mcp",
64
+ });
65
+ db.run("UPDATE agent_tasks SET provider = ? WHERE id = ?", [provider, task.id]);
66
+ return task.id;
67
+ }
68
+
69
+ /**
70
+ * Load the extension and route its `/context` POSTs into the real snapshot
71
+ * writer, the same path `POST /api/tasks/:id/context` takes.
72
+ */
73
+ function loadExtension(taskId: string) {
74
+ const handlers = new Map<string, Handler>();
75
+ const pending: Promise<unknown>[] = [];
76
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
77
+ const urlStr = typeof url === "string" ? url : url.toString();
78
+ if (urlStr.endsWith(`/api/tasks/${taskId}/context`) && init?.method === "POST") {
79
+ const body = JSON.parse(String(init.body)) as Omit<
80
+ CreateContextSnapshotInput,
81
+ "taskId" | "agentId"
82
+ >;
83
+ const write = createContextSnapshot({ ...body, taskId, agentId });
84
+ pending.push(write);
85
+ await write;
86
+ }
87
+ return new Response("{}", { status: 200 });
88
+ }) as typeof fetch;
89
+
90
+ const pi = {
91
+ on: (event: string, handler: Handler) => {
92
+ handlers.set(event, handler);
93
+ },
94
+ sendMessage: () => {},
95
+ registerMcpServer: () => {},
96
+ };
97
+ createSwarmHooksExtension({
98
+ apiUrl: "http://localhost:3013",
99
+ apiKey: "example-test-key",
100
+ agentId,
101
+ taskId,
102
+ isLead: false,
103
+ })(pi as unknown as Parameters<ExtensionFactory>[0]);
104
+
105
+ let tokens = 0;
106
+ const ctx = {
107
+ getContextUsage: () => ({ tokens, contextWindow: 1_000_000, percent: tokens / 10_000 }),
108
+ };
109
+ const fire = async (event: ContextEvent | SessionCompactEvent, usedTokens: number) => {
110
+ tokens = usedTokens;
111
+ await handlers.get(event.type)?.(event, ctx);
112
+ await Promise.all(pending);
113
+ };
114
+ return { fire };
115
+ }
116
+
117
+ describe("pi context hook", () => {
118
+ test("per-call context usage is progress; only session_compact counts", async () => {
119
+ const taskId = await createTask("pi");
120
+ const { fire } = loadExtension(taskId);
121
+
122
+ for (const used of [82_000, 94_000, 94_400]) {
123
+ await fire({ type: "context", messages: [] }, used);
124
+ }
125
+
126
+ let snapshots = await getContextSnapshotsByTaskId(taskId);
127
+ expect(snapshots.length).toBe(3);
128
+ expect(snapshots.filter((s) => s.eventType === "compaction")).toEqual([]);
129
+ let summary = await getContextSummaryByTaskId(taskId);
130
+ expect(summary.compactionCount).toBe(0);
131
+ expect(summary.peakContextTokens).toBe(94_400);
132
+
133
+ await fire(
134
+ {
135
+ type: "session_compact",
136
+ reason: "threshold",
137
+ willRetry: false,
138
+ fromExtension: false,
139
+ compactionEntry: {
140
+ type: "compaction",
141
+ id: "c1",
142
+ parentId: null,
143
+ timestamp: new Date().toISOString(),
144
+ summary: "s",
145
+ firstKeptEntryId: "e1",
146
+ tokensBefore: 120_000,
147
+ },
148
+ },
149
+ 30_000,
150
+ );
151
+
152
+ snapshots = await getContextSnapshotsByTaskId(taskId);
153
+ const compactions = snapshots.filter((s) => s.eventType === "compaction");
154
+ expect(compactions.length).toBe(1);
155
+ expect(compactions[0]?.compactTrigger).toBe("auto");
156
+ expect(compactions[0]?.preCompactTokens).toBe(120_000);
157
+ summary = await getContextSummaryByTaskId(taskId);
158
+ expect(summary.compactionCount).toBe(1);
159
+ });
160
+ });
161
+
162
+ describe("migration 219", () => {
163
+ test("relabels trigger-less pi compactions and leaves other rows alone", async () => {
164
+ const piTask = await createTask("pi");
165
+ const claudeTask = await createTask("claude");
166
+ const snapshot = (taskId: string, sessionId: string, extra = {}) =>
167
+ createContextSnapshot({
168
+ taskId,
169
+ agentId,
170
+ sessionId,
171
+ eventType: "compaction",
172
+ contextPercent: 9,
173
+ ...extra,
174
+ });
175
+
176
+ await snapshot(piTask, `pi-${piTask}`);
177
+ await snapshot(piTask, `pi-${piTask}`);
178
+ await snapshot(piTask, `pi-${piTask}`, { compactTrigger: "auto", preCompactTokens: 150_000 });
179
+ await snapshot(claudeTask, "claude-session");
180
+ expect((await getContextSummaryByTaskId(piTask)).compactionCount).toBe(3);
181
+
182
+ db.exec(await Bun.file("src/be/migrations/219_relabel_pi_context_compactions.sql").text());
183
+
184
+ const piRows = await getContextSnapshotsByTaskId(piTask);
185
+ expect(piRows.map((s) => s.eventType).sort()).toEqual(["compaction", "progress", "progress"]);
186
+ expect(piRows.find((s) => s.eventType === "compaction")?.preCompactTokens).toBe(150_000);
187
+ expect((await getContextSummaryByTaskId(piTask)).compactionCount).toBe(1);
188
+
189
+ const claudeRows = await getContextSnapshotsByTaskId(claudeTask);
190
+ expect(claudeRows.map((s) => s.eventType)).toEqual(["compaction"]);
191
+ expect((await getContextSummaryByTaskId(claudeTask)).compactionCount).toBe(1);
192
+ });
193
+ });
@@ -0,0 +1,269 @@
1
+ // Claim-time resolution of model profiles: a task's own `profile`, the agent's
2
+ // default (MODEL_PROFILE), and how both sit in the precedence next to a raw
3
+ // `model` and a `modelTier`. Drives the real `/api/poll` handler.
4
+
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { unlink } from "node:fs/promises";
7
+ import {
8
+ closeDb,
9
+ createAgent,
10
+ createTaskExtended,
11
+ getDbClient,
12
+ getTaskById,
13
+ initDb,
14
+ upsertSwarmConfig,
15
+ } from "../be/db";
16
+ import { createModelProfile, getAgentDefaultProfileName } from "../be/db/model-profiles";
17
+ import { recordClaimModelResolution } from "../be/model-tier-resolution";
18
+ import { handlePoll } from "../http/poll";
19
+ import { taskModelPin } from "../tasks/model-pin";
20
+ import { inheritedModelSelection } from "../tasks/worker-follow-up";
21
+
22
+ const TEST_DB_PATH = "./test-profile-claim.sqlite";
23
+
24
+ async function removeDbFiles(path: string): Promise<void> {
25
+ for (const suffix of ["", "-wal", "-shm"]) {
26
+ try {
27
+ await unlink(path + suffix);
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
30
+ }
31
+ }
32
+ }
33
+
34
+ type Trigger = { type: string; taskId: string; task: Record<string, unknown> };
35
+
36
+ async function callPoll(agentId: string): Promise<Trigger | null> {
37
+ let bodyStr = "";
38
+ const req = {
39
+ method: "GET",
40
+ url: "/api/poll",
41
+ headers: { "x-agent-id": agentId },
42
+ } as unknown as Parameters<typeof handlePoll>[0];
43
+ const res = {
44
+ setHeader() {},
45
+ writeHead() {},
46
+ end(body?: string) {
47
+ bodyStr = body ?? "";
48
+ },
49
+ } as unknown as Parameters<typeof handlePoll>[1];
50
+ await handlePoll(req, res, ["api", "poll"], new URLSearchParams(), agentId);
51
+ return (JSON.parse(bodyStr) as { trigger: Trigger | null }).trigger;
52
+ }
53
+
54
+ async function claimRecord(id: string) {
55
+ return await getDbClient().get<{
56
+ resolvedModel: string | null;
57
+ modelSource: string | null;
58
+ resolvedProfile: string | null;
59
+ }>("SELECT resolvedModel, modelSource, resolvedProfile FROM agent_tasks WHERE id = ?", [id]);
60
+ }
61
+
62
+ async function worker(name: string, harnessProvider: "claude" | "codex", maxTasks = 1) {
63
+ return await createAgent({ name, isLead: false, status: "idle", maxTasks, harnessProvider });
64
+ }
65
+
66
+ async function setDefault(agentId: string, profile: string): Promise<void> {
67
+ await upsertSwarmConfig({
68
+ scope: "agent",
69
+ scopeId: agentId,
70
+ key: "MODEL_PROFILE",
71
+ value: profile,
72
+ exportToEnv: false,
73
+ });
74
+ }
75
+
76
+ beforeAll(async () => {
77
+ await removeDbFiles(TEST_DB_PATH);
78
+ initDb(TEST_DB_PATH);
79
+ });
80
+
81
+ afterAll(async () => {
82
+ closeDb();
83
+ await removeDbFiles(TEST_DB_PATH);
84
+ });
85
+
86
+ beforeEach(async () => {
87
+ const client = getDbClient();
88
+ // References first: migration 220's triggers refuse deleting a named profile.
89
+ await client.run("DELETE FROM agent_tasks");
90
+ await client.run("DELETE FROM swarm_config WHERE scope IN ('agent', 'repo')");
91
+ await client.run("DELETE FROM swarm_repos");
92
+ await client.run("DELETE FROM agents");
93
+ await client.run("DELETE FROM model_profiles");
94
+ await createModelProfile({
95
+ name: "claude-opus-high",
96
+ harness: "claude",
97
+ model: "opus",
98
+ effort: "high",
99
+ });
100
+ await createModelProfile({ name: "claude-haiku", harness: "claude", model: "haiku" });
101
+ await createModelProfile({
102
+ name: "codex-sol",
103
+ harness: "codex",
104
+ model: "gpt-5.6-sol",
105
+ effort: "xhigh",
106
+ });
107
+ });
108
+
109
+ describe("a task's own profile", () => {
110
+ test("claim stamps the profile and hands its model and effort to the worker", async () => {
111
+ const w = await worker("p-task", "claude");
112
+ const task = await createTaskExtended("work", { agentId: w.id, profile: "claude-opus-high" });
113
+ const trigger = await callPoll(w.id);
114
+
115
+ expect(trigger?.task).toMatchObject({
116
+ resolvedModel: "opus",
117
+ modelSource: "profile:task",
118
+ resolvedProfile: "claude-opus-high",
119
+ effort: "high",
120
+ });
121
+ expect(await claimRecord(task.id)).toEqual({
122
+ resolvedModel: "opus",
123
+ modelSource: "profile:task",
124
+ resolvedProfile: "claude-opus-high",
125
+ });
126
+ // The profile's effort rides on the trigger only; the row keeps the creator's ask.
127
+ expect((await getTaskById(task.id))?.effort).toBeUndefined();
128
+ });
129
+
130
+ test("the task's own effort wins over the profile's", async () => {
131
+ const w = await worker("p-effort", "claude");
132
+ await createTaskExtended("work", { agentId: w.id, profile: "claude-opus-high", effort: "low" });
133
+ expect((await callPoll(w.id))?.task.effort).toBe("low");
134
+ });
135
+
136
+ test("a profile beats the agent default", async () => {
137
+ const w = await worker("p-beats-default", "claude");
138
+ await setDefault(w.id, "claude-haiku");
139
+ const task = await createTaskExtended("work", { agentId: w.id, profile: "claude-opus-high" });
140
+ await callPoll(w.id);
141
+ expect((await claimRecord(task.id))?.resolvedProfile).toBe("claude-opus-high");
142
+ });
143
+
144
+ test("a directed task with another harness's profile fails fast without a spawn", async () => {
145
+ const w = await worker("p-mismatch", "claude");
146
+ await createTaskExtended("work", { agentId: w.id, profile: "codex-sol" });
147
+ const trigger = await callPoll(w.id);
148
+ expect(String(trigger?.task.modelUnsupported)).toContain("[model-harness-mismatch]");
149
+ });
150
+
151
+ test("a task naming a missing profile fails fast with a tagged reason", async () => {
152
+ // Migration 220's triggers keep such a row from existing; the claim still
153
+ // refuses one rather than run a model nobody picked.
154
+ const w = await worker("p-missing", "claude");
155
+ const task = await createTaskExtended("work", { agentId: w.id });
156
+ const fields = await recordClaimModelResolution(
157
+ { id: task.id, profile: "claude-gone" },
158
+ { id: w.id, harnessProvider: "claude" },
159
+ );
160
+ expect(String(fields.modelUnsupported)).toContain("[model-profile-missing]");
161
+ });
162
+
163
+ test("a pool task waits for a worker on the profile's harness", async () => {
164
+ const codex = await worker("p-pool-codex", "codex");
165
+ const claude = await worker("p-pool-claude", "claude");
166
+ const task = await createTaskExtended("pool work", { profile: "claude-opus-high" });
167
+
168
+ expect(await callPoll(codex.id)).toBeNull();
169
+ const trigger = await callPoll(claude.id);
170
+ expect(trigger?.taskId).toBe(task.id);
171
+ expect(trigger?.task.resolvedProfile).toBe("claude-opus-high");
172
+ });
173
+ });
174
+
175
+ describe("the agent's default profile", () => {
176
+ test("applies to a task with no model and no tier", async () => {
177
+ const w = await worker("d-applies", "codex");
178
+ await setDefault(w.id, "codex-sol");
179
+ const task = await createTaskExtended("work", { agentId: w.id });
180
+ const trigger = await callPoll(w.id);
181
+ expect(trigger?.task).toMatchObject({
182
+ resolvedModel: "gpt-5.6-sol",
183
+ modelSource: "profile:agent-default",
184
+ resolvedProfile: "codex-sol",
185
+ effort: "xhigh",
186
+ });
187
+ expect((await claimRecord(task.id))?.modelSource).toBe("profile:agent-default");
188
+ });
189
+
190
+ test("a raw task model and a task tier both win over it", async () => {
191
+ const w = await worker("d-loses", "claude", 2);
192
+ await setDefault(w.id, "claude-opus-high");
193
+ const pinned = await createTaskExtended("pinned", { agentId: w.id, model: "sonnet" });
194
+ const tiered = await createTaskExtended("tiered", { agentId: w.id, modelTier: "smol" });
195
+ await callPoll(w.id);
196
+ await callPoll(w.id);
197
+ expect(await claimRecord(pinned.id)).toEqual({
198
+ resolvedModel: "sonnet",
199
+ modelSource: "model",
200
+ resolvedProfile: null,
201
+ });
202
+ expect(await claimRecord(tiered.id)).toEqual({
203
+ resolvedModel: "haiku",
204
+ modelSource: "tier-default",
205
+ resolvedProfile: null,
206
+ });
207
+ });
208
+
209
+ test("a default for another harness is ignored; a missing one cannot be stored", async () => {
210
+ const w = await worker("d-ignored", "claude", 2);
211
+ await setDefault(w.id, "codex-sol");
212
+ const first = await createTaskExtended("work", { agentId: w.id });
213
+ const trigger = await callPoll(w.id);
214
+ expect(trigger?.task.modelUnsupported).toBeUndefined();
215
+ expect(trigger?.task.resolvedModel).toBeUndefined();
216
+ expect(await claimRecord(first.id)).toEqual({
217
+ resolvedModel: null,
218
+ modelSource: null,
219
+ resolvedProfile: null,
220
+ });
221
+
222
+ await expect(setDefault(w.id, "gone")).rejects.toThrow("model-profile-missing");
223
+ expect(await getAgentDefaultProfileName(w.id)).toBe("codex-sol");
224
+ });
225
+ });
226
+
227
+ describe("re-runs", () => {
228
+ test("a profile-filled model is not a pin, so a re-claim resolves the profile again", async () => {
229
+ const w = await worker("r-rerun", "claude");
230
+ await setDefault(w.id, "claude-opus-high");
231
+ const task = await createTaskExtended("work", { agentId: w.id });
232
+ await callPoll(w.id);
233
+ // The session report writes the model it ran into `model`.
234
+ await getDbClient().run(
235
+ "UPDATE agent_tasks SET model = 'opus', claudeSessionId = 's1', status = 'pending' WHERE id = ?",
236
+ [task.id],
237
+ );
238
+ const ran = await getTaskById(task.id);
239
+ expect(ran && taskModelPin(ran)).toBeUndefined();
240
+
241
+ // The default changes between runs; the re-claim follows it.
242
+ await setDefault(w.id, "claude-haiku");
243
+ const trigger = await callPoll(w.id);
244
+ expect(trigger?.task).toMatchObject({
245
+ resolvedModel: "haiku",
246
+ resolvedProfile: "claude-haiku",
247
+ });
248
+ // Neither the previous run's model (no pin) nor its profile effort carries over.
249
+ expect(trigger?.task.model).toBeUndefined();
250
+ expect(trigger?.task.effort).toBeUndefined();
251
+ });
252
+
253
+ test("a resume carries the task's own profile, not an agent default", async () => {
254
+ const a = await worker("r-resume-own", "claude");
255
+ const b = await worker("r-resume-default", "claude");
256
+ await setDefault(a.id, "claude-opus-high");
257
+ await setDefault(b.id, "claude-opus-high");
258
+ const own = await createTaskExtended("work", { agentId: a.id, profile: "claude-haiku" });
259
+ const viaDefault = await createTaskExtended("work", { agentId: b.id });
260
+ await callPoll(a.id);
261
+ await callPoll(b.id);
262
+
263
+ const ownRow = await getTaskById(own.id);
264
+ const defaultRow = await getTaskById(viaDefault.id);
265
+ expect(ownRow && inheritedModelSelection(ownRow)).toEqual({ profile: "claude-haiku" });
266
+ expect(defaultRow?.resolvedProfile).toBe("claude-opus-high");
267
+ expect(defaultRow && inheritedModelSelection(defaultRow)).toEqual({});
268
+ });
269
+ });