@desplega.ai/agent-swarm 2.7.6 → 2.7.7

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 (244) hide show
  1. package/dist/{acp-adapter-bp91d37f.js → acp-adapter-7b4vk3h5.js} +5 -4
  2. package/dist/{actions-yj0eeaqh.js → actions-3tt153af.js} +18 -18
  3. package/dist/{amp-adapter-4pg9h34q.js → amp-adapter-njpts0yr.js} +1 -1
  4. package/dist/{anthropic-messages-x4q6rfq5.js → anthropic-messages-xey2skwx.js} +7 -7
  5. package/dist/{app-909e97cr.js → app-nky4378f.js} +3 -3
  6. package/dist/{assistant-qdyb5fyy.js → assistant-jnb669pa.js} +19 -19
  7. package/dist/{azure-openai-responses-s1w5n70z.js → azure-openai-responses-26t7j9ra.js} +5 -5
  8. package/dist/{boot-reembed-fk0s9z18.js → boot-reembed-9mgvaztt.js} +1 -1
  9. package/dist/{boot-reembed-gs1rzrnh.js → boot-reembed-qs2djdej.js} +1 -1
  10. package/dist/{boot-scrub-sweep-4qfd4kfx.js → boot-scrub-sweep-yjpdj7s1.js} +3 -3
  11. package/dist/{boot-seeds-yr00k13d.js → boot-seeds-5cm6ambd.js} +4 -4
  12. package/dist/{boxd-vdmfssd9.js → boxd-nv507va8.js} +1 -1
  13. package/dist/{claude-adapter-qta1f1xn.js → claude-adapter-fn287zmm.js} +5 -4
  14. package/dist/{claude-managed-setup-sc6j4dm0.js → claude-managed-setup-wtdz2mj2.js} +1 -1
  15. package/dist/{claude-sdk-session-zgrz6yvc.js → claude-sdk-session-x91kqyhm.js} +8288 -8224
  16. package/dist/{cli-wp9sp1qb.js → cli-08bmz9z9.js} +1 -1
  17. package/dist/{cli-yy3rhsdw.js → cli-1c6tzt8g.js} +7 -7
  18. package/dist/{cli-q0k1w5zd.js → cli-35rsp8ye.js} +1 -1
  19. package/dist/{cli-xnrwazav.js → cli-3fg0bdcv.js} +1 -1
  20. package/dist/{cli-89knwags.js → cli-5b456bpf.js} +2 -2
  21. package/dist/{cli-drr5v6av.js → cli-5jnhv72d.js} +1 -1
  22. package/dist/{cli-dxae2yg7.js → cli-5zx699j9.js} +2 -2
  23. package/dist/{cli-0at8w34w.js → cli-6g0hb4cp.js} +8 -8
  24. package/dist/{cli-e3r2neck.js → cli-6pfd54vx.js} +5 -5
  25. package/dist/{cli-bsn8nhrq.js → cli-6vcp76qk.js} +3 -3
  26. package/dist/{cli-ekwks2vs.js → cli-72v799xn.js} +1 -1
  27. package/dist/cli-82j36f2m.js +45 -0
  28. package/dist/{cli-yx4kfngt.js → cli-8305gbq0.js} +29 -16
  29. package/dist/{cli-4necwtsv.js → cli-8sckt74b.js} +8 -8
  30. package/dist/{cli-vvpx3gsx.js → cli-95jzxcp3.js} +1 -1
  31. package/dist/{cli-hr2e2fft.js → cli-adeegh7d.js} +7 -7
  32. package/dist/{cli-m48g6p95.js → cli-afkqkrtw.js} +3 -3
  33. package/dist/{cli-0qatpy8q.js → cli-ayayvfg4.js} +24 -4
  34. package/dist/{cli-bqt1mptt.js → cli-az48n0nr.js} +4 -4
  35. package/dist/{cli-zm72anhg.js → cli-bfv7vdg6.js} +9 -9
  36. package/dist/{cli-hgaazk69.js → cli-caj33vcq.js} +5 -5
  37. package/dist/{cli-3ypk9vk1.js → cli-cb2a8pna.js} +1 -1
  38. package/dist/{cli-waqbxg08.js → cli-cppkf404.js} +17 -17
  39. package/dist/{cli-yn4kxs06.js → cli-ddaanqkd.js} +4 -4
  40. package/dist/{cli-386tyxxg.js → cli-e0kmphd6.js} +2 -2
  41. package/dist/{cli-54t00gt8.js → cli-e46hg0xw.js} +1 -1
  42. package/dist/{cli-jm2vfpfd.js → cli-e5bzrn01.js} +1 -1
  43. package/dist/{cli-436532ck.js → cli-excnnjg2.js} +165 -153
  44. package/dist/{cli-71ptav7x.js → cli-ferkkrg9.js} +1 -1
  45. package/dist/{cli-vpf89f34.js → cli-fmjd0t1y.js} +3 -3
  46. package/dist/{cli-pd7vagab.js → cli-fyt2g4wd.js} +3 -3
  47. package/dist/{cli-gdhpcbhf.js → cli-g08zdaec.js} +1 -1
  48. package/dist/{cli-ehq736cn.js → cli-g7s47cc5.js} +4 -4
  49. package/dist/{cli-svj5090z.js → cli-gz6q4eve.js} +3 -3
  50. package/dist/{cli-8snvgs5r.js → cli-je518txm.js} +10 -10
  51. package/dist/{cli-rmmmrjkd.js → cli-jtjtabxe.js} +1 -1
  52. package/dist/{cli-611a1294.js → cli-k0wwfqbg.js} +18 -1
  53. package/dist/{cli-es6va8jk.js → cli-k1nje3v3.js} +38 -43
  54. package/dist/{cli-p5gp8pcx.js → cli-k8196sh2.js} +6 -6
  55. package/dist/{cli-nwj0ee2w.js → cli-k899ehvh.js} +2 -2
  56. package/dist/{cli-decfb6v8.js → cli-m0da8j35.js} +23 -9
  57. package/dist/{cli-he9wchme.js → cli-mkk9rnzz.js} +4 -4
  58. package/dist/{cli-nhrvpaj7.js → cli-n6bv12jn.js} +510 -4
  59. package/dist/{cli-8bc1w0tw.js → cli-nmzhp1b5.js} +14 -12
  60. package/dist/{cli-0yv5z2pg.js → cli-p82cnnjq.js} +1 -1
  61. package/dist/{cli-c60vetmm.js → cli-pc6n7fre.js} +1 -1
  62. package/dist/{cli-xds5f77d.js → cli-pjctyea3.js} +2 -2
  63. package/dist/{cli-xbbndvjy.js → cli-pq73h5mz.js} +3 -3
  64. package/dist/{cli-aa6yxc7m.js → cli-pz4rk021.js} +1 -1
  65. package/dist/{cli-xnqgpshy.js → cli-q3tkegby.js} +1 -1
  66. package/dist/cli-qghe0h66.js +67 -0
  67. package/dist/{cli-zbbwez0r.js → cli-qh4d4tvk.js} +4 -4
  68. package/dist/{cli-w6dt9ged.js → cli-rtt8g4v9.js} +1 -1
  69. package/dist/{cli-1h3wcpbw.js → cli-snpj5zbj.js} +2 -2
  70. package/dist/{cli-a7v854rf.js → cli-tgrv9cjx.js} +263 -12
  71. package/dist/{cli-faxe6thg.js → cli-vz22fyqv.js} +186 -54
  72. package/dist/{cli-k2gzwsj2.js → cli-wk5yrfwd.js} +160 -26
  73. package/dist/{cli-shpbx5ee.js → cli-ws19768n.js} +1641 -49
  74. package/dist/{cli-70r77n04.js → cli-xae4g51r.js} +1 -1
  75. package/dist/{cli-kqdg1bsb.js → cli-yndmfxb3.js} +2 -2
  76. package/dist/{cli-bjb3z51z.js → cli-yyj3j5m4.js} +2 -2
  77. package/dist/cli.js +21 -21
  78. package/dist/{cloudflare-workers-ai-system-one-bm60fjm0.js → cloudflare-workers-ai-system-one-ef2a980q.js} +2 -2
  79. package/dist/{codex-adapter-ayd1fb6h.js → codex-adapter-cf2yxe9m.js} +3 -2
  80. package/dist/{codex-hook-9efm2x1m.js → codex-hook-a0c6g6t6.js} +1 -1
  81. package/dist/{codex-session-runner-jesa32xc.js → codex-session-runner-6vfzdgbd.js} +3 -2
  82. package/dist/{commands-hanewgf2.js → commands-t3mfy7vn.js} +3 -3
  83. package/dist/{controller-dt1n3c1q.js → controller-q7xrbaz0.js} +3 -3
  84. package/dist/{controllers-connect-xx8x64n5.js → controllers-connect-c6nm70kg.js} +19 -19
  85. package/dist/{cursor-adapter-9s4dwwdw.js → cursor-adapter-ftd81ga8.js} +1 -1
  86. package/dist/{db-3vttcbv4.js → db-dk572x25.js} +3 -3
  87. package/dist/{db-9dv0g1ab.js → db-vemtpmcc.js} +4 -2
  88. package/dist/{dispatcher-tx9hd1sv.js → dispatcher-kh7ecxh9.js} +17 -17
  89. package/dist/{dsh-adapter-007y4ejs.js → dsh-adapter-v8628r89.js} +1 -1
  90. package/dist/{e2b-we6hs6q5.js → e2b-fr12yccx.js} +1 -1
  91. package/dist/{e2b-a19d28rz.js → e2b-x6ywzak3.js} +1 -1
  92. package/dist/{easter-egg-3d-rgbrqb8s.js → easter-egg-3d-nenw4x63.js} +1 -1
  93. package/dist/{embedding-space-ny1e2940.js → embedding-space-0jepb239.js} +6 -6
  94. package/dist/{embeddings-2t386t13.js → embeddings-xxt49yxg.js} +1 -1
  95. package/dist/{execute-pksh0ceh.js → execute-1mhw6e4x.js} +1 -1
  96. package/dist/{external-channel-phgmhmd7.js → external-channel-55v12e60.js} +2 -2
  97. package/dist/{fx-adapter-fy8rr921.js → fx-adapter-bs8wqrk5.js} +6 -5
  98. package/dist/{google-generative-ai-8y3m3k4b.js → google-generative-ai-v94hr7h0.js} +5 -5
  99. package/dist/{google-vertex-w3v98n7b.js → google-vertex-g7vr6cf8.js} +5 -5
  100. package/dist/{grok-adapter-q0abvpaj.js → grok-adapter-gpjqmkcq.js} +6 -5
  101. package/dist/handlers-zxmp1ta4.js +32 -0
  102. package/dist/{heartbeat-kadyek8p.js → heartbeat-k42svnr0.js} +20 -20
  103. package/dist/{hook-9k551xnj.js → hook-114m7hap.js} +4 -3
  104. package/dist/{hook-5zs3msf6.js → hook-6nphweh0.js} +24 -9
  105. package/dist/{http-mhz9vw1f.js → http-r8cc4wz7.js} +1025 -173
  106. package/dist/index-a8phxqnk.js +37 -0
  107. package/dist/{index-vdygbq10.js → index-e3s8wmxy.js} +1 -1
  108. package/dist/{index-q361cg2f.js → index-fpk4bcd9.js} +1 -1
  109. package/dist/index-k6h00t4c.js +30 -0
  110. package/dist/{index-rvr00sg7.js → index-nh3wyqdt.js} +1017 -574
  111. package/dist/index-xrz7te8h.js +33 -0
  112. package/dist/{keepalive-w56f32cg.js → keepalive-x060nt9p.js} +3 -3
  113. package/dist/lead-956sd2yc.js +71 -0
  114. package/dist/{maintenance-7mdgxcea.js → maintenance-2xxfexhf.js} +1 -1
  115. package/dist/{mistral-conversations-4pmwj1c0.js → mistral-conversations-3gjpdb4b.js} +4 -4
  116. package/dist/{oauth-refresh-sweep-29txy9vk.js → oauth-refresh-sweep-fckc4ar5.js} +2 -2
  117. package/dist/{onboard-kpsrkarj.js → onboard-zc48j237.js} +3 -3
  118. package/dist/{openai-codex-responses-44j573nm.js → openai-codex-responses-8pbxq9h5.js} +5 -5
  119. package/dist/{openai-completions-a4gee2a8.js → openai-completions-rm00s912.js} +4 -4
  120. package/dist/{openai-decisions-w4qkrcar.js → openai-decisions-8v957r2r.js} +1 -1
  121. package/dist/{openai-responses-rbp6h5kr.js → openai-responses-agxpf96k.js} +5 -5
  122. package/dist/{opencode-adapter-j25vm3rr.js → opencode-adapter-r690h1cr.js} +4 -3
  123. package/dist/{otel-impl-6kkct7a8.js → otel-impl-r5xbh81q.js} +4 -4
  124. package/dist/{pi-mono-adapter-exhkzhn2.js → pi-mono-adapter-c7v8sdec.js} +7 -6
  125. package/dist/{pricing-refresh-n2x7jh6k.js → pricing-refresh-3pmre11v.js} +3 -3
  126. package/dist/{rbac-roles-81ypx6es.js → rbac-roles-3vbshwsb.js} +1 -1
  127. package/dist/{rbac-roles-b5htkjby.js → rbac-roles-910mmkgz.js} +2 -2
  128. package/dist/{render-v2-qn6jmpk4.js → render-v2-pxykkbab.js} +3 -3
  129. package/dist/{resume-wd30wx23.js → resume-adda0bwy.js} +6 -6
  130. package/dist/{runtime-4hsg7h60.js → runtime-fnbm7stk.js} +1 -1
  131. package/dist/{session-access-5qvfevhk.js → session-access-2fvamrdc.js} +4 -4
  132. package/dist/{session-source-backfill-n1czavbr.js → session-source-backfill-08xvv0sm.js} +2 -2
  133. package/dist/{setup-dnb56qy5.js → setup-1aqmvmh1.js} +1 -1
  134. package/dist/{typesafe-system-one-5mavm259.js → typesafe-system-one-0bmqcxfg.js} +2 -2
  135. package/dist/{virtual-modules-76xxv9fs.js → virtual-modules-d2pjk9j1.js} +5 -5
  136. package/dist/{work-objects-4n21tfkm.js → work-objects-9s0yrm5j.js} +20 -20
  137. package/dist/{worker-follow-up-77hvzxww.js → worker-follow-up-3q78fqmr.js} +17 -17
  138. package/dist/worker-y15hy08h.js +71 -0
  139. package/openapi.json +197 -4
  140. package/package.json +5 -5
  141. package/src/apps/definition.ts +6 -5
  142. package/src/be/db/page-theme-tokens.ts +12 -0
  143. package/src/be/db/tasks/read.ts +9 -1
  144. package/src/be/db/tasks/write.ts +19 -0
  145. package/src/be/db-client.ts +107 -7
  146. package/src/be/db.ts +34 -2
  147. package/src/be/migrations/269_pages_design_system.sql +23 -0
  148. package/src/be/seed/schedules-seeder.ts +0 -10
  149. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +2 -0
  150. package/src/be/seed-scripts/catalog/catalog-report.ts +2 -0
  151. package/src/be/seed-scripts/catalog/memory-eval.ts +2 -0
  152. package/src/be/seed-scripts/catalog/swarm-update-check.ts +150 -0
  153. package/src/be/seed-scripts/index.ts +9 -0
  154. package/src/be/seed-skills/bundled-files.generated.json +3 -3
  155. package/src/be/swarm-config-guard.ts +32 -0
  156. package/src/be/task-auto-title-listener.ts +127 -0
  157. package/src/commands/onboard/agent-fs-version.ts +1 -1
  158. package/src/commands/profile-sync.ts +151 -16
  159. package/src/commands/runner.ts +15 -7
  160. package/src/hooks/hook.ts +20 -9
  161. package/src/http/index.ts +3 -0
  162. package/src/http/pages-public.ts +93 -129
  163. package/src/http/pages.ts +94 -21
  164. package/src/pages/browser-sdk.ts +48 -21
  165. package/src/pages/color-literals.ts +115 -0
  166. package/src/pages/page-head.ts +766 -0
  167. package/src/pages/version.ts +3 -0
  168. package/src/providers/amp-adapter.ts +1 -1
  169. package/src/providers/codex-adapter.ts +1 -0
  170. package/src/providers/codex-app-server.ts +27 -1
  171. package/src/providers/pi-mono-extension.ts +30 -15
  172. package/src/tasks/follow-up-gate.ts +318 -0
  173. package/src/tasks/worker-follow-up.ts +21 -1
  174. package/src/tests/codex-app-server.test.ts +59 -0
  175. package/src/tests/codex-gateway-route.test.ts +50 -1
  176. package/src/tests/create-page-tool.test.ts +501 -0
  177. package/src/tests/db-client-busy-retry.test.ts +81 -0
  178. package/src/tests/db-task-read-contract.test.ts +1 -0
  179. package/src/tests/db-task-write-contract.test.ts +2 -0
  180. package/src/tests/db-tasks-read.test.ts +21 -0
  181. package/src/tests/fixtures/pages/head-v1.html +56 -0
  182. package/src/tests/follow-up-gate-shadow.test.ts +513 -0
  183. package/src/tests/identity-field-budget.test.ts +1 -1
  184. package/src/tests/internal-ai/credentials.test.ts +62 -0
  185. package/src/tests/internal-ai/task-title.test.ts +283 -0
  186. package/src/tests/merge-gate-detect-changes.test.ts +145 -0
  187. package/src/tests/page-proxy.test.ts +1 -1
  188. package/src/tests/page-session-dev-cookie.test.ts +188 -0
  189. package/src/tests/pages-frame-ancestors.test.ts +28 -1
  190. package/src/tests/pages-head-v2.test.ts +534 -0
  191. package/src/tests/pages-http.test.ts +330 -0
  192. package/src/tests/pages-public-html.test.ts +43 -0
  193. package/src/tests/pages-storage.test.ts +182 -0
  194. package/src/tests/pages-versioning.test.ts +72 -0
  195. package/src/tests/profile-sync-stale-writeback.test.ts +334 -0
  196. package/src/tests/profile-sync.test.ts +11 -15
  197. package/src/tests/scheduled-task-required-params-migration.test.ts +3 -1
  198. package/src/tests/script-connections.test.ts +2 -0
  199. package/src/tests/seed-registry.test.ts +28 -6
  200. package/src/tests/seed-schedules.test.ts +61 -8
  201. package/src/tests/seed-scripts.test.ts +142 -2
  202. package/src/tests/swarm-diff.test.ts +121 -2
  203. package/src/tests/task-auto-title-config-guard.test.ts +37 -0
  204. package/src/tests/task-auto-title-listener.test.ts +165 -0
  205. package/src/tests/theme-import.test.ts +758 -0
  206. package/src/tests/themes-page-theme.test.ts +155 -0
  207. package/src/tests/themes-presets.test.ts +172 -0
  208. package/src/tests/workflow-script-principal.test.ts +2 -1
  209. package/src/themes/classics.ts +949 -0
  210. package/src/themes/import.ts +420 -0
  211. package/src/themes/page-fields.ts +40 -0
  212. package/src/themes/page-theme.ts +102 -0
  213. package/src/themes/presets.ts +561 -0
  214. package/src/themes/tokens.ts +218 -0
  215. package/src/tools/app-upsert.ts +1 -1
  216. package/src/tools/create-page.ts +118 -12
  217. package/src/tools/utils.ts +33 -0
  218. package/src/types.ts +45 -3
  219. package/src/utils/constants.ts +19 -0
  220. package/src/utils/follow-up-gate-schedule-ids.ts +13 -0
  221. package/src/utils/internal-ai/credentials.ts +146 -93
  222. package/src/utils/internal-ai/index.ts +10 -0
  223. package/src/utils/internal-ai/models.ts +18 -0
  224. package/src/utils/internal-ai/task-title.ts +282 -0
  225. package/src/utils/page-session.ts +37 -0
  226. package/templates/schedules/daily-status-report/content.md +88 -2
  227. package/templates/schedules/daily-swarm-update-check/content.md +16 -40
  228. package/templates/skills/apps/SKILL.md +1 -1
  229. package/templates/skills/apps/content.md +1 -1
  230. package/templates/skills/heartbeat-runbook/content.md +1 -1
  231. package/templates/skills/pages/config.json +1 -1
  232. package/templates/skills/pages/content.md +539 -86
  233. package/templates/skills/pages/files/examples/annotated-pr.html +21 -12
  234. package/templates/skills/pages/files/examples/multiplayer-board.html +41 -41
  235. package/templates/skills/pages/files/examples/report-page.html +125 -124
  236. package/dist/cli-2pnswgh8.js +0 -189
  237. package/dist/handlers-by3sfxyx.js +0 -32
  238. package/dist/index-4f2pvxae.js +0 -37
  239. package/dist/index-98gz7tmp.js +0 -30
  240. package/dist/index-k08z2cak.js +0 -33
  241. package/dist/lead-enq35nqp.js +0 -70
  242. package/dist/worker-gg1576j3.js +0 -70
  243. package/templates/schedules/daily-workflow-health-audit/config.json +0 -19
  244. package/templates/schedules/daily-workflow-health-audit/content.md +0 -191
