@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
@@ -135,7 +135,7 @@ import {
135
135
  } from "./credential-wait.ts";
136
136
  import { refreshIdentityIfChanged } from "./identity-refresh.ts";
137
137
  import {
138
- contentSha256,
138
+ bootIdentityBaselines,
139
139
  prependProfileSyncRejectionBanner,
140
140
  resolveClaudeMdPath,
141
141
  syncProfileFilesToServer,
@@ -6779,12 +6779,20 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
6779
6779
  // are skipped, which prevents clobbering DB-side edits made by Lead via
6780
6780
  // update-profile during the running session.
6781
6781
  try {
6782
- const baselines: Record<string, string> = {};
6783
- if (agentSoulMd) baselines.soulMd = contentSha256(agentSoulMd);
6784
- if (agentIdentityMd) baselines.identityMd = contentSha256(agentIdentityMd);
6785
- if (agentToolsMd) baselines.toolsMd = contentSha256(agentToolsMd);
6786
- if (agentHeartbeatMd) baselines.heartbeatMd = contentSha256(agentHeartbeatMd);
6787
- if (agentClaudeMd) baselines.claudeMd = contentSha256(agentClaudeMd);
6782
+ // start-up.sh is not rewritten here when it exists: the entrypoint composed
6783
+ // it from the DB at container start, so its agent-managed section is the base.
6784
+ const setupFile = Bun.file("/workspace/start-up.sh");
6785
+ const baselines = bootIdentityBaselines(
6786
+ {
6787
+ soulMd: agentSoulMd,
6788
+ identityMd: agentIdentityMd,
6789
+ toolsMd: agentToolsMd,
6790
+ heartbeatMd: agentHeartbeatMd,
6791
+ claudeMd: agentClaudeMd,
6792
+ },
6793
+ (await setupFile.exists()) ? await setupFile.text() : undefined,
6794
+ agentId,
6795
+ );
6788
6796
  await writeIdentityBaselines(baselines);
6789
6797
  console.log(`[${role}] Recorded identity file baselines for session-end sync`);
6790
6798
  } catch {
package/src/hooks/hook.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "../be/memory/raters/llm";
12
12
  import { materializeClaudeMd, stopClaudeMd } from "../commands/claude-md-session";
13
13
  import {
14
+ advanceIdentityBaselines,
14
15
  buildIndependentIdentityPayloads,
15
16
  contentSha256,
16
17
  fetchWithSyncTimeout,
@@ -148,7 +149,7 @@ export async function postHookProfileUpdate({
148
149
  body: ProfilePayload["body"];
149
150
  label: string;
150
151
  fetchImpl?: typeof fetch;
151
- }): Promise<void> {
152
+ }): Promise<boolean> {
152
153
  try {
153
154
  const response = await fetchImpl(url, {
154
155
  method: "PUT",
@@ -166,9 +167,11 @@ export async function postHookProfileUpdate({
166
167
  ),
167
168
  );
168
169
  }
170
+ return response.ok;
169
171
  } catch (error) {
170
172
  const message = error instanceof Error ? error.message : String(error);
171
173
  console.error(scrubSecrets(`[hook] ${label} profile sync errored: ${message}`));
174
+ return false;
172
175
  }
173
176
  }
174
177
 
@@ -676,10 +679,10 @@ export async function handleHook(): Promise<void> {
676
679
  /**
677
680
  * Sync SOUL.md and IDENTITY.md content back to the server.
678
681
  *
679
- * When `changeSource` is `"session_sync"` (the Stop-hook default), loads
680
- * baseline hashes written at session start and skips any file whose content
681
- * hasn't changed. This prevents the session-end sync from clobbering DB-side
682
- * edits that Lead made via `update-profile` during the running session.
682
+ * Loads the baseline hashes and skips any file whose content hasn't changed.
683
+ * This prevents clobbering DB-side edits that Lead made via `update-profile`
684
+ * during the running session: at Stop, and on a `"self_edit"` too, where an
685
+ * edit to one file re-reads all four.
683
686
  */
684
687
  const syncIdentityFilesToServer = async (
685
688
  agentId: string,
@@ -687,7 +690,7 @@ export async function handleHook(): Promise<void> {
687
690
  ): Promise<void> => {
688
691
  if (!mcpConfig) return;
689
692
 
690
- const baselines = changeSource === "session_sync" ? await readIdentityBaselines() : null;
693
+ const baselines = await readIdentityBaselines();
691
694
 
692
695
  const updates: Record<string, string> = {};
693
696
 
@@ -747,14 +750,19 @@ export async function handleHook(): Promise<void> {
747
750
 
748
751
  if (Object.keys(updates).length === 0) return;
749
752
 
750
- for (const payload of buildIndependentIdentityPayloads(updates, changeSource)) {
751
- await postHookProfileUpdate({
753
+ const synced: Record<string, string> = {};
754
+ for (const [field, value] of Object.entries(updates)) {
755
+ const [payload] = buildIndependentIdentityPayloads({ [field]: value }, changeSource);
756
+ if (!payload) continue;
757
+ const ok = await postHookProfileUpdate({
752
758
  url: `${getBaseUrl()}/api/agents/${agentId}/profile`,
753
759
  headers: mcpConfig.headers,
754
760
  body: payload.body,
755
761
  label: payload.label,
756
762
  });
763
+ if (ok) synced[field] = value;
757
764
  }
765
+ await advanceIdentityBaselines(synced);
758
766
  };
759
767
 
760
768
  /**
@@ -792,9 +800,11 @@ export async function handleHook(): Promise<void> {
792
800
  warnProfileFileTooLarge(agentId, "setupScript", content.length, "hook");
793
801
  return;
794
802
  }
803
+ // Unchanged since it last matched the DB: not an edit, so don't send it.
804
+ if ((await readIdentityBaselines())?.setupScript === contentSha256(content)) return;
795
805
 
796
806
  try {
797
- await fetch(`${getBaseUrl()}/api/agents/${agentId}/profile`, {
807
+ const resp = await fetch(`${getBaseUrl()}/api/agents/${agentId}/profile`, {
798
808
  method: "PUT",
799
809
  headers: {
800
810
  ...mcpConfig.headers,
@@ -802,6 +812,7 @@ export async function handleHook(): Promise<void> {
802
812
  },
803
813
  body: JSON.stringify({ setupScript: content, changeSource }),
804
814
  });
815
+ if (resp.ok) await advanceIdentityBaselines({ setupScript: content });
805
816
  } catch {
806
817
  /* silently fail */
807
818
  }
package/src/http/index.ts CHANGED
@@ -33,6 +33,7 @@ import { startRuntimeSupervisor, stopRuntimeSupervisor } from "../be/runtime-poo
33
33
  import { startScratchScriptGc, stopScratchScriptGc } from "../be/scripts/retention";
34
34
  import { loadSecretRegistry } from "../be/secret-registry";
35
35
  import { seedLegacyCapabilitiesConfig } from "../be/seed-capabilities";
36
+ import { startTaskAutoTitleListener } from "../be/task-auto-title-listener";
36
37
  import { initApiTelemetry } from "../be/telemetry-enable";
37
38
  import { startWorkerNudgeListener } from "../be/worker-nudge-listener";
38
39
  import { initBitbucket } from "../bitbucket";
@@ -465,6 +466,7 @@ const detachRealtime = attachRealtimeTransport(httpServer);
465
466
  const { attachControllerTransport } = await import("./controllers-connect");
466
467
  const detachControllers = attachControllerTransport(httpServer);
467
468
  const stopWorkerNudges = startWorkerNudgeListener();
469
+ const taskAutoTitles = startTaskAutoTitleListener();
468
470
  const stopRoomDeletion = realtimeBus.subscribe("room:namespace-deleted", (namespace) => {
469
471
  void Promise.resolve(removeNamespaceRooms(String(namespace))).catch((error) =>
470
472
  console.error("[rooms] Deletion failed:", scrubSecrets(String(error))),
@@ -474,6 +476,7 @@ globalState.__closeRealtime = () => {
474
476
  detachRealtime();
475
477
  detachControllers();
476
478
  stopWorkerNudges();
479
+ taskAutoTitles.stop();
477
480
  stopRoomDeletion();
478
481
  };
479
482
  globalState.__httpServer = httpServer;
@@ -25,15 +25,26 @@ import type { IncomingMessage, ServerResponse } from "node:http";
25
25
  import { z } from "zod";
26
26
  import { getPage, incrementPageViewCount } from "../be/db";
27
27
  import { BROWSER_SDK_JS, SWARM_UI_JS } from "../pages/browser-sdk";
28
+ import { COLOR_LITERAL_THRESHOLD, countColorLiterals } from "../pages/color-literals";
28
29
  import {
29
30
  feedbackKeyGuardScript,
30
31
  feedbackOverlayScripts,
31
32
  isPageFeedbackRequested,
32
33
  } from "../pages/feedback-overlay";
34
+ import {
35
+ buildPageHeadV2,
36
+ PAGE_HEAD_V1,
37
+ PAGE_MODE_PARAM,
38
+ PAGE_THEME_PARAM,
39
+ } from "../pages/page-head";
40
+ import { resolvePageTheme } from "../themes/page-theme";
33
41
  import { type Page, PageSchema } from "../types";
34
- import { getAppUrl, getConfiguredAppUrls } from "../utils/constants";
35
- import { getNodeEnv } from "../utils/node-env";
36
- import { extractAndVerifyCookie, pageSessionSetCookies } from "../utils/page-session";
42
+ import { getAppUrl, getConfiguredAppUrls, getLocalDevAppOrigins } from "../utils/constants";
43
+ import {
44
+ extractAndVerifyCookie,
45
+ isLocalDevPageRequest,
46
+ pageSessionSetCookies,
47
+ } from "../utils/page-session";
37
48
  import { scrubSecrets } from "../utils/secret-scrubber";
38
49
  import { route } from "./route-def";
39
50
 
@@ -95,96 +106,76 @@ const publicPageJsonRoute = route({
95
106
  // ─── Helpers ────────────────────────────────────────────────────────────────
96
107
 
97
108
  /**
98
- * Inject the BROWSER_SDK script tag into an HTML body. Insert immediately
99
- * after `<head>` if present; otherwise prepend so partial fragments still get
100
- * the SDK. The script is wrapped in `<script>...</script>` with no token
101
- * injection (the SDK relies on server-side header injection at the
102
- * `/@swarm/api/*` proxy boundary).
103
- *
104
- * Also injects `<base target="_blank">` so links inside the iframed page
105
- * open in the parent window — avoids the user being trapped inside an
106
- * iframe by a misbehaving page.
109
+ * The `<head>` defaults for a page's design version (`src/pages/page-head.ts`).
110
+ * Version 1 is the frozen original head and ignores the viewer params. Version
111
+ * 2 resolves the theme: the page `theme`, else the viewer's `__swarm-theme`
112
+ * preset, else Hive, plus the page `themeTokens` on top. It is dark only for
113
+ * `__swarm-mode=dark`, and a `?print=1` export is always light. A body with
114
+ * `COLOR_LITERAL_THRESHOLD` or more hardcoded colors also stays light: its
115
+ * hardcoded light surfaces would get the dark-mode text color.
107
116
  */
117
+ function pageHead(page: Page, queryParams: URLSearchParams, wantsPrint: boolean): string {
118
+ if (page.designVersion === 1) return PAGE_HEAD_V1;
119
+ const theme = resolvePageTheme({
120
+ pageTheme: page.theme,
121
+ viewerTheme: queryParams.get(PAGE_THEME_PARAM),
122
+ overrides: page.themeTokens,
123
+ });
124
+ const dark =
125
+ !wantsPrint &&
126
+ queryParams.get(PAGE_MODE_PARAM) === "dark" &&
127
+ countColorLiterals(page.body) < COLOR_LITERAL_THRESHOLD;
128
+ return buildPageHeadV2({ theme, mode: dark ? "dark" : "light" });
129
+ }
130
+
131
+ /** A doctype at the start of a body (`\s` also matches a BOM). */
132
+ const LEADING_DOCTYPE_RE = /^\s*<!doctype\b[^>]*>/i;
133
+ /** The `<html ...>` start tag right after a leading doctype. */
134
+ const LEADING_HTML_TAG_RE = /^\s*<!doctype\b[^>]*>\s*<html\b[^>]*>/i;
135
+
108
136
  /**
109
- * Default `<head>` injection: `<base>` so links escape the iframe, Tailwind
110
- * Play CDN so agent pages can use utility classes out of the box, Space
111
- * Grotesk / Space Mono fonts to match the swarm SPA, a small reset that
112
- * makes pages theme-aware (light by default) so an agent who writes zero CSS
113
- * still gets a presentable page, and finally the Browser SDK so
114
- * `window.swarmSdk` works.
137
+ * Inject the head defaults and the BROWSER_SDK script tag into an HTML body,
138
+ * right after the first `<head ...>` tag. The script is wrapped in
139
+ * `<script>...</script>` with no token injection (the SDK relies on
140
+ * server-side header injection at the `/@swarm/api/*` proxy boundary).
141
+ *
142
+ * A body without `<head>` (a raw fragment) still gets the SDK:
143
+ * - Design version 1 prepends it, as it always did.
144
+ * - Design version 2 (`standardsMode`) keeps the browser in standards mode,
145
+ * because its defaults are written for it. It prepends `<!doctype html>`
146
+ * when the body has no doctype, then injects after `<html ...>`, else after
147
+ * the doctype. Content before the doctype puts the page in quirks mode.
115
148
  *
116
- * Agent-provided styles can override these defaults. Tailwind utilities stay
117
- * available, but its Preflight reset is disabled to preserve browser heading,
118
- * margin, and list styles.
149
+ * Both head versions start with `<base target="_blank">`, so links inside the
150
+ * iframed page open in a new tab: a misbehaving page cannot trap the user
151
+ * inside the iframe.
119
152
  */
120
- const PAGE_HEAD_DEFAULTS = `<base target="_blank">
121
- <link rel="preconnect" href="https://fonts.googleapis.com">
122
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
123
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
124
- <script src="https://cdn.tailwindcss.com"></script>
125
- <script>
126
- if (window.tailwind) {
127
- window.tailwind.config = { corePlugins: { preflight: false } };
128
- }
129
- </script>
130
- <style>
131
- *, ::before, ::after { box-sizing: border-box; border: 0 solid #e5e7eb; }
132
- hr { border-top-width: 1px; }
133
- img, video { display: block; max-width: 100%; height: auto; }
134
- button, input, optgroup, select, textarea, ::file-selector-button { font: inherit; }
135
- :root {
136
- color-scheme: light;
137
- --swarm-bg: #fbfbfa;
138
- --swarm-card: #ffffff;
139
- --swarm-border: #e5e7eb;
140
- --swarm-text: #111827;
141
- --swarm-muted: #6b7280;
142
- --swarm-primary: #3b82f6;
143
- }
144
- html, body { background: var(--swarm-bg); color: var(--swarm-text); }
145
- body {
146
- font-family: "Space Grotesk", system-ui, sans-serif;
147
- margin: 0;
148
- padding: 24px;
149
- line-height: 1.5;
150
- }
151
- code, pre, kbd, samp { font-family: "Space Mono", ui-monospace, monospace; }
152
- a { color: var(--swarm-primary); }
153
- ::selection { background: var(--swarm-primary); color: #fff; }
154
- @media print {
155
- /* Override theme variables so built-in primitives (swarm-diff, swarm-card)
156
- that read var(--swarm-card) / var(--swarm-border) etc. inline-styled
157
- backgrounds also flip to light. Without this, diff cards stay dark navy
158
- on print. */
159
- :root {
160
- --swarm-bg: #ffffff;
161
- --swarm-card: #ffffff;
162
- --swarm-border: #cccccc;
163
- --swarm-text: #000000;
164
- --swarm-muted: #555555;
165
- }
166
- html, body { background: white !important; color: black !important; }
167
- a { color: black !important; text-decoration: underline; }
168
- /* Hide any swarm chrome the agent (or built-in primitives) tagged with
169
- .no-print. Use this class on annotation badges, jump-list nav, anything
170
- that shouldn't appear in the PDF export. */
171
- .no-print { display: none !important; }
172
- /* Avoid page-break inside cards / diff blocks. */
173
- .swarm-card, swarm-diff { break-inside: avoid; }
174
- }
175
- </style>`;
176
-
177
- function injectBrowserSdk(html: string, headFirst = ""): string {
178
- const injection = `${headFirst}${PAGE_HEAD_DEFAULTS}<script>${BROWSER_SDK_JS}</script><script>${SWARM_UI_JS}</script>`;
179
- // Use the first occurrence of `<head>` (case-insensitive). A page that
180
- // doesn't have a `<head>` element (raw fragment) still gets the SDK at the
181
- // front of the document.
182
- const headOpenMatch = html.match(/<head\b[^>]*>/i);
183
- if (headOpenMatch) {
184
- const idx = headOpenMatch.index! + headOpenMatch[0].length;
185
- return html.slice(0, idx) + injection + html.slice(idx);
186
- }
187
- return injection + html;
153
+ function injectBrowserSdk(
154
+ html: string,
155
+ { head, headFirst, standardsMode }: { head: string; headFirst: string; standardsMode: boolean },
156
+ ): string {
157
+ const injection = `${headFirst}${head}<script>${BROWSER_SDK_JS}</script><script>${SWARM_UI_JS}</script>`;
158
+ const doc = standardsMode && !LEADING_DOCTYPE_RE.test(html) ? `<!doctype html>${html}` : html;
159
+ // The first `<head>` (case-insensitive) wins.
160
+ const leading = standardsMode
161
+ ? (LEADING_HTML_TAG_RE.exec(doc) ?? LEADING_DOCTYPE_RE.exec(doc))
162
+ : null;
163
+ const idx = headTagEnd(doc) ?? (leading ? leading.index + leading[0].length : null);
164
+ if (idx === null) return injection + doc;
165
+ return doc.slice(0, idx) + injection + doc.slice(idx);
166
+ }
167
+
168
+ /**
169
+ * End offset of the first `<head ...>` start tag, or null. It finds the same
170
+ * tag as `/<head\b[^>]*>/i` in linear time. That regex rescans the rest of the
171
+ * body for every `<head` that has no `>` after it, so a planted body could
172
+ * block the API on each view.
173
+ */
174
+ function headTagEnd(doc: string): number | null {
175
+ const start = doc.search(/<head\b/i);
176
+ if (start === -1) return null;
177
+ const close = doc.indexOf(">", start);
178
+ return close === -1 ? null : close + 1;
188
179
  }
189
180
 
190
181
  /**
@@ -350,24 +341,16 @@ function buildCsp(page: Page): string {
350
341
  // `frame-ancestors` lists every origin allowed to iframe `/p/:id`. We must
351
342
  // include the SPA origin(s). Configured app URLs may be comma-separated so
352
343
  // portless dev (`https://ui.swarm.localhost`), a Vite port (`http://localhost:5274`),
353
- // and a tunnel/staging origin can all coexist. Additionally, in non-production
354
- // we always allow `http://localhost:*` and `https://*.localhost` so swapping
355
- // between Vite ports / portless dev doesn't require restarting the API.
344
+ // and a tunnel/staging origin can all coexist. Under an explicit
345
+ // development/test NODE_ENV we also allow the local Vite ports and
346
+ // `*.localhost` so swapping between them doesn't require restarting the API.
347
+ // Unset NODE_ENV (every deployed image) gets no localhost ancestors.
356
348
  const configured = getConfiguredAppUrls();
357
- const devFallbacks =
358
- getNodeEnv() === "production"
359
- ? []
360
- : [
361
- "http://localhost:5274",
362
- "http://localhost:5175",
363
- "http://127.0.0.1:5274",
364
- "http://127.0.0.1:5175",
365
- "https://*.localhost",
366
- "http://*.localhost",
367
- ];
349
+ const devFallbacks = getLocalDevAppOrigins();
368
350
  const embedders = page.authMode === "public" ? getPageFrameAncestors() : [];
369
351
  const ancestors = Array.from(new Set([...configured, ...devFallbacks, ...embedders]));
370
- // Allow Tailwind Play CDN (`cdn.tailwindcss.com`) for scripts, Google
352
+ // Allow Tailwind Play CDN (`cdn.tailwindcss.com`, design version 1; the
353
+ // pinned v4 build of design version 2 comes from jsdelivr) for scripts, Google
371
354
  // Fonts (`fonts.googleapis.com` stylesheets + `fonts.gstatic.com` font
372
355
  // files) for the swarm default typography, and same-origin /@swarm/api/*
373
356
  // for the Browser SDK. Inline scripts/styles remain allowed so
@@ -471,29 +454,6 @@ function extractPasswordCandidate(
471
454
  return decoded.slice(colonIdx + 1);
472
455
  }
473
456
 
474
- /**
475
- * Detect whether the originating request is from a "dev" / localhost context,
476
- * mirroring the same logic used by the bearer-launch endpoint in
477
- * `src/http/pages.ts`. Used to decide whether to issue cookies with
478
- * `SameSite=Lax` (dev) vs `SameSite=None; Secure` (prod).
479
- */
480
- function isLocalhostRequest(req: IncomingMessage): boolean {
481
- if (getNodeEnv() === "production") return false;
482
- // Only emit `SameSite=Lax` (no Secure) when the request comes from the
483
- // SAME http://localhost origin as the API — Lax cookies don't travel on
484
- // cross-site fetches, so portless `*.localhost` setups (SPA on https
485
- // talking to the API on http) must use `SameSite=None; Secure`. Chrome
486
- // treats localhost as a secure origin so Secure is honored on HTTP.
487
- const origin = (req.headers.origin as string | undefined) ?? "";
488
- if (origin === "") {
489
- const rawHost = req.headers.host;
490
- const host = Array.isArray(rawHost) ? rawHost[0] : rawHost;
491
- if (!host) return true; // best-effort dev default
492
- return host.startsWith("localhost") || host.startsWith("127.0.0.1");
493
- }
494
- return origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1");
495
- }
496
-
497
457
  // ─── Handler ────────────────────────────────────────────────────────────────
498
458
 
499
459
  export async function handlePagesPublic(
@@ -562,7 +522,7 @@ export async function handlePagesPublic(
562
522
  }
563
523
  if (matched) {
564
524
  inlineSetCookie = await pageSessionSetCookies(req, page.id, {
565
- dev: isLocalhostRequest(req),
525
+ dev: isLocalDevPageRequest(req),
566
526
  });
567
527
  // fall through to the regular 200 path below.
568
528
  } else {
@@ -669,7 +629,11 @@ export async function handlePagesPublic(
669
629
  // The feedback key guard goes first in <head> so it runs before page scripts.
670
630
  const feedback = isPageFeedbackRequested(queryParams);
671
631
  const html =
672
- injectBrowserSdk(page.body, feedback ? feedbackKeyGuardScript() : "") +
632
+ injectBrowserSdk(page.body, {
633
+ head: pageHead(page, queryParams, wantsPrint),
634
+ headFirst: feedback ? feedbackKeyGuardScript() : "",
635
+ standardsMode: page.designVersion !== 1,
636
+ }) +
673
637
  (wantsPrint ? PRINT_AUTOTRIGGER_SCRIPT : "") +
674
638
  (feedback ? feedbackOverlayScripts(page.id) : "");
675
639
  const headers: Record<string, string | string[]> = {
package/src/http/pages.ts CHANGED
@@ -25,6 +25,8 @@ import { resolveTemplate } from "../prompts/resolver";
25
25
  // Side-effect import: registers `task.page.feedback`.
26
26
  import "../prompts/session-templates";
27
27
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
28
+ import { readThemeFields } from "../themes/page-fields";
29
+ import { THEME_PRESET_IDS } from "../themes/presets";
28
30
  import {
29
31
  AssetKeySchema,
30
32
  type Page,
@@ -32,11 +34,11 @@ import {
32
34
  PageContentTypeSchema,
33
35
  PageSchema,
34
36
  type PageSummary,
37
+ PageThemeTokensInputSchema,
35
38
  PageVersionSchema,
36
39
  } from "../types";
37
40
  import { getAppUrl, getPublicMcpBaseUrl } from "../utils/constants";
38
- import { getNodeEnv } from "../utils/node-env";
39
- import { pageSessionSetCookies } from "../utils/page-session";
41
+ import { isLocalDevPageRequest, pageSessionSetCookies } from "../utils/page-session";
40
42
  import { authUser, getRequestAuth, isOperatorAuth } from "../utils/request-auth-context";
41
43
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
42
44
  import { route } from "./route-def";
@@ -94,12 +96,43 @@ const PageWithShareUrlsSchema = PageSchema.extend({
94
96
  * used, which omits them entirely) — modeled as optional rather than a union
95
97
  * since both shapes share every other field verbatim.
96
98
  */
97
- const PageListItemSchema = PageSchema.partial({ body: true, passwordHash: true }).extend({
99
+ const PageListItemSchema = PageSchema.partial({
100
+ body: true,
101
+ passwordHash: true,
102
+ theme: true,
103
+ themeTokens: true,
104
+ designVersion: true,
105
+ }).extend({
98
106
  favorite: z.boolean(),
99
107
  api_url: z.string(),
100
108
  app_url: z.string(),
101
109
  });
102
110
 
111
+ /** Design fields shared by the create and update bodies. */
112
+ const pageDesignBodyFields = {
113
+ theme: z
114
+ .string()
115
+ .nullable()
116
+ .optional()
117
+ .describe(
118
+ `Theme preset id that restyles the whole page. One of: ${THEME_PRESET_IDS.join(", ")}. Omit to follow the viewer's theme. Pass null on update to clear it.`,
119
+ ),
120
+ themeTokens: PageThemeTokensInputSchema.nullable()
121
+ .optional()
122
+ .describe(
123
+ 'Per-page token overrides, applied on top of the preset. Send { light, dark } maps of token name to CSS value, or { format: "css", css } with a CSS variable block (:root, .dark, @theme). Stored normalized. Names outside the token contract, and values that reference a variable outside it, are ignored and listed in ignoredTokens. Replaces the stored overrides. Pass null on update to clear them.',
124
+ ),
125
+ designVersion: z
126
+ .number()
127
+ .int()
128
+ .min(1)
129
+ .max(2)
130
+ .optional()
131
+ .describe(
132
+ "Design system version. 2 is the swarm design system and the default for new pages. 1 is the original look that older pages keep. Omit on update to keep the stored value.",
133
+ ),
134
+ };
135
+
103
136
  // ─── Route Definitions ──────────────────────────────────────────────────────
104
137
 
105
138
  const createPageRoute = route({
@@ -118,6 +151,7 @@ const createPageRoute = route({
118
151
  password: z.string().min(1).optional(),
119
152
  body: z.string(),
120
153
  needsCredentials: z.array(z.string()).optional(),
154
+ ...pageDesignBodyFields,
121
155
  }),
122
156
  responses: {
123
157
  201: {
@@ -128,9 +162,20 @@ const createPageRoute = route({
128
162
  version: z.literal(1),
129
163
  api_url: z.string(),
130
164
  app_url: z.string(),
165
+ ignoredTokens: z
166
+ .array(z.string())
167
+ .optional()
168
+ .describe(
169
+ "Theme tokens the importer ignored. A bare name is outside the token contract. Other entries give the reason, for example --font-sans (references --font-geist-sans) or --ring (in .card). Absent when none were ignored.",
170
+ ),
171
+ designVersion: z
172
+ .number()
173
+ .int()
174
+ .min(1)
175
+ .describe("The design version of the new page: 2 unless the body set designVersion: 1."),
131
176
  }),
132
177
  },
133
- 400: { description: "Invalid body" },
178
+ 400: { description: "Invalid body, unknown theme, or invalid themeTokens" },
134
179
  409: { description: "Slug already exists for this agent" },
135
180
  },
136
181
  });
@@ -214,12 +259,31 @@ const updatePageRoute = route({
214
259
  password: z.string().min(1).nullable().optional(),
215
260
  body: z.string().optional(),
216
261
  needsCredentials: z.array(z.string()).nullable().optional(),
262
+ ...pageDesignBodyFields,
217
263
  }),
218
264
  responses: {
219
265
  200: {
220
266
  description: "Page updated",
221
- schema: z.object({ id: z.string(), key: AssetKeySchema, version: z.number().int().min(1) }),
267
+ schema: z.object({
268
+ id: z.string(),
269
+ key: AssetKeySchema,
270
+ version: z.number().int().min(1),
271
+ ignoredTokens: z
272
+ .array(z.string())
273
+ .optional()
274
+ .describe(
275
+ "Theme tokens the importer ignored. A bare name is outside the token contract. Other entries give the reason, for example --font-sans (references --font-geist-sans) or --ring (in .card). Absent when none were ignored.",
276
+ ),
277
+ designVersion: z
278
+ .number()
279
+ .int()
280
+ .min(1)
281
+ .describe(
282
+ "The design version the page has after the update. An omitted designVersion keeps the stored one, so an older page stays on 1.",
283
+ ),
284
+ }),
222
285
  },
286
+ 400: { description: "Unknown theme or invalid themeTokens" },
223
287
  404: { description: "Page not found" },
224
288
  413: { description: "Payload too large" },
225
289
  },
@@ -429,7 +493,7 @@ const getPageVersionRoute = route({
429
493
  * Cookie issuance moved to `src/utils/page-session.ts::issuePageSessionCookie`
430
494
  * so the password-flow on `/p/:id` (step-5) can mint cookies via the same
431
495
  * helper. `dev=true` softens the cookie for `http://localhost` (no Secure
432
- * required; SameSite=Lax). Detected by `isDevRequest()` below.
496
+ * required; SameSite=Lax). Detected by `isLocalDevPageRequest()` in `src/utils/page-session.ts`.
433
497
  */
434
498
 
435
499
  /**
@@ -528,20 +592,6 @@ function formatFeedbackComments(comments: z.infer<typeof PageFeedbackCommentSche
528
592
  .join("\n\n");
529
593
  }
530
594
 
531
- function isDevRequest(req: IncomingMessage): boolean {
532
- if (getNodeEnv() === "production") return false;
533
- // We want `SameSite=Lax` only when the request itself comes from the same
534
- // local-`http://localhost` origin as the API — same-site loads tolerate
535
- // Lax without Secure. Anything else (including portless `*.localhost`
536
- // setups talking from HTTPS to the HTTP API) must use `SameSite=None;
537
- // Secure` so the cookie travels on cross-site fetches; Chrome treats
538
- // localhost as a secure origin so the Secure flag is fine on HTTP.
539
- const origin = (req.headers.origin as string | undefined) ?? "";
540
- return (
541
- origin === "" || origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1")
542
- );
543
- }
544
-
545
595
  // ─── Handler ────────────────────────────────────────────────────────────────
546
596
 
547
597
  export async function handlePages(
@@ -562,6 +612,12 @@ export async function handlePages(
562
612
  return true;
563
613
  }
564
614
 
615
+ const themeFields = readThemeFields(parsed.body);
616
+ if ("error" in themeFields) {
617
+ jsonError(res, themeFields.error, 400);
618
+ return true;
619
+ }
620
+
565
621
  const slug = parsed.body.slug ?? slugify(parsed.body.title);
566
622
 
567
623
  // Hash password if provided. Bun.password.hash is async (Argon2 by default;
@@ -589,6 +645,9 @@ export async function handlePages(
589
645
  passwordHash,
590
646
  body: parsed.body.body,
591
647
  needsCredentials: parsed.body.needsCredentials,
648
+ theme: themeFields.theme,
649
+ themeTokens: themeFields.themeTokens,
650
+ designVersion: parsed.body.designVersion,
592
651
  });
593
652
  // First write has no prior snapshot — version 1 is implicit (the parent
594
653
  // IS v1). Subsequent edits land via PUT and bump the counter.
@@ -598,6 +657,8 @@ export async function handlePages(
598
657
  version: 1,
599
658
  api_url: `${getApiBaseUrl()}/p/${page.id}`,
600
659
  app_url: `${getAppBaseUrl()}/pages/${page.slug}`,
660
+ ...(themeFields.ignored.length > 0 ? { ignoredTokens: themeFields.ignored } : {}),
661
+ designVersion: page.designVersion,
601
662
  });
602
663
  } catch (err) {
603
664
  if (err instanceof AssetKeyAuthorizationError) {
@@ -790,6 +851,13 @@ export async function handlePages(
790
851
  return true;
791
852
  }
792
853
 
854
+ // Before the snapshot: a rejected theme must not leave a version row behind.
855
+ const themeFields = readThemeFields(parsed.body);
856
+ if ("error" in themeFields) {
857
+ jsonError(res, themeFields.error, 400);
858
+ return true;
859
+ }
860
+
793
861
  // Hash password if a new one was provided. `null` → clear the hash.
794
862
  let passwordHashUpdate: string | null | undefined;
795
863
  if (parsed.body.password === null) {
@@ -830,6 +898,9 @@ export async function handlePages(
830
898
  passwordHash: passwordHashUpdate,
831
899
  body: parsed.body.body,
832
900
  needsCredentials: parsed.body.needsCredentials ?? undefined,
901
+ theme: themeFields.theme,
902
+ themeTokens: themeFields.themeTokens,
903
+ designVersion: parsed.body.designVersion,
833
904
  });
834
905
  if (!updated) {
835
906
  res.writeHead(404);
@@ -840,6 +911,8 @@ export async function handlePages(
840
911
  id: updated.id,
841
912
  key: updated.key,
842
913
  version: await getPageEditCounter(updated.id),
914
+ ...(themeFields.ignored.length > 0 ? { ignoredTokens: themeFields.ignored } : {}),
915
+ designVersion: updated.designVersion,
843
916
  });
844
917
  return true;
845
918
  }
@@ -988,7 +1061,7 @@ export async function handlePages(
988
1061
  ? { name: `guest-${randomUUID().slice(0, 8)}`, op: true as const }
989
1062
  : undefined;
990
1063
  const cookies = await pageSessionSetCookies(req, page.id, {
991
- dev: isDevRequest(req),
1064
+ dev: isLocalDevPageRequest(req),
992
1065
  ...identity,
993
1066
  });
994
1067