@@ -36,15 +36,15 @@
36
36
  "pages": [
37
37
  {
38
38
  "path": "examples/annotated-pr.html",
39
- "content": "<!doctype html>\n<!--\n Annotated-PR page using the auto-injected <swarm-diff> web component.\n\n No <script> import and no bundling: <swarm-diff>, <swarm-diff-jumps> and\n window.swarmUi.renderDiff are injected into every HTML page automatically.\n Each hunk gets id=\"swarm-diff-<file-slug>-<old_start>\" so <swarm-diff-jumps>\n can build the jump list and external links can deep-link a specific hunk.\n-->\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>PR #1234 — console.log cleanup</title>\n</head>\n<body>\n <h1>PR #1234 — cleanup raw <code>console.log</code> calls</h1>\n <p>Replaces ad-hoc logging with the project logger.</p>\n\n <swarm-diff-jumps></swarm-diff-jumps>\n\n <swarm-diff file=\"src/foo.ts\" base-sha=\"abc123\" head-sha=\"def456\">\n { \"hunks\": [\n { \"old_start\": 10, \"old_lines\": 3, \"new_start\": 10, \"new_lines\": 4,\n \"lines\": [\n { \"type\": \"context\", \"text\": \" const x = 1;\" },\n { \"type\": \"del\", \"text\": \"- console.log(x);\" },\n { \"type\": \"add\", \"text\": \"+ logger.info({ x });\" },\n { \"type\": \"add\", \"text\": \"+ return x;\" }\n ],\n \"annotations\": [\n { \"line\": 12, \"severity\": \"warn\", \"text\": \"Avoid raw console.log\" }\n ]\n }\n ] }\n </swarm-diff>\n\n <swarm-diff file=\"src/bar.ts\" base-sha=\"abc123\" head-sha=\"def456\">\n { \"hunks\": [\n { \"old_start\": 5, \"old_lines\": 1, \"new_start\": 5, \"new_lines\": 1,\n \"lines\": [\n { \"type\": \"del\", \"text\": \"- console.error('boom');\" },\n { \"type\": \"add\", \"text\": \"+ logger.error('boom');\" }\n ]\n }\n ] }\n </swarm-diff>\n</body>\n</html>\n"
39
+ "content": "<!doctype html>\n<!--\n Annotated-PR page using the auto-injected <swarm-diff> web component.\n\n No <script> import and no bundling: <swarm-diff>, <swarm-diff-jumps> and\n window.swarmUi.renderDiff are injected into every HTML page automatically.\n\n Each \"text\" is the code line WITHOUT its +, - or space marker: the\n component draws the marker in its own column. old_lines counts the context\n and deleted lines, and new_lines counts the context and added lines. Inside\n the JSON, write < as \\u003c and & as \\u0026.\n\n Each hunk gets id=\"swarm-diff-<slug>\". The slug is <file>-<old_start> in\n lowercase, with each run of characters other than a-z and 0-9 changed to\n one \"-\". So src/foo.ts at old_start 10 gets id=\"swarm-diff-src-foo-ts-10\".\n <swarm-diff-jumps> builds its jump list from these ids, and external links\n can deep-link a specific hunk.\n-->\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>PR #1234: console.log cleanup</title>\n</head>\n<body>\n <h1>PR #1234: cleanup raw <code>console.log</code> calls</h1>\n <p>Replaces ad-hoc logging with the project logger.</p>\n\n <swarm-diff-jumps></swarm-diff-jumps>\n\n <swarm-diff file=\"src/foo.ts\" base-sha=\"abc123\" head-sha=\"def456\">\n { \"hunks\": [\n { \"old_start\": 10, \"old_lines\": 2, \"new_start\": 10, \"new_lines\": 3,\n \"lines\": [\n { \"type\": \"context\", \"text\": \"export function load(id) {\" },\n { \"type\": \"del\", \"text\": \" console.log(id);\" },\n { \"type\": \"add\", \"text\": \" logger.info({ id });\" },\n { \"type\": \"add\", \"text\": \" return cache.get(id);\" }\n ],\n \"annotations\": [\n { \"line\": 12, \"severity\": \"warn\", \"text\": \"Returns undefined on a cache miss\" }\n ]\n }\n ] }\n </swarm-diff>\n\n <swarm-diff file=\"src/bar.ts\" base-sha=\"abc123\" head-sha=\"def456\">\n { \"hunks\": [\n { \"old_start\": 5, \"old_lines\": 1, \"new_start\": 5, \"new_lines\": 1,\n \"lines\": [\n { \"type\": \"del\", \"text\": \"console.error('boom');\" },\n { \"type\": \"add\", \"text\": \"logger.error('boom');\" }\n ]\n }\n ] }\n </swarm-diff>\n</body>\n</html>\n"
40
40
  },
41
41
  {
42
42
  "path": "examples/multiplayer-board.html",
43
- "content": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Shared board</title>\n <style>\n :root {\n color: #252522;\n background: #f7f6f3;\n font-family: \"SF Pro Display\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n font-synthesis: none;\n }\n\n * { box-sizing: border-box; }\n\n body {\n margin: 0;\n min-width: 320px;\n padding: 0;\n background: #f7f6f3;\n color: #252522;\n font-family: \"SF Pro Display\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n }\n\n button, input, textarea { font: inherit; }\n\n button {\n border: 1px solid #252522;\n border-radius: 8px;\n background: #252522;\n color: #fff;\n cursor: pointer;\n padding: 10px 14px;\n }\n\n button:hover { background: #3c3c37; }\n button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #e1a52c; outline-offset: 2px; }\n button:disabled { cursor: not-allowed; opacity: .5; }\n\n .page { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }\n\n header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 24px;\n border-bottom: 1px solid #d9d7d0;\n padding-bottom: 24px;\n }\n\n h1, h2, p { margin-top: 0; }\n h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.04em; margin-bottom: 8px; }\n h2 { font-size: 1.15rem; letter-spacing: -.02em; margin-bottom: 16px; }\n .lede, .muted { color: #67665f; }\n .lede { max-width: 46rem; margin-bottom: 0; }\n\n .viewer {\n min-width: 170px;\n border: 1px solid #d9d7d0;\n border-radius: 8px;\n background: #fff;\n padding: 12px;\n }\n\n .eyebrow {\n color: #67665f;\n display: block;\n font-size: .72rem;\n font-weight: 700;\n letter-spacing: .08em;\n margin-bottom: 4px;\n text-transform: uppercase;\n }\n\n .viewer-name { font-weight: 700; overflow-wrap: anywhere; }\n\n .notice {\n border: 1px solid #d9d7d0;\n border-left: 4px solid #e1a52c;\n border-radius: 8px;\n background: #fffdf8;\n margin: 24px 0;\n min-height: 47px;\n padding: 12px 14px;\n }\n\n .notice[data-tone=\"error\"] { border-left-color: #a34736; background: #fff9f7; }\n .notice[data-tone=\"stale\"] { border-left-color: #e1a52c; background: #fffdf8; }\n .notice:empty { display: none; }\n\n .grid { display: grid; gap: 24px; grid-template-columns: minmax(0, 1.65fr) minmax(260px, .85fr); }\n .panel { border: 1px solid #d9d7d0; border-radius: 12px; background: #fff; padding: 20px; }\n .panel + .panel { margin-top: 24px; }\n\n .add-row { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 16px; }\n input, textarea {\n border: 1px solid #bdbbb4;\n border-radius: 8px;\n background: #fff;\n color: inherit;\n padding: 10px 12px;\n width: 100%;\n }\n\n textarea { min-height: 94px; resize: vertical; }\n label { display: block; font-size: .88rem; font-weight: 650; margin-bottom: 8px; }\n\n .cards, .peers { list-style: none; margin: 0; padding: 0; }\n .cards { border-top: 1px solid #e7e5df; }\n .card {\n align-items: center;\n border-bottom: 1px solid #e7e5df;\n display: flex;\n gap: 12px;\n justify-content: space-between;\n padding: 13px 0;\n }\n\n .card-title { font-weight: 650; overflow-wrap: anywhere; }\n .card-status {\n background: #fff;\n border-color: #bdbbb4;\n color: #252522;\n flex: 0 0 auto;\n font-size: .82rem;\n padding: 7px 10px;\n }\n\n .card-status[data-done=\"true\"] { background: #f6efd9; border-color: #c89429; }\n .empty { color: #67665f; padding: 16px 0 4px; }\n .agent-note { background: #f7f6f3; border-radius: 8px; min-height: 46px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }\n .agent-note:empty::before { color: #67665f; content: \"No note yet. A server-side agent can write one here.\"; }\n\n .peers { display: grid; gap: 8px; }\n .peer {\n border: 1px solid #e7e5df;\n border-radius: 8px;\n display: grid;\n gap: 3px;\n padding: 10px;\n }\n\n .peer-name { font-weight: 650; overflow-wrap: anywhere; }\n .room-meta { color: #67665f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem; margin: 0 0 16px; }\n .save-state { color: #67665f; font-size: .82rem; margin: 8px 0 0; min-height: 1.2em; }\n\n @media (max-width: 720px) {\n .page { padding: 24px 16px 40px; }\n header { align-items: stretch; flex-direction: column; }\n .viewer { min-width: 0; }\n .grid { grid-template-columns: 1fr; }\n .add-row { grid-template-columns: 1fr; }\n }\n </style>\n </head>\n <body>\n <swarm-cursors room=\"match-42\"></swarm-cursors>\n <main class=\"page\">\n <header>\n <div>\n <h1>Shared board</h1>\n <p class=\"lede\">A small board shared by everyone in match-42. Changes appear as peers make them.</p>\n </div>\n <section class=\"viewer\" aria-label=\"Current viewer\">\n <span class=\"eyebrow\">Connected as</span>\n <div class=\"viewer-name\" id=\"viewer-name\">Connecting…</div>\n </section>\n </header>\n\n <div id=\"notice\" class=\"notice\" role=\"status\" aria-live=\"polite\"></div>\n\n <div class=\"grid\">\n <section class=\"panel\" aria-labelledby=\"board-heading\">\n <h2 id=\"board-heading\">Match board</h2>\n <p class=\"room-meta\">Room: match-42 · Schema: 1</p>\n <form id=\"add-card-form\" class=\"add-row\">\n <label for=\"card-title\" class=\"sr-only\" hidden>New card title</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"120\" placeholder=\"Write a card title\" required>\n <button type=\"submit\">Add card</button>\n </form>\n <ul id=\"cards\" class=\"cards\" aria-live=\"polite\"></ul>\n </section>\n\n <aside>\n <section class=\"panel\" aria-labelledby=\"peers-heading\">\n <h2 id=\"peers-heading\">Live peers</h2>\n <p class=\"muted\">Pointers use document-normalized coordinates and line up across viewports.</p>\n <ul id=\"peers\" class=\"peers\" aria-live=\"polite\"></ul>\n </section>\n\n <section class=\"panel\" aria-labelledby=\"agent-note-heading\">\n <h2 id=\"agent-note-heading\">Agent note</h2>\n <div id=\"agent-note\" class=\"agent-note\" aria-live=\"polite\"></div>\n </section>\n </aside>\n </div>\n\n <section class=\"panel\" aria-labelledby=\"lobby-heading\">\n <h2 id=\"lobby-heading\">Lobby</h2>\n <p class=\"room-meta\">Room: lobby · Schema: 1</p>\n <form id=\"lobby-form\">\n <label for=\"lobby-message\">Shared lobby message</label>\n <textarea id=\"lobby-message\" maxlength=\"1000\" placeholder=\"Leave a message for everyone in the lobby\"></textarea>\n <button type=\"submit\">Save lobby message</button>\n <p id=\"lobby-save-state\" class=\"save-state\" aria-live=\"polite\"></p>\n </form>\n </section>\n </main>\n\n <script>\n (() => {\n const ui = {\n viewerName: document.querySelector('#viewer-name'),\n notice: document.querySelector('#notice'),\n cards: document.querySelector('#cards'),\n peers: document.querySelector('#peers'),\n agentNote: document.querySelector('#agent-note'),\n addCardForm: document.querySelector('#add-card-form'),\n cardTitle: document.querySelector('#card-title'),\n lobbyForm: document.querySelector('#lobby-form'),\n lobbyMessage: document.querySelector('#lobby-message'),\n lobbySaveState: document.querySelector('#lobby-save-state')\n };\n\n let board;\n let lobby;\n let peers = [];\n let lobbyDirty = false;\n\n function message(error) {\n return error && error.message ? error.message : String(error);\n }\n\n function showNotice(text, tone) {\n ui.notice.textContent = text || '';\n ui.notice.dataset.tone = tone || '';\n }\n\n function markStale(room, roomName) {\n if (!room.stale) return false;\n showNotice(`${roomName} uses a different schema version. Its saved state remains visible, but this page cannot write changes.`, 'stale');\n return true;\n }\n\n function renderBoard(state) {\n const cards = state && state.cards && typeof state.cards === 'object' ? state.cards : {};\n ui.cards.replaceChildren();\n const entries = Object.entries(cards);\n\n if (!entries.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No cards yet. Add the first card.';\n ui.cards.append(empty);\n }\n\n for (const [id, card] of entries) {\n const item = document.createElement('li');\n item.className = 'card';\n const title = document.createElement('span');\n title.className = 'card-title';\n title.textContent = card && typeof card.title === 'string' ? card.title : 'Untitled card';\n const toggle = document.createElement('button');\n const done = Boolean(card && card.done);\n toggle.className = 'card-status';\n toggle.dataset.done = String(done);\n toggle.type = 'button';\n toggle.textContent = done ? 'Done' : 'Open';\n toggle.setAttribute('aria-label', `Mark ${title.textContent} as ${done ? 'open' : 'done'}`);\n toggle.addEventListener('click', () => toggleCard(id));\n item.append(title, toggle);\n ui.cards.append(item);\n }\n\n ui.agentNote.textContent = state && typeof state.agentNote === 'string' ? state.agentNote : '';\n }\n\n function renderPeers(nextPeers) {\n peers = Array.isArray(nextPeers) ? nextPeers : [];\n ui.peers.replaceChildren();\n\n if (!peers.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No other peers are connected.';\n ui.peers.append(empty);\n return;\n }\n\n for (const peer of peers) {\n const item = document.createElement('li');\n item.className = 'peer';\n const name = document.createElement('span');\n name.className = 'peer-name';\n name.textContent = peer.name || 'Guest';\n item.append(name);\n ui.peers.append(item);\n }\n }\n\n function renderLobby(state) {\n if (lobbyDirty) return;\n ui.lobbyMessage.value = state && typeof state.message === 'string' ? state.message : '';\n }\n\n async function addCard(title) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n state.cards ??= {};\n state.cards[crypto.randomUUID()] = { title, done: false };\n });\n ui.cardTitle.value = '';\n showNotice('Card added.', 'success');\n } catch (error) {\n showNotice(`Could not add the card: ${message(error)}`, 'error');\n }\n }\n\n async function toggleCard(id) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n const card = state.cards && state.cards[id];\n if (card) card.done = !card.done;\n });\n } catch (error) {\n showNotice(`Could not update the card: ${message(error)}`, 'error');\n }\n }\n\n async function saveLobbyMessage() {\n if (markStale(lobby, 'lobby')) return;\n try {\n const value = ui.lobbyMessage.value;\n await lobby.change(state => {\n state.message = value;\n });\n lobbyDirty = false;\n ui.lobbySaveState.textContent = 'Saved to the lobby room.';\n } catch (error) {\n ui.lobbySaveState.textContent = `Could not save: ${message(error)}`;\n showNotice(`Could not save the lobby message: ${message(error)}`, 'error');\n }\n }\n\n async function initialize() {\n try {\n board = await window.swarmSdk.room('match-42', { schemaVersion: 1 });\n lobby = await window.swarmSdk.room('lobby', { schemaVersion: 1 });\n\n const viewer = board.me || lobby.me || {};\n ui.viewerName.textContent = viewer.name || 'Guest';\n\n board.on('change', renderBoard);\n board.on('presence', renderPeers);\n board.on('error', error => showNotice(`match-42 error: ${message(error)}`, 'error'));\n board.on('reset', () => showNotice('match-42 was reset by another participant. The board has resynchronized.', 'stale'));\n lobby.on('change', renderLobby);\n lobby.on('error', error => showNotice(`lobby error: ${message(error)}`, 'error'));\n lobby.on('reset', () => showNotice('lobby was reset by another participant. The lobby has resynchronized.', 'stale'));\n\n renderBoard(board.state);\n renderPeers(board.presence.peers);\n renderLobby(lobby.state);\n markStale(board, 'match-42');\n markStale(lobby, 'lobby');\n\n if (!board.stale) {\n await board.change(state => {\n state.cards ??= {};\n state.agentNote ??= '';\n });\n }\n if (!lobby.stale) {\n await lobby.change(state => {\n state.message ??= '';\n state.activeMatch ??= 'match-42';\n });\n }\n } catch (error) {\n showNotice(`Could not connect to the shared rooms: ${message(error)}`, 'error');\n ui.viewerName.textContent = 'Unavailable';\n }\n }\n\n ui.addCardForm.addEventListener('submit', event => {\n event.preventDefault();\n const title = ui.cardTitle.value.trim();\n if (title) addCard(title);\n });\n ui.lobbyMessage.addEventListener('input', () => { lobbyDirty = true; });\n ui.lobbyForm.addEventListener('submit', event => {\n event.preventDefault();\n saveLobbyMessage();\n });\n window.addEventListener('pagehide', () => {\n if (board) board.close();\n if (lobby) lobby.close();\n });\n\n initialize();\n })();\n </script>\n </body>\n</html>\n"
43
+ "content": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Shared board</title>\n <style>\n :root {\n color: var(--color-foreground);\n background: var(--color-background);\n font-family: var(--font-sans);\n font-synthesis: none;\n }\n\n * { box-sizing: border-box; }\n\n body {\n margin: 0;\n min-width: 320px;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n font-family: var(--font-sans);\n }\n\n button, input, textarea { font: inherit; }\n\n button {\n border: 1px solid var(--color-primary);\n border-radius: var(--radius-md);\n background: var(--color-primary);\n color: var(--color-primary-foreground);\n cursor: pointer;\n padding: 10px 14px;\n }\n\n button:hover { background: color-mix(in oklab, var(--color-primary) 90%, transparent); }\n button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid var(--color-ring); outline-offset: 2px; }\n button:disabled { cursor: not-allowed; opacity: .5; }\n\n .page { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }\n\n header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 24px;\n border-bottom: 1px solid var(--color-border);\n padding-bottom: 24px;\n }\n\n h1, h2, p { margin-top: 0; }\n h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.04em; margin-bottom: 8px; }\n h2 { font-size: 1.15rem; letter-spacing: -.02em; margin-bottom: 16px; }\n .lede, .muted { color: var(--color-muted-foreground); }\n .lede { max-width: 46rem; margin-bottom: 0; }\n\n .viewer {\n min-width: 170px;\n border: 1px solid var(--color-border);\n border-radius: var(--radius-md);\n background: var(--color-card);\n padding: 12px;\n }\n\n .eyebrow {\n color: var(--color-muted-foreground);\n display: block;\n font-size: .72rem;\n font-weight: 700;\n letter-spacing: .08em;\n margin-bottom: 4px;\n text-transform: uppercase;\n }\n\n .viewer-name { font-weight: 700; overflow-wrap: anywhere; }\n\n .notice {\n border: 1px solid var(--color-border);\n border-left: 4px solid var(--color-primary);\n border-radius: var(--radius-md);\n background: var(--color-card);\n margin: 24px 0;\n min-height: 47px;\n padding: 12px 14px;\n }\n\n .notice[data-tone=\"error\"] { border-left-color: var(--color-status-error); background: color-mix(in oklab, var(--color-status-error) 10%, var(--color-card)); }\n .notice[data-tone=\"stale\"] { border-left-color: var(--color-status-warning); background: color-mix(in oklab, var(--color-status-warning) 10%, var(--color-card)); }\n .notice:empty { display: none; }\n\n .grid { display: grid; gap: 24px; grid-template-columns: minmax(0, 1.65fr) minmax(260px, .85fr); }\n .panel { border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-card); padding: 20px; }\n .panel + .panel { margin-top: 24px; }\n\n .add-row { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 16px; }\n input, textarea {\n border: 1px solid var(--color-input);\n border-radius: var(--radius-md);\n background: var(--color-card);\n color: inherit;\n padding: 10px 12px;\n width: 100%;\n }\n\n textarea { min-height: 94px; resize: vertical; }\n label { display: block; font-size: .88rem; font-weight: 650; margin-bottom: 8px; }\n\n .cards, .peers { list-style: none; margin: 0; padding: 0; }\n .cards { border-top: 1px solid var(--color-border-subtle); }\n .card {\n align-items: center;\n border-bottom: 1px solid var(--color-border-subtle);\n display: flex;\n gap: 12px;\n justify-content: space-between;\n padding: 13px 0;\n }\n\n .card-title { font-weight: 650; overflow-wrap: anywhere; }\n .card-status {\n background: var(--color-card);\n border-color: var(--color-input);\n color: var(--color-foreground);\n flex: 0 0 auto;\n font-size: .82rem;\n padding: 7px 10px;\n }\n\n .card-status[data-done=\"true\"] { background: var(--color-status-success); border-color: var(--color-status-success-strong); color: var(--color-status-success-foreground); }\n .empty { color: var(--color-muted-foreground); padding: 16px 0 4px; }\n .agent-note { background: var(--color-muted); border-radius: var(--radius-md); min-height: 46px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }\n .agent-note:empty::before { color: var(--color-muted-foreground); content: \"No note yet. A server-side agent can write one here.\"; }\n\n .peers { display: grid; gap: 8px; }\n .peer {\n border: 1px solid var(--color-border-subtle);\n border-radius: var(--radius-md);\n display: grid;\n gap: 3px;\n padding: 10px;\n }\n\n .peer-name { font-weight: 650; overflow-wrap: anywhere; }\n .room-meta { color: var(--color-muted-foreground); font-family: var(--font-mono); font-size: .75rem; margin: 0 0 16px; }\n .save-state { color: var(--color-muted-foreground); font-size: .82rem; margin: 8px 0 0; min-height: 1.2em; }\n\n @media (max-width: 720px) {\n .page { padding: 24px 16px 40px; }\n header { align-items: stretch; flex-direction: column; }\n .viewer { min-width: 0; }\n .grid { grid-template-columns: 1fr; }\n .add-row { grid-template-columns: 1fr; }\n }\n </style>\n </head>\n <body>\n <swarm-cursors room=\"match-42\"></swarm-cursors>\n <main class=\"page\">\n <header>\n <div>\n <h1>Shared board</h1>\n <p class=\"lede\">A small board shared by everyone in match-42. Changes appear as peers make them.</p>\n </div>\n <section class=\"viewer\" aria-label=\"Current viewer\">\n <span class=\"eyebrow\">Connected as</span>\n <div class=\"viewer-name\" id=\"viewer-name\">Connecting…</div>\n </section>\n </header>\n\n <div id=\"notice\" class=\"notice\" role=\"status\" aria-live=\"polite\"></div>\n\n <div class=\"grid\">\n <section class=\"panel\" aria-labelledby=\"board-heading\">\n <h2 id=\"board-heading\">Match board</h2>\n <p class=\"room-meta\">Room: match-42 · Schema: 1</p>\n <form id=\"add-card-form\" class=\"add-row\">\n <label for=\"card-title\" class=\"sr-only\" hidden>New card title</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"120\" placeholder=\"Write a card title\" required>\n <button type=\"submit\">Add card</button>\n </form>\n <ul id=\"cards\" class=\"cards\" aria-live=\"polite\"></ul>\n </section>\n\n <aside>\n <section class=\"panel\" aria-labelledby=\"peers-heading\">\n <h2 id=\"peers-heading\">Live peers</h2>\n <p class=\"muted\">Pointers use document-normalized coordinates and line up across viewports.</p>\n <ul id=\"peers\" class=\"peers\" aria-live=\"polite\"></ul>\n </section>\n\n <section class=\"panel\" aria-labelledby=\"agent-note-heading\">\n <h2 id=\"agent-note-heading\">Agent note</h2>\n <div id=\"agent-note\" class=\"agent-note\" aria-live=\"polite\"></div>\n </section>\n </aside>\n </div>\n\n <section class=\"panel\" aria-labelledby=\"lobby-heading\">\n <h2 id=\"lobby-heading\">Lobby</h2>\n <p class=\"room-meta\">Room: lobby · Schema: 1</p>\n <form id=\"lobby-form\">\n <label for=\"lobby-message\">Shared lobby message</label>\n <textarea id=\"lobby-message\" maxlength=\"1000\" placeholder=\"Leave a message for everyone in the lobby\"></textarea>\n <button type=\"submit\">Save lobby message</button>\n <p id=\"lobby-save-state\" class=\"save-state\" aria-live=\"polite\"></p>\n </form>\n </section>\n </main>\n\n <script>\n (() => {\n const ui = {\n viewerName: document.querySelector('#viewer-name'),\n notice: document.querySelector('#notice'),\n cards: document.querySelector('#cards'),\n peers: document.querySelector('#peers'),\n agentNote: document.querySelector('#agent-note'),\n addCardForm: document.querySelector('#add-card-form'),\n cardTitle: document.querySelector('#card-title'),\n lobbyForm: document.querySelector('#lobby-form'),\n lobbyMessage: document.querySelector('#lobby-message'),\n lobbySaveState: document.querySelector('#lobby-save-state')\n };\n\n let board;\n let lobby;\n let peers = [];\n let lobbyDirty = false;\n\n function message(error) {\n return error && error.message ? error.message : String(error);\n }\n\n function showNotice(text, tone) {\n ui.notice.textContent = text || '';\n ui.notice.dataset.tone = tone || '';\n }\n\n function markStale(room, roomName) {\n if (!room.stale) return false;\n showNotice(`${roomName} uses a different schema version. Its saved state remains visible, but this page cannot write changes.`, 'stale');\n return true;\n }\n\n function renderBoard(state) {\n const cards = state && state.cards && typeof state.cards === 'object' ? state.cards : {};\n ui.cards.replaceChildren();\n const entries = Object.entries(cards);\n\n if (!entries.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No cards yet. Add the first card.';\n ui.cards.append(empty);\n }\n\n for (const [id, card] of entries) {\n const item = document.createElement('li');\n item.className = 'card';\n const title = document.createElement('span');\n title.className = 'card-title';\n title.textContent = card && typeof card.title === 'string' ? card.title : 'Untitled card';\n const toggle = document.createElement('button');\n const done = Boolean(card && card.done);\n toggle.className = 'card-status';\n toggle.dataset.done = String(done);\n toggle.type = 'button';\n toggle.textContent = done ? 'Done' : 'Open';\n toggle.setAttribute('aria-label', `Mark ${title.textContent} as ${done ? 'open' : 'done'}`);\n toggle.addEventListener('click', () => toggleCard(id));\n item.append(title, toggle);\n ui.cards.append(item);\n }\n\n ui.agentNote.textContent = state && typeof state.agentNote === 'string' ? state.agentNote : '';\n }\n\n function renderPeers(nextPeers) {\n peers = Array.isArray(nextPeers) ? nextPeers : [];\n ui.peers.replaceChildren();\n\n if (!peers.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No other peers are connected.';\n ui.peers.append(empty);\n return;\n }\n\n for (const peer of peers) {\n const item = document.createElement('li');\n item.className = 'peer';\n const name = document.createElement('span');\n name.className = 'peer-name';\n name.textContent = peer.name || 'Guest';\n item.append(name);\n ui.peers.append(item);\n }\n }\n\n function renderLobby(state) {\n if (lobbyDirty) return;\n ui.lobbyMessage.value = state && typeof state.message === 'string' ? state.message : '';\n }\n\n async function addCard(title) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n state.cards ??= {};\n state.cards[crypto.randomUUID()] = { title, done: false };\n });\n ui.cardTitle.value = '';\n showNotice('Card added.', 'success');\n } catch (error) {\n showNotice(`Could not add the card: ${message(error)}`, 'error');\n }\n }\n\n async function toggleCard(id) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n const card = state.cards && state.cards[id];\n if (card) card.done = !card.done;\n });\n } catch (error) {\n showNotice(`Could not update the card: ${message(error)}`, 'error');\n }\n }\n\n async function saveLobbyMessage() {\n if (markStale(lobby, 'lobby')) return;\n try {\n const value = ui.lobbyMessage.value;\n await lobby.change(state => {\n state.message = value;\n });\n lobbyDirty = false;\n ui.lobbySaveState.textContent = 'Saved to the lobby room.';\n } catch (error) {\n ui.lobbySaveState.textContent = `Could not save: ${message(error)}`;\n showNotice(`Could not save the lobby message: ${message(error)}`, 'error');\n }\n }\n\n async function initialize() {\n try {\n board = await window.swarmSdk.room('match-42', { schemaVersion: 1 });\n lobby = await window.swarmSdk.room('lobby', { schemaVersion: 1 });\n\n const viewer = board.me || lobby.me || {};\n ui.viewerName.textContent = viewer.name || 'Guest';\n\n board.on('change', renderBoard);\n board.on('presence', renderPeers);\n board.on('error', error => showNotice(`match-42 error: ${message(error)}`, 'error'));\n board.on('reset', () => showNotice('match-42 was reset by another participant. The board has resynchronized.', 'stale'));\n lobby.on('change', renderLobby);\n lobby.on('error', error => showNotice(`lobby error: ${message(error)}`, 'error'));\n lobby.on('reset', () => showNotice('lobby was reset by another participant. The lobby has resynchronized.', 'stale'));\n\n renderBoard(board.state);\n renderPeers(board.presence.peers);\n renderLobby(lobby.state);\n markStale(board, 'match-42');\n markStale(lobby, 'lobby');\n\n if (!board.stale) {\n await board.change(state => {\n state.cards ??= {};\n state.agentNote ??= '';\n });\n }\n if (!lobby.stale) {\n await lobby.change(state => {\n state.message ??= '';\n state.activeMatch ??= 'match-42';\n });\n }\n } catch (error) {\n showNotice(`Could not connect to the shared rooms: ${message(error)}`, 'error');\n ui.viewerName.textContent = 'Unavailable';\n }\n }\n\n ui.addCardForm.addEventListener('submit', event => {\n event.preventDefault();\n const title = ui.cardTitle.value.trim();\n if (title) addCard(title);\n });\n ui.lobbyMessage.addEventListener('input', () => { lobbyDirty = true; });\n ui.lobbyForm.addEventListener('submit', event => {\n event.preventDefault();\n saveLobbyMessage();\n });\n window.addEventListener('pagehide', () => {\n if (board) board.close();\n if (lobby) lobby.close();\n });\n\n initialize();\n })();\n </script>\n </body>\n</html>\n"
44
44
  },
45
45
  {
46
46
  "path": "examples/report-page.html",
47
- "content": "<!doctype html>\n<!--\n Reference report page — minimalist taste baseline + report density layer.\n\n Copy this as the starting point for any dense report/dashboard page, then\n replace the content. The CSS already encodes the baseline documented in the\n `pages` skill: warm monochrome canvas, single-column 1120px spine, 8/12/16/\n 24/32/48/72 spacing scale, tabular numerals, flat bordered cards, table\n horizontal scroll on mobile, and no decorative gradients or nested cards.\n\n Publish with:\n create_page({\n title: \"Q2 SEO Performance\",\n slug: \"q2-seo-performance\",\n description: \"Human-readable SEO performance report.\",\n contentType: \"text/html\",\n authMode: \"authed\",\n body: <this file's contents>,\n })\n-->\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Q2 SEO Performance</title>\n <style>\n :root {\n color-scheme: light;\n --bg: #f6f4f0;\n --panel: #ffffff;\n --ink: #18181b;\n --muted: #62646a;\n --line: #dedbd2;\n --accent: #2563eb;\n --danger: #b42318;\n --warn: #b54708;\n --ok: #067647;\n --radius: 8px;\n --shadow: 0 1px 2px rgba(24, 24, 27, 0.04);\n }\n * { box-sizing: border-box; }\n body {\n margin: 0;\n background: var(--bg);\n color: var(--ink);\n font-family: \"SF Pro Display\", \"Geist Sans\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n font-size: 16px;\n line-height: 1.55;\n }\n main {\n width: min(1120px, calc(100% - 32px));\n margin: 0 auto;\n padding: 48px 0 72px;\n }\n header { margin-bottom: 32px; }\n .eyebrow {\n margin: 0 0 8px;\n color: var(--muted);\n font-size: 13px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n h1 {\n margin: 0;\n max-width: 780px;\n font-size: clamp(2rem, 4vw, 3rem);\n line-height: 1.05;\n letter-spacing: 0;\n }\n .lede {\n max-width: 760px;\n margin: 16px 0 0;\n color: var(--muted);\n font-size: 18px;\n }\n .metrics {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 12px;\n margin: 32px 0;\n }\n .metric, .section {\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: var(--radius);\n box-shadow: var(--shadow);\n }\n .metric { padding: 18px; }\n .metric strong {\n display: block;\n font-size: 32px;\n line-height: 1;\n font-variant-numeric: tabular-nums;\n }\n .metric span {\n display: block;\n margin-top: 8px;\n color: var(--muted);\n font-size: 13px;\n font-weight: 650;\n }\n .section {\n margin-top: 18px;\n padding: 24px;\n }\n h2 { margin: 0 0 12px; font-size: 24px; line-height: 1.2; }\n h3 { margin: 0 0 8px; font-size: 17px; line-height: 1.3; }\n p { margin: 0 0 12px; }\n .table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }\n table { width: 100%; border-collapse: collapse; min-width: 640px; }\n th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }\n th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }\n td { font-size: 14px; font-variant-numeric: tabular-nums; }\n code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n details { margin-top: 24px; }\n summary { cursor: pointer; font-weight: 700; }\n pre { overflow: auto; padding: 16px; background: #111827; color: #f9fafb; border-radius: var(--radius); }\n @media (max-width: 760px) {\n main { width: min(100% - 24px, 1120px); padding-top: 32px; }\n .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .section { padding: 18px; }\n }\n @media (max-width: 520px) {\n .metrics { grid-template-columns: 1fr; }\n .lede { font-size: 16px; }\n }\n </style>\n</head>\n<body>\n <main>\n <header>\n <p class=\"eyebrow\">Generated 2026-06-04</p>\n <h1>Q2 SEO Performance</h1>\n <p class=\"lede\">Organic traffic grew sharply, but the next gains depend on fixing thin page metadata and publishing three high-intent pillar pages.</p>\n </header>\n\n <section class=\"metrics\" aria-label=\"Key metrics\">\n <div class=\"metric\"><strong>18.6k</strong><span>Organic clicks</span></div>\n <div class=\"metric\"><strong>+50%</strong><span>Quarter over quarter</span></div>\n <div class=\"metric\"><strong>23</strong><span>Metadata fixes</span></div>\n <div class=\"metric\"><strong>3</strong><span>Priority pages</span></div>\n </section>\n\n <section class=\"section\">\n <h2>Summary</h2>\n <p>Start with the conclusion. Add tables only after the reader understands what changed and what to do next.</p>\n <div class=\"table-wrap\">\n <table>\n <thead><tr><th>Metric</th><th>Q1</th><th>Q2</th><th>Change</th></tr></thead>\n <tbody><tr><td>Organic clicks</td><td>12,400</td><td>18,600</td><td>+50%</td></tr></tbody>\n </table>\n </div>\n </section>\n\n <details>\n <summary>Raw evidence</summary>\n <pre>{ \"source\": \"search-console\", \"range\": \"2026-04-01/2026-06-30\" }</pre>\n </details>\n </main>\n</body>\n</html>\n"
47
+ "content": "<!doctype html>\n<!--\n Reference report page for the swarm design system (designVersion 2).\n\n Copy this as the starting point for a dense report or dashboard page, then\n replace the content. It uses only the swarm-* primitives, the theme tokens,\n and a few Tailwind layout utilities. It has zero color literals, so it\n follows the viewer's theme and dark mode inside the dashboard.\n\n Publish with:\n create_page({\n title: \"Q2 SEO Performance\",\n slug: \"q2-seo-performance\",\n description: \"Human-readable SEO performance report.\",\n contentType: \"text/html\",\n authMode: \"authed\",\n body: <this file's contents>,\n })\n-->\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Q2 SEO Performance</title>\n</head>\n<body>\n <main class=\"swarm-page\">\n <header>\n <p class=\"swarm-eyebrow\">Search Console · Generated 2026-06-04</p>\n <h1>Q2 SEO Performance</h1>\n <p class=\"swarm-lede\">Organic clicks grew 50% this quarter. The next gains depend on fixing thin page metadata and on three high-intent pillar pages.</p>\n <span class=\"swarm-badge\" data-tone=\"success\" data-dot>On track</span>\n </header>\n\n <section class=\"swarm-metrics\" aria-label=\"Key metrics\">\n <div class=\"swarm-metric\">\n <div class=\"swarm-metric-value\">18.6k</div>\n <div class=\"swarm-metric-label\">Organic clicks</div>\n </div>\n <div class=\"swarm-metric\" data-tone=\"success\">\n <div class=\"swarm-metric-value\">+50%</div>\n <div class=\"swarm-metric-label\">Quarter over quarter</div>\n </div>\n <div class=\"swarm-metric\" data-tone=\"warning\">\n <div class=\"swarm-metric-value\">23</div>\n <div class=\"swarm-metric-label\">Metadata fixes</div>\n </div>\n <div class=\"swarm-metric\">\n <div class=\"swarm-metric-value\">3</div>\n <div class=\"swarm-metric-label\">Priority pages</div>\n </div>\n </section>\n\n <section class=\"swarm-section\" aria-labelledby=\"findings-heading\">\n <h2 id=\"findings-heading\">Findings</h2>\n <div class=\"swarm-callout\" data-tone=\"warning\">\n <strong>Thin metadata caps growth.</strong> 23 ranking pages share a generic title and description. Fix them before new content ships.\n </div>\n <div class=\"swarm-callout\">\n <strong>One post drives the growth.</strong> The agent swarms post brought about a quarter of the new clicks.\n </div>\n </section>\n\n <section class=\"swarm-section\" aria-labelledby=\"pages-heading\">\n <h2 id=\"pages-heading\">Top pages</h2>\n <div class=\"swarm-toolbar\">\n <input class=\"swarm-input\" id=\"page-filter\" type=\"search\" placeholder=\"Filter pages\" aria-label=\"Filter pages\">\n <div class=\"swarm-segmented\" role=\"group\" aria-label=\"Status\">\n <button type=\"button\" data-status-filter=\"all\" aria-pressed=\"true\">All</button>\n <button type=\"button\" data-status-filter=\"fix\" aria-pressed=\"false\">Needs fix</button>\n </div>\n <button class=\"swarm-button\" id=\"filter-reset\" type=\"button\">Reset</button>\n </div>\n <div class=\"swarm-table-wrap\">\n <table class=\"swarm-table\" id=\"pages-table\">\n <thead>\n <tr><th>Page</th><th>Status</th><th class=\"num\">Q1 clicks</th><th class=\"num\">Q2 clicks</th><th class=\"num\">Change</th></tr>\n </thead>\n <tbody>\n <tr data-status=\"ok\"><td class=\"nowrap\">/pricing</td><td><span class=\"swarm-badge\" data-tone=\"success\">Healthy</span></td><td class=\"num\">4,100</td><td class=\"num\">6,900</td><td class=\"num\">+68%</td></tr>\n <tr data-status=\"fix\"><td class=\"nowrap\">/docs/install</td><td><span class=\"swarm-badge\" data-tone=\"warning\">Thin title</span></td><td class=\"num\">3,200</td><td class=\"num\">3,350</td><td class=\"num\">+5%</td></tr>\n <tr data-status=\"ok\"><td class=\"nowrap\">/blog/agent-swarms</td><td><span class=\"swarm-badge\" data-tone=\"success\">Healthy</span></td><td class=\"num\">1,500</td><td class=\"num\">3,100</td><td class=\"num\">+107%</td></tr>\n <tr data-status=\"fix\"><td class=\"nowrap\">/integrations/slack</td><td><span class=\"swarm-badge\" data-tone=\"error\">No description</span></td><td class=\"num\">1,900</td><td class=\"num\">1,700</td><td class=\"num text-status-error-strong\">-11%</td></tr>\n <tr data-status=\"review\"><td class=\"nowrap\">/changelog</td><td><span class=\"swarm-badge\">Not reviewed</span></td><td class=\"num\">400</td><td class=\"num\">1,150</td><td class=\"num\">+188%</td></tr>\n </tbody>\n </table>\n </div>\n <p class=\"text-muted-foreground\" id=\"filter-empty\" hidden>No pages match the filter.</p>\n </section>\n\n <section class=\"swarm-section\" aria-labelledby=\"actions-heading\">\n <h2 id=\"actions-heading\">Next actions</h2>\n <div class=\"grid gap-4 md:grid-cols-2\">\n <article class=\"swarm-panel\">\n <h3>Fix thin metadata</h3>\n <dl class=\"swarm-kv\">\n <dt>Owner</dt><dd>Content team</dd>\n <dt>Pages</dt><dd>23</dd>\n <dt>Due</dt><dd>2026-07-15</dd>\n </dl>\n </article>\n <article class=\"swarm-panel\">\n <h3>Publish three pillar pages</h3>\n <dl class=\"swarm-kv\">\n <dt>Owner</dt><dd>Growth team</dd>\n <dt>Topics</dt><dd>Agent orchestration, task routing, swarm costs</dd>\n <dt>Due</dt><dd>2026-08-01</dd>\n </dl>\n </article>\n </div>\n <div class=\"mt-4\">\n <a class=\"swarm-button\" data-variant=\"primary\" href=\"https://search.google.com/search-console\">Open Search Console</a>\n </div>\n </section>\n\n <details>\n <summary>Raw evidence</summary>\n <pre>{ \"source\": \"search-console\", \"range\": \"2026-04-01/2026-06-30\" }</pre>\n </details>\n </main>\n\n <script>\n (() => {\n const filter = document.getElementById(\"page-filter\");\n const reset = document.getElementById(\"filter-reset\");\n const empty = document.getElementById(\"filter-empty\");\n const statusButtons = document.querySelectorAll(\"[data-status-filter]\");\n const rows = document.querySelectorAll(\"#pages-table tbody tr\");\n let status = \"all\";\n\n function applyFilter() {\n const query = filter.value.trim().toLowerCase();\n let shown = 0;\n for (const row of rows) {\n const visible =\n row.textContent.toLowerCase().includes(query) &&\n (status === \"all\" || row.dataset.status === status);\n row.style.display = visible ? \"\" : \"none\";\n if (visible) shown += 1;\n }\n empty.hidden = shown > 0;\n }\n\n function setStatus(next) {\n status = next;\n for (const button of statusButtons) {\n button.setAttribute(\"aria-pressed\", String(button.dataset.statusFilter === next));\n }\n applyFilter();\n }\n\n for (const button of statusButtons) {\n button.addEventListener(\"click\", () => setStatus(button.dataset.statusFilter));\n }\n filter.addEventListener(\"input\", applyFilter);\n reset.addEventListener(\"click\", () => {\n filter.value = \"\";\n setStatus(\"all\");\n });\n })();\n </script>\n</body>\n</html>\n"
48
48
  }
49
49
  ],
50
50
  "planning": [
@@ -12,6 +12,11 @@ import {
12
12
  import { envExportRestrictedError, isEnvExportRestrictedKey } from "../utils/config-env-export";
13
13
  import { validateEgressAllowlist } from "../utils/egress-guard";
14
14
  import { parseEnabledTools } from "../utils/enabled-tools";
15
+ import {
16
+ isFollowUpGateScheduleId,
17
+ parseFollowUpGateScheduleIds,
18
+ } from "../utils/follow-up-gate-schedule-ids";
19
+ import { kindForModelString } from "../utils/internal-ai/models";
15
20
  import { normalizeOrgWebsite } from "../utils/org-website";
16
21
  import { parseTaskToolManifest } from "../utils/task-tool-manifest";
17
22
  import { isTierConfigKey, validateTierConfigValue } from "./model-tier-keys";
@@ -306,6 +311,24 @@ export function validateRuntimeTokenTtlHours(value: unknown): string | null {
306
311
  : "Invalid RUNTIME_TOKEN_TTL_HOURS (must be greater than zero and at most 168)";
307
312
  }
308
313
 
314
+ /**
315
+ * `TASK_AUTO_TITLE_MODEL` pins the task-title model: `provider/model-id` for a
316
+ * provider the internal-ai layer can call, or a bare `claude -p` alias. Empty
317
+ * clears the pin. The model id itself is checked at call time (an unknown id
318
+ * logs and falls through to the next candidate).
319
+ */
320
+ export function validateTaskAutoTitleModel(value: unknown): string | null {
321
+ if (typeof value !== "string") return "Invalid TASK_AUTO_TITLE_MODEL (must be a string)";
322
+ const model = value.trim();
323
+ if (!model) return null;
324
+ if (/\s/.test(model) || model.endsWith("/")) {
325
+ return "Invalid TASK_AUTO_TITLE_MODEL (use provider/model-id or a claude -p alias)";
326
+ }
327
+ return kindForModelString(model)
328
+ ? null
329
+ : "Invalid TASK_AUTO_TITLE_MODEL (provider must be openrouter, anthropic, openai, or openai-codex)";
330
+ }
331
+
309
332
  export const validateControllerTokenTtlDays = boundedIntegerValidators(
310
333
  ["RUNTIME_CONTROLLER_TOKEN_TTL_DAYS"],
311
334
  1,
@@ -428,6 +451,13 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
428
451
  // model's weekly window (Fable/Opus/Sonnet). fallback: legacy random pick.
429
452
  ...enumValidator("MODEL_WINDOW_EXHAUSTED_POLICY", ["fail", "fallback"]),
430
453
  ...enumValidator("HEARTBEAT_CHECKLIST_GATE_MODE", ["shadow", "enforce"]),
454
+ // `enforce` is deliberately absent: the follow-up gate only records decisions.
455
+ ...enumValidator("FOLLOW_UP_GATE_MODE", ["off", "shadow"]),
456
+ FOLLOW_UP_GATE_SCHEDULE_IDS: (value: unknown) => {
457
+ const message = "Invalid FOLLOW_UP_GATE_SCHEDULE_IDS (must be comma-separated schedule UUIDs)";
458
+ if (typeof value !== "string") return message;
459
+ return parseFollowUpGateScheduleIds(value).every(isFollowUpGateScheduleId) ? null : message;
460
+ },
431
461
  ...enumValidator("SYSTEM_ONE_DEFAULT_PROVIDER", [...SYSTEM_ONE_DEFAULT_PROVIDER_OPTIONS]),
432
462
  // Codex credits-exhausted cooldown (ms). Permissive on range here (positive
433
463
  // integer) — the worker clamps to [5m, 7d] via resolveCodexCreditsExhaustedCooldownMs.
@@ -525,7 +555,9 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
525
555
  "MODEL_AUTO_UPGRADE",
526
556
  "COMB_ENABLED",
527
557
  "AGENT_FS_PROXY",
558
+ "TASK_AUTO_TITLE_ENABLED",
528
559
  ]),
560
+ TASK_AUTO_TITLE_MODEL: validateTaskAutoTitleModel,
529
561
  ...enumValidator("SLACK_MODE", ["socket", "http"]),
530
562
  ...enumValidator("SLACK_THREAD_STEERING", ["off", "lead", "all"]),
531
563
  ...enumValidator("SLACK_THREAD_STEERING_MODE", ["steer", "queue"]),
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Automatic task titles.
3
+ *
4
+ * Listens for `task.created` (emitted after commit by `createTaskExtended`),
5
+ * asks the cheapest configured model for a short title, and stores it on the
6
+ * task when nobody set one first. The sessions sidebar and task lists read
7
+ * `title` and fall back to the prompt, so a failed or skipped generation
8
+ * changes nothing visible.
9
+ *
10
+ * `TASK_AUTO_TITLE_ENABLED=false` turns the listener off at the next event;
11
+ * `TASK_AUTO_TITLE_MODEL` pins one model (see `buildTaskTitlePlan`).
12
+ */
13
+
14
+ import { getApiKey } from "../utils/api-key";
15
+ import { getMcpBaseUrl } from "../utils/constants";
16
+ import { parseEnvFlag } from "../utils/env-flag";
17
+ import { generateTaskTitle, TASK_TITLE_TIMEOUT_MS } from "../utils/internal-ai/task-title";
18
+ import { scrubSecrets } from "../utils/secret-scrubber";
19
+ import { workflowEventBus } from "../workflows/event-bus";
20
+ import { getTaskById } from "./db/tasks/read";
21
+ import { setTaskTitleIfUnset } from "./db/tasks/write";
22
+
23
+ /** Titles generated at once. Bursts (a workflow fan-out) queue behind this. */
24
+ export const TASK_AUTO_TITLE_CONCURRENCY = 3;
25
+
26
+ export function isTaskAutoTitleEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
27
+ return parseEnvFlag(env.TASK_AUTO_TITLE_ENABLED, true);
28
+ }
29
+
30
+ export interface TaskAutoTitleListenerOptions {
31
+ /** Defaults to `process.env`; read on every event so a config reload applies. */
32
+ env?: NodeJS.ProcessEnv;
33
+ /** Test injection: replaces the model call. */
34
+ generate?: typeof generateTaskTitle;
35
+ }
36
+
37
+ export interface TaskAutoTitleListener {
38
+ stop(): void;
39
+ /** Resolves once every queued task has been processed. */
40
+ idle(): Promise<void>;
41
+ }
42
+
43
+ export function startTaskAutoTitleListener(
44
+ options: TaskAutoTitleListenerOptions = {},
45
+ ): TaskAutoTitleListener {
46
+ const env = options.env ?? process.env;
47
+ const generate = options.generate ?? generateTaskTitle;
48
+ const queue: string[] = [];
49
+ let active = 0;
50
+ let idleWaiters: Array<() => void> = [];
51
+
52
+ const settleIdle = () => {
53
+ if (active > 0 || queue.length > 0) return;
54
+ const waiters = idleWaiters;
55
+ idleWaiters = [];
56
+ for (const resolve of waiters) resolve();
57
+ };
58
+
59
+ const pump = () => {
60
+ while (active < TASK_AUTO_TITLE_CONCURRENCY) {
61
+ const taskId = queue.shift();
62
+ if (!taskId) break;
63
+ active++;
64
+ void titleTask(taskId, env, generate)
65
+ .catch((error) => console.error("[task-title] Failed:", scrubSecrets(String(error))))
66
+ .finally(() => {
67
+ active--;
68
+ pump();
69
+ settleIdle();
70
+ });
71
+ }
72
+ };
73
+
74
+ const onTaskCreated = (data: unknown) => {
75
+ if (!isTaskAutoTitleEnabled(env)) return;
76
+ const taskId = (data as { taskId?: unknown }).taskId;
77
+ if (typeof taskId !== "string" || !taskId) return;
78
+ queue.push(taskId);
79
+ pump();
80
+ };
81
+
82
+ workflowEventBus.on("task.created", onTaskCreated);
83
+ return {
84
+ stop: () => workflowEventBus.off("task.created", onTaskCreated),
85
+ idle: () =>
86
+ new Promise<void>((resolve) => {
87
+ idleWaiters.push(resolve);
88
+ settleIdle();
89
+ }),
90
+ };
91
+ }
92
+
93
+ async function titleTask(
94
+ taskId: string,
95
+ env: NodeJS.ProcessEnv,
96
+ generate: typeof generateTaskTitle,
97
+ ): Promise<void> {
98
+ const task = await getTaskById(taskId);
99
+ if (!task || task.title) return;
100
+ const shortId = taskId.slice(0, 8);
101
+ const startedAt = Date.now();
102
+ const result = await generate({
103
+ task: task.task,
104
+ env,
105
+ // Loopback context for the codex OAuth probe; only used when no API key is set.
106
+ apiUrl: getMcpBaseUrl(),
107
+ apiKey: getApiKey(),
108
+ callerTag: `task-title:${task.source}`,
109
+ signal: AbortSignal.timeout(TASK_TITLE_TIMEOUT_MS),
110
+ });
111
+ if (!result.ok) {
112
+ if (result.reason !== "short-task") {
113
+ console.log(`[task-title] ${shortId}: no title (${result.reason})`);
114
+ }
115
+ return;
116
+ }
117
+ const updated = await setTaskTitleIfUnset(taskId, result.title);
118
+ const elapsed = Date.now() - startedAt;
119
+ // The title derives from task text, so a pasted token could reach the log.
120
+ console.log(
121
+ scrubSecrets(
122
+ `[task-title] ${shortId}: "${result.title}" via ${result.model} in ${elapsed}ms${
123
+ updated ? "" : " (kept the existing title)"
124
+ }`,
125
+ ),
126
+ );
127
+ }
@@ -2,4 +2,4 @@
2
2
  * agent-fs image tag bundled by `agent-swarm onboard --yes`.
3
3
  * `bun run bump:agent-fs` rewrites it together with the other pins.
4
4
  */
5
- export const AGENT_FS_VERSION = "0.18.13";
5
+ export const AGENT_FS_VERSION = "0.18.14";
@@ -27,6 +27,7 @@
27
27
  * but it must be VISIBLE.
28
28
  */
29
29
 
30
+ import { rename } from "node:fs/promises";
30
31
  import { resolveTemplateAsync } from "../prompts/resolver.ts";
31
32
  import type { Agent, ProfileExpectedHashes, SwarmEvent } from "../types.ts";
32
33
  import { MAX_PROFILE_FILE_LENGTH } from "../utils/constants.ts";
@@ -52,10 +53,13 @@ export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
52
53
  // Flow:
53
54
  // 1. Runner writes DB content → /workspace/*.md at session start.
54
55
  // 2. Runner records SHA-256 hashes of the written content (the "baselines").
55
- // 3. At session end, sync compares current file hash against its baseline.
56
+ // 3. Every sync (session end AND on-edit) compares each file's hash against
57
+ // its baseline.
56
58
  // - Hash matches → file untouched by the agent → skip sync (preserves
57
59
  // any DB-side edits Lead made during the session).
58
60
  // - Hash differs → agent modified the file → sync it back to DB.
61
+ // 4. After the API accepts a file, its baseline advances to the synced hash,
62
+ // so a file edited once is not re-sent over a later DB-side edit.
59
63
  // ──────────────────────────────────────────────────────────────────────────
60
64
  export const IDENTITY_BASELINES_PATH = "/tmp/identity-baselines.json";
61
65
 
@@ -337,8 +341,71 @@ export async function prependProfileSyncRejectionBanner(
337
341
  : { prompt, injected: false };
338
342
  }
339
343
 
344
+ /**
345
+ * Serializes every read-modify-write of IDENTITY_BASELINES_PATH across
346
+ * processes: the runner, the Claude hook and the pi extension each advance it,
347
+ * and two unserialized advances drop one another's hashes.
348
+ */
349
+ export const IDENTITY_BASELINES_LOCK_PATH = `${IDENTITY_BASELINES_PATH}.lock`;
350
+ /** The baselines critical section is one small read and write; wait this long for it. */
351
+ const BASELINES_LOCK_WAIT_MS = 5_000;
352
+
353
+ /** Profile-file IO, injectable so tests never touch the real workspace. */
354
+ export interface ProfileFileIO {
355
+ readFile: FileReader;
356
+ writeFile: FileWriter;
357
+ /** Lock for baseline updates; defaults to IDENTITY_BASELINES_LOCK_PATH. */
358
+ baselinesLockPath?: string;
359
+ }
360
+
361
+ export const PROFILE_FILE_IO: ProfileFileIO = {
362
+ readFile: readFileIfExists,
363
+ writeFile: writeFileAtomic,
364
+ };
365
+
366
+ /**
367
+ * Replace the baselines at boot. Taken under the baselines lock so an advance
368
+ * still in flight from an earlier session cannot reapply its pre-boot read; if
369
+ * the lock is unavailable the boot baselines are written anyway.
370
+ */
340
371
  export async function writeIdentityBaselines(baselines: IdentityBaselines): Promise<void> {
341
- await Bun.write(IDENTITY_BASELINES_PATH, JSON.stringify(baselines));
372
+ const write = () => writeFileAtomic(IDENTITY_BASELINES_PATH, JSON.stringify(baselines));
373
+ const locked = await withFileLock(IDENTITY_BASELINES_LOCK_PATH, write, {
374
+ waitMs: BASELINES_LOCK_WAIT_MS,
375
+ });
376
+ if (!locked.acquired) await write();
377
+ }
378
+
379
+ /**
380
+ * The baselines the runner records at boot: the hash of each DB value the
381
+ * workspace file now derives from. An empty HEARTBEAT.md is a valid DB value
382
+ * and is never rewritten at boot, so it is baselined too; otherwise the empty
383
+ * file that survives a restart would count as an edit and be sent over a later
384
+ * DB edit. The setup script is baselined from the entrypoint-composed file.
385
+ */
386
+ export function bootIdentityBaselines(
387
+ profile: {
388
+ soulMd?: string;
389
+ identityMd?: string;
390
+ toolsMd?: string;
391
+ heartbeatMd?: string | null;
392
+ claudeMd?: string;
393
+ },
394
+ setupScriptFile: string | undefined,
395
+ agentId = "unknown",
396
+ ): IdentityBaselines {
397
+ const baselines: IdentityBaselines = {};
398
+ if (profile.soulMd) baselines.soulMd = contentSha256(profile.soulMd);
399
+ if (profile.identityMd) baselines.identityMd = contentSha256(profile.identityMd);
400
+ if (profile.toolsMd) baselines.toolsMd = contentSha256(profile.toolsMd);
401
+ if (typeof profile.heartbeatMd === "string") {
402
+ baselines.heartbeatMd = contentSha256(profile.heartbeatMd);
403
+ }
404
+ if (profile.claudeMd) baselines.claudeMd = contentSha256(profile.claudeMd);
405
+ const setupContent =
406
+ setupScriptFile === undefined ? null : extractSetupScriptContent(setupScriptFile, agentId);
407
+ if (setupContent) baselines.setupScript = contentSha256(setupContent);
408
+ return baselines;
342
409
  }
343
410
 
344
411
  export async function readIdentityBaselines(
@@ -353,6 +420,42 @@ export async function readIdentityBaselines(
353
420
  }
354
421
  }
355
422
 
423
+ /**
424
+ * Record the hashes of profile files the API just accepted: the DB now holds
425
+ * that content, so the same bytes on disk are no longer an edit. The update
426
+ * runs under IDENTITY_BASELINES_LOCK_PATH so concurrent syncs never drop each
427
+ * other's hashes. Best effort: on failure the older baselines stay (logged).
428
+ */
429
+ export async function advanceIdentityBaselines(
430
+ synced: Record<string, string>,
431
+ io: ProfileFileIO = PROFILE_FILE_IO,
432
+ ): Promise<void> {
433
+ if (Object.keys(synced).length === 0) return;
434
+ const update = async () => {
435
+ const baselines = (await readIdentityBaselines(io.readFile)) ?? {};
436
+ for (const [field, content] of Object.entries(synced)) {
437
+ baselines[field] = contentSha256(content);
438
+ }
439
+ await io.writeFile(IDENTITY_BASELINES_PATH, JSON.stringify(baselines));
440
+ };
441
+ try {
442
+ const locked = await withFileLock(
443
+ io.baselinesLockPath ?? IDENTITY_BASELINES_LOCK_PATH,
444
+ update,
445
+ {
446
+ waitMs: BASELINES_LOCK_WAIT_MS,
447
+ },
448
+ );
449
+ if (locked.acquired) return;
450
+ // No flock on this platform: nothing else can serialize it, so update anyway.
451
+ if (locked.reason === "unsupported") return await update();
452
+ console.warn(`[profile-sync] identity baselines not advanced: lock ${locked.reason}`);
453
+ } catch (err) {
454
+ const msg = err instanceof Error ? err.message : String(err);
455
+ console.warn(scrubSecrets(`[profile-sync] identity baselines not advanced: ${msg}`));
456
+ }
457
+ }
458
+
356
459
  /**
357
460
  * Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
358
461
  * Stop hook reads and owns — the runner only uses it as a backstop for an
@@ -581,6 +684,8 @@ export interface ProfileSyncOptions {
581
684
  claudeMdLock?: { path?: string; waitMs?: number };
582
685
  /** Injectable fetch for tests. Defaults to the global `fetch`. */
583
686
  fetchImpl?: typeof fetch;
687
+ /** Injectable profile-file IO for tests. Defaults to the worker's FS. */
688
+ io?: ProfileFileIO;
584
689
  }
585
690
 
586
691
  /**
@@ -658,7 +763,7 @@ export function extractSetupScriptContent(raw: string, agentId = "unknown"): str
658
763
  *
659
764
  * When `baselines` is provided, skips any field whose content hash matches the
660
765
  * baseline (i.e. the file was not modified during the session). This prevents
661
- * session-end sync from clobbering DB-side edits made by Lead.
766
+ * a sync from clobbering DB-side edits made by Lead.
662
767
  */
663
768
  export function buildIdentityPayload(
664
769
  files: {
@@ -729,7 +834,7 @@ export function buildIdentityPayload(
729
834
  export type FileReader = (path: string) => Promise<string | undefined>;
730
835
 
731
836
  /** Default file reader — reads from the worker's local FS via Bun. */
732
- async function readFileIfExists(path: string): Promise<string | undefined> {
837
+ export async function readFileIfExists(path: string): Promise<string | undefined> {
733
838
  try {
734
839
  const file = Bun.file(path);
735
840
  if (!(await file.exists())) return undefined;
@@ -739,6 +844,16 @@ async function readFileIfExists(path: string): Promise<string | undefined> {
739
844
  }
740
845
  }
741
846
 
847
+ /** Writes a file's text. */
848
+ export type FileWriter = (path: string, content: string) => Promise<void>;
849
+
850
+ /** Write via a temp file and rename, so a concurrent reader never sees a partial file. */
851
+ async function writeFileAtomic(path: string, content: string): Promise<void> {
852
+ const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
853
+ await Bun.write(tmp, content);
854
+ await rename(tmp, path);
855
+ }
856
+
742
857
  /**
743
858
  * Compare the five local profile markdown files with the authenticated agent's
744
859
  * stored DB values. This must run inside the agent's local environment; a
@@ -788,11 +903,11 @@ export async function auditProfileDivergences(
788
903
  * `fields` selects which groups to include. The file reader is injectable so
789
904
  * the field-selection / guard logic can be unit-tested without touching the FS.
790
905
  *
791
- * When `changeSource` is `"session_sync"`, loads baseline hashes written at
792
- * session start and skips identity fields whose content hasn't changed — this
793
- * prevents blind-overwriting DB-side edits made by Lead during the session.
794
- * On-edit syncs (`"self_edit"`) bypass baselines entirely since the agent
795
- * explicitly changed the file and the new content should propagate.
906
+ * Loads the baseline hashes and skips identity fields and the setup script
907
+ * whose content hasn't changed — this prevents blind-overwriting DB-side edits
908
+ * made by Lead during the session. That holds for on-edit syncs
909
+ * (`"self_edit"`) too: an edit to one file triggers a sync of every identity
910
+ * file, and the untouched ones still hold the boot-time copy.
796
911
  */
797
912
  export async function collectProfilePayloads(
798
913
  fields: ProfileSyncField[],
@@ -803,7 +918,7 @@ export async function collectProfilePayloads(
803
918
  ): Promise<ProfilePayload[]> {
804
919
  const payloads: ProfilePayload[] = [];
805
920
 
806
- const baselines = changeSource === "session_sync" ? await readIdentityBaselines(readFile) : null;
921
+ const baselines = await readIdentityBaselines(readFile);
807
922
 
808
923
  if (fields.includes("identity")) {
809
924
  const updates = buildIdentityPayload(
@@ -843,7 +958,7 @@ export async function collectProfilePayloads(
843
958
  const raw = await readFile(SETUP_SCRIPT_PATH);
844
959
  if (raw !== undefined) {
845
960
  const content = extractSetupScriptContent(raw, agentId);
846
- if (content !== null) {
961
+ if (content !== null && baselines?.setupScript !== contentSha256(content)) {
847
962
  payloads.push({ label: "setup", body: { setupScript: content, changeSource } });
848
963
  }
849
964
  }
@@ -855,12 +970,13 @@ export async function collectProfilePayloads(
855
970
  /**
856
971
  * POST a single profile update. NON-FATAL but VISIBLE: a non-2xx response or a
857
972
  * thrown error is logged (scrubbed) and swallowed so it never fails the task,
858
- * but — unlike the original copies — it is never silently ignored.
973
+ * but — unlike the original copies — it is never silently ignored. Resolves to
974
+ * whether the API accepted it.
859
975
  */
860
976
  export async function postProfileUpdate(
861
977
  opts: Pick<ProfileSyncOptions, "agentId" | "apiUrl" | "apiKey" | "fetchImpl">,
862
978
  payload: ProfilePayload,
863
- ): Promise<void> {
979
+ ): Promise<boolean> {
864
980
  const doFetch = opts.fetchImpl ?? fetch;
865
981
  try {
866
982
  const resp = await doFetch(`${opts.apiUrl}/api/agents/${opts.agentId}/profile`, {
@@ -885,10 +1001,26 @@ export async function postProfileUpdate(
885
1001
  ),
886
1002
  );
887
1003
  }
1004
+ return resp.ok;
888
1005
  } catch (err) {
889
1006
  const msg = err instanceof Error ? err.message : String(err);
890
1007
  console.warn(scrubSecrets(`[profile-sync] ${payload.label} sync errored: ${msg}`));
1008
+ return false;
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * The baseline-tracked profile fields a payload carried. Only for payloads sent
1014
+ * outside the CLAUDE.md lock: the hook-owned personal CLAUDE.md keeps its own
1015
+ * lineage record, but an unconditional CLAUDE.md sync (the workspace copy, or a
1016
+ * self_edit) is tracked by its baseline like every other file.
1017
+ */
1018
+ function baselineFieldsOf(payload: ProfilePayload): Record<string, string> {
1019
+ const fields: Record<string, string> = {};
1020
+ for (const [field, value] of Object.entries(payload.body)) {
1021
+ if (field !== "changeSource" && typeof value === "string") fields[field] = value;
891
1022
  }
1023
+ return fields;
892
1024
  }
893
1025
 
894
1026
  /**
@@ -912,16 +1044,19 @@ export async function syncProfileFilesToServer(opts: ProfileSyncOptions): Promis
912
1044
  fields.includes("claude") && syncsHookOwnedClaudeMd(claudeMdPath, changeSource);
913
1045
  const unlockedFields = claudeUnderLock ? fields.filter((f) => f !== "claude") : fields;
914
1046
 
1047
+ const io = opts.io ?? PROFILE_FILE_IO;
915
1048
  const payloads = await collectProfilePayloads(
916
1049
  unlockedFields,
917
1050
  changeSource,
918
- readFileIfExists,
1051
+ io.readFile,
919
1052
  claudeMdPath,
920
1053
  opts.agentId,
921
1054
  );
1055
+ const synced: Record<string, string> = {};
922
1056
  for (const payload of payloads) {
923
- await postProfileUpdate(opts, payload);
1057
+ if (await postProfileUpdate(opts, payload)) Object.assign(synced, baselineFieldsOf(payload));
924
1058
  }
1059
+ await advanceIdentityBaselines(synced, io);
925
1060
 
926
1061
  if (claudeUnderLock) {
927
1062
  const { path: lockPath = CLAUDE_MD_LOCK_PATH, waitMs = RUNNER_LOCK_WAIT_MS } =
@@ -933,7 +1068,7 @@ export async function syncProfileFilesToServer(opts: ProfileSyncOptions): Promis
933
1068
  const claude = await collectProfilePayloads(
934
1069
  ["claude"],
935
1070
  changeSource,
936
- readFileIfExists,
1071
+ io.readFile,
937
1072
  claudeMdPath,
938
1073
  opts.agentId,
939
1074
  );