@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.
- package/dist/{acp-adapter-bp91d37f.js → acp-adapter-7b4vk3h5.js} +5 -4
- package/dist/{actions-yj0eeaqh.js → actions-3tt153af.js} +18 -18
- package/dist/{amp-adapter-4pg9h34q.js → amp-adapter-njpts0yr.js} +1 -1
- package/dist/{anthropic-messages-x4q6rfq5.js → anthropic-messages-xey2skwx.js} +7 -7
- package/dist/{app-909e97cr.js → app-nky4378f.js} +3 -3
- package/dist/{assistant-qdyb5fyy.js → assistant-jnb669pa.js} +19 -19
- package/dist/{azure-openai-responses-s1w5n70z.js → azure-openai-responses-26t7j9ra.js} +5 -5
- package/dist/{boot-reembed-fk0s9z18.js → boot-reembed-9mgvaztt.js} +1 -1
- package/dist/{boot-reembed-gs1rzrnh.js → boot-reembed-qs2djdej.js} +1 -1
- package/dist/{boot-scrub-sweep-4qfd4kfx.js → boot-scrub-sweep-yjpdj7s1.js} +3 -3
- package/dist/{boot-seeds-yr00k13d.js → boot-seeds-5cm6ambd.js} +4 -4
- package/dist/{boxd-vdmfssd9.js → boxd-nv507va8.js} +1 -1
- package/dist/{claude-adapter-qta1f1xn.js → claude-adapter-fn287zmm.js} +5 -4
- package/dist/{claude-managed-setup-sc6j4dm0.js → claude-managed-setup-wtdz2mj2.js} +1 -1
- package/dist/{claude-sdk-session-zgrz6yvc.js → claude-sdk-session-x91kqyhm.js} +8288 -8224
- package/dist/{cli-wp9sp1qb.js → cli-08bmz9z9.js} +1 -1
- package/dist/{cli-yy3rhsdw.js → cli-1c6tzt8g.js} +7 -7
- package/dist/{cli-q0k1w5zd.js → cli-35rsp8ye.js} +1 -1
- package/dist/{cli-xnrwazav.js → cli-3fg0bdcv.js} +1 -1
- package/dist/{cli-89knwags.js → cli-5b456bpf.js} +2 -2
- package/dist/{cli-drr5v6av.js → cli-5jnhv72d.js} +1 -1
- package/dist/{cli-dxae2yg7.js → cli-5zx699j9.js} +2 -2
- package/dist/{cli-0at8w34w.js → cli-6g0hb4cp.js} +8 -8
- package/dist/{cli-e3r2neck.js → cli-6pfd54vx.js} +5 -5
- package/dist/{cli-bsn8nhrq.js → cli-6vcp76qk.js} +3 -3
- package/dist/{cli-ekwks2vs.js → cli-72v799xn.js} +1 -1
- package/dist/cli-82j36f2m.js +45 -0
- package/dist/{cli-yx4kfngt.js → cli-8305gbq0.js} +29 -16
- package/dist/{cli-4necwtsv.js → cli-8sckt74b.js} +8 -8
- package/dist/{cli-vvpx3gsx.js → cli-95jzxcp3.js} +1 -1
- package/dist/{cli-hr2e2fft.js → cli-adeegh7d.js} +7 -7
- package/dist/{cli-m48g6p95.js → cli-afkqkrtw.js} +3 -3
- package/dist/{cli-0qatpy8q.js → cli-ayayvfg4.js} +24 -4
- package/dist/{cli-bqt1mptt.js → cli-az48n0nr.js} +4 -4
- package/dist/{cli-zm72anhg.js → cli-bfv7vdg6.js} +9 -9
- package/dist/{cli-hgaazk69.js → cli-caj33vcq.js} +5 -5
- package/dist/{cli-3ypk9vk1.js → cli-cb2a8pna.js} +1 -1
- package/dist/{cli-waqbxg08.js → cli-cppkf404.js} +17 -17
- package/dist/{cli-yn4kxs06.js → cli-ddaanqkd.js} +4 -4
- package/dist/{cli-386tyxxg.js → cli-e0kmphd6.js} +2 -2
- package/dist/{cli-54t00gt8.js → cli-e46hg0xw.js} +1 -1
- package/dist/{cli-jm2vfpfd.js → cli-e5bzrn01.js} +1 -1
- package/dist/{cli-436532ck.js → cli-excnnjg2.js} +165 -153
- package/dist/{cli-71ptav7x.js → cli-ferkkrg9.js} +1 -1
- package/dist/{cli-vpf89f34.js → cli-fmjd0t1y.js} +3 -3
- package/dist/{cli-pd7vagab.js → cli-fyt2g4wd.js} +3 -3
- package/dist/{cli-gdhpcbhf.js → cli-g08zdaec.js} +1 -1
- package/dist/{cli-ehq736cn.js → cli-g7s47cc5.js} +4 -4
- package/dist/{cli-svj5090z.js → cli-gz6q4eve.js} +3 -3
- package/dist/{cli-8snvgs5r.js → cli-je518txm.js} +10 -10
- package/dist/{cli-rmmmrjkd.js → cli-jtjtabxe.js} +1 -1
- package/dist/{cli-611a1294.js → cli-k0wwfqbg.js} +18 -1
- package/dist/{cli-es6va8jk.js → cli-k1nje3v3.js} +38 -43
- package/dist/{cli-p5gp8pcx.js → cli-k8196sh2.js} +6 -6
- package/dist/{cli-nwj0ee2w.js → cli-k899ehvh.js} +2 -2
- package/dist/{cli-decfb6v8.js → cli-m0da8j35.js} +23 -9
- package/dist/{cli-he9wchme.js → cli-mkk9rnzz.js} +4 -4
- package/dist/{cli-nhrvpaj7.js → cli-n6bv12jn.js} +510 -4
- package/dist/{cli-8bc1w0tw.js → cli-nmzhp1b5.js} +14 -12
- package/dist/{cli-0yv5z2pg.js → cli-p82cnnjq.js} +1 -1
- package/dist/{cli-c60vetmm.js → cli-pc6n7fre.js} +1 -1
- package/dist/{cli-xds5f77d.js → cli-pjctyea3.js} +2 -2
- package/dist/{cli-xbbndvjy.js → cli-pq73h5mz.js} +3 -3
- package/dist/{cli-aa6yxc7m.js → cli-pz4rk021.js} +1 -1
- package/dist/{cli-xnqgpshy.js → cli-q3tkegby.js} +1 -1
- package/dist/cli-qghe0h66.js +67 -0
- package/dist/{cli-zbbwez0r.js → cli-qh4d4tvk.js} +4 -4
- package/dist/{cli-w6dt9ged.js → cli-rtt8g4v9.js} +1 -1
- package/dist/{cli-1h3wcpbw.js → cli-snpj5zbj.js} +2 -2
- package/dist/{cli-a7v854rf.js → cli-tgrv9cjx.js} +263 -12
- package/dist/{cli-faxe6thg.js → cli-vz22fyqv.js} +186 -54
- package/dist/{cli-k2gzwsj2.js → cli-wk5yrfwd.js} +160 -26
- package/dist/{cli-shpbx5ee.js → cli-ws19768n.js} +1641 -49
- package/dist/{cli-70r77n04.js → cli-xae4g51r.js} +1 -1
- package/dist/{cli-kqdg1bsb.js → cli-yndmfxb3.js} +2 -2
- package/dist/{cli-bjb3z51z.js → cli-yyj3j5m4.js} +2 -2
- package/dist/cli.js +21 -21
- package/dist/{cloudflare-workers-ai-system-one-bm60fjm0.js → cloudflare-workers-ai-system-one-ef2a980q.js} +2 -2
- package/dist/{codex-adapter-ayd1fb6h.js → codex-adapter-cf2yxe9m.js} +3 -2
- package/dist/{codex-hook-9efm2x1m.js → codex-hook-a0c6g6t6.js} +1 -1
- package/dist/{codex-session-runner-jesa32xc.js → codex-session-runner-6vfzdgbd.js} +3 -2
- package/dist/{commands-hanewgf2.js → commands-t3mfy7vn.js} +3 -3
- package/dist/{controller-dt1n3c1q.js → controller-q7xrbaz0.js} +3 -3
- package/dist/{controllers-connect-xx8x64n5.js → controllers-connect-c6nm70kg.js} +19 -19
- package/dist/{cursor-adapter-9s4dwwdw.js → cursor-adapter-ftd81ga8.js} +1 -1
- package/dist/{db-3vttcbv4.js → db-dk572x25.js} +3 -3
- package/dist/{db-9dv0g1ab.js → db-vemtpmcc.js} +4 -2
- package/dist/{dispatcher-tx9hd1sv.js → dispatcher-kh7ecxh9.js} +17 -17
- package/dist/{dsh-adapter-007y4ejs.js → dsh-adapter-v8628r89.js} +1 -1
- package/dist/{e2b-we6hs6q5.js → e2b-fr12yccx.js} +1 -1
- package/dist/{e2b-a19d28rz.js → e2b-x6ywzak3.js} +1 -1
- package/dist/{easter-egg-3d-rgbrqb8s.js → easter-egg-3d-nenw4x63.js} +1 -1
- package/dist/{embedding-space-ny1e2940.js → embedding-space-0jepb239.js} +6 -6
- package/dist/{embeddings-2t386t13.js → embeddings-xxt49yxg.js} +1 -1
- package/dist/{execute-pksh0ceh.js → execute-1mhw6e4x.js} +1 -1
- package/dist/{external-channel-phgmhmd7.js → external-channel-55v12e60.js} +2 -2
- package/dist/{fx-adapter-fy8rr921.js → fx-adapter-bs8wqrk5.js} +6 -5
- package/dist/{google-generative-ai-8y3m3k4b.js → google-generative-ai-v94hr7h0.js} +5 -5
- package/dist/{google-vertex-w3v98n7b.js → google-vertex-g7vr6cf8.js} +5 -5
- package/dist/{grok-adapter-q0abvpaj.js → grok-adapter-gpjqmkcq.js} +6 -5
- package/dist/handlers-zxmp1ta4.js +32 -0
- package/dist/{heartbeat-kadyek8p.js → heartbeat-k42svnr0.js} +20 -20
- package/dist/{hook-9k551xnj.js → hook-114m7hap.js} +4 -3
- package/dist/{hook-5zs3msf6.js → hook-6nphweh0.js} +24 -9
- package/dist/{http-mhz9vw1f.js → http-r8cc4wz7.js} +1025 -173
- package/dist/index-a8phxqnk.js +37 -0
- package/dist/{index-vdygbq10.js → index-e3s8wmxy.js} +1 -1
- package/dist/{index-q361cg2f.js → index-fpk4bcd9.js} +1 -1
- package/dist/index-k6h00t4c.js +30 -0
- package/dist/{index-rvr00sg7.js → index-nh3wyqdt.js} +1017 -574
- package/dist/index-xrz7te8h.js +33 -0
- package/dist/{keepalive-w56f32cg.js → keepalive-x060nt9p.js} +3 -3
- package/dist/lead-956sd2yc.js +71 -0
- package/dist/{maintenance-7mdgxcea.js → maintenance-2xxfexhf.js} +1 -1
- package/dist/{mistral-conversations-4pmwj1c0.js → mistral-conversations-3gjpdb4b.js} +4 -4
- package/dist/{oauth-refresh-sweep-29txy9vk.js → oauth-refresh-sweep-fckc4ar5.js} +2 -2
- package/dist/{onboard-kpsrkarj.js → onboard-zc48j237.js} +3 -3
- package/dist/{openai-codex-responses-44j573nm.js → openai-codex-responses-8pbxq9h5.js} +5 -5
- package/dist/{openai-completions-a4gee2a8.js → openai-completions-rm00s912.js} +4 -4
- package/dist/{openai-decisions-w4qkrcar.js → openai-decisions-8v957r2r.js} +1 -1
- package/dist/{openai-responses-rbp6h5kr.js → openai-responses-agxpf96k.js} +5 -5
- package/dist/{opencode-adapter-j25vm3rr.js → opencode-adapter-r690h1cr.js} +4 -3
- package/dist/{otel-impl-6kkct7a8.js → otel-impl-r5xbh81q.js} +4 -4
- package/dist/{pi-mono-adapter-exhkzhn2.js → pi-mono-adapter-c7v8sdec.js} +7 -6
- package/dist/{pricing-refresh-n2x7jh6k.js → pricing-refresh-3pmre11v.js} +3 -3
- package/dist/{rbac-roles-81ypx6es.js → rbac-roles-3vbshwsb.js} +1 -1
- package/dist/{rbac-roles-b5htkjby.js → rbac-roles-910mmkgz.js} +2 -2
- package/dist/{render-v2-qn6jmpk4.js → render-v2-pxykkbab.js} +3 -3
- package/dist/{resume-wd30wx23.js → resume-adda0bwy.js} +6 -6
- package/dist/{runtime-4hsg7h60.js → runtime-fnbm7stk.js} +1 -1
- package/dist/{session-access-5qvfevhk.js → session-access-2fvamrdc.js} +4 -4
- package/dist/{session-source-backfill-n1czavbr.js → session-source-backfill-08xvv0sm.js} +2 -2
- package/dist/{setup-dnb56qy5.js → setup-1aqmvmh1.js} +1 -1
- package/dist/{typesafe-system-one-5mavm259.js → typesafe-system-one-0bmqcxfg.js} +2 -2
- package/dist/{virtual-modules-76xxv9fs.js → virtual-modules-d2pjk9j1.js} +5 -5
- package/dist/{work-objects-4n21tfkm.js → work-objects-9s0yrm5j.js} +20 -20
- package/dist/{worker-follow-up-77hvzxww.js → worker-follow-up-3q78fqmr.js} +17 -17
- package/dist/worker-y15hy08h.js +71 -0
- package/openapi.json +197 -4
- package/package.json +5 -5
- package/src/apps/definition.ts +6 -5
- package/src/be/db/page-theme-tokens.ts +12 -0
- package/src/be/db/tasks/read.ts +9 -1
- package/src/be/db/tasks/write.ts +19 -0
- package/src/be/db-client.ts +107 -7
- package/src/be/db.ts +34 -2
- package/src/be/migrations/269_pages_design_system.sql +23 -0
- package/src/be/seed/schedules-seeder.ts +0 -10
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +2 -0
- package/src/be/seed-scripts/catalog/catalog-report.ts +2 -0
- package/src/be/seed-scripts/catalog/memory-eval.ts +2 -0
- package/src/be/seed-scripts/catalog/swarm-update-check.ts +150 -0
- package/src/be/seed-scripts/index.ts +9 -0
- package/src/be/seed-skills/bundled-files.generated.json +3 -3
- package/src/be/swarm-config-guard.ts +32 -0
- package/src/be/task-auto-title-listener.ts +127 -0
- package/src/commands/onboard/agent-fs-version.ts +1 -1
- package/src/commands/profile-sync.ts +151 -16
- package/src/commands/runner.ts +15 -7
- package/src/hooks/hook.ts +20 -9
- package/src/http/index.ts +3 -0
- package/src/http/pages-public.ts +93 -129
- package/src/http/pages.ts +94 -21
- package/src/pages/browser-sdk.ts +48 -21
- package/src/pages/color-literals.ts +115 -0
- package/src/pages/page-head.ts +766 -0
- package/src/pages/version.ts +3 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +1 -0
- package/src/providers/codex-app-server.ts +27 -1
- package/src/providers/pi-mono-extension.ts +30 -15
- package/src/tasks/follow-up-gate.ts +318 -0
- package/src/tasks/worker-follow-up.ts +21 -1
- package/src/tests/codex-app-server.test.ts +59 -0
- package/src/tests/codex-gateway-route.test.ts +50 -1
- package/src/tests/create-page-tool.test.ts +501 -0
- package/src/tests/db-client-busy-retry.test.ts +81 -0
- package/src/tests/db-task-read-contract.test.ts +1 -0
- package/src/tests/db-task-write-contract.test.ts +2 -0
- package/src/tests/db-tasks-read.test.ts +21 -0
- package/src/tests/fixtures/pages/head-v1.html +56 -0
- package/src/tests/follow-up-gate-shadow.test.ts +513 -0
- package/src/tests/identity-field-budget.test.ts +1 -1
- package/src/tests/internal-ai/credentials.test.ts +62 -0
- package/src/tests/internal-ai/task-title.test.ts +283 -0
- package/src/tests/merge-gate-detect-changes.test.ts +145 -0
- package/src/tests/page-proxy.test.ts +1 -1
- package/src/tests/page-session-dev-cookie.test.ts +188 -0
- package/src/tests/pages-frame-ancestors.test.ts +28 -1
- package/src/tests/pages-head-v2.test.ts +534 -0
- package/src/tests/pages-http.test.ts +330 -0
- package/src/tests/pages-public-html.test.ts +43 -0
- package/src/tests/pages-storage.test.ts +182 -0
- package/src/tests/pages-versioning.test.ts +72 -0
- package/src/tests/profile-sync-stale-writeback.test.ts +334 -0
- package/src/tests/profile-sync.test.ts +11 -15
- package/src/tests/scheduled-task-required-params-migration.test.ts +3 -1
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/seed-registry.test.ts +28 -6
- package/src/tests/seed-schedules.test.ts +61 -8
- package/src/tests/seed-scripts.test.ts +142 -2
- package/src/tests/swarm-diff.test.ts +121 -2
- package/src/tests/task-auto-title-config-guard.test.ts +37 -0
- package/src/tests/task-auto-title-listener.test.ts +165 -0
- package/src/tests/theme-import.test.ts +758 -0
- package/src/tests/themes-page-theme.test.ts +155 -0
- package/src/tests/themes-presets.test.ts +172 -0
- package/src/tests/workflow-script-principal.test.ts +2 -1
- package/src/themes/classics.ts +949 -0
- package/src/themes/import.ts +420 -0
- package/src/themes/page-fields.ts +40 -0
- package/src/themes/page-theme.ts +102 -0
- package/src/themes/presets.ts +561 -0
- package/src/themes/tokens.ts +218 -0
- package/src/tools/app-upsert.ts +1 -1
- package/src/tools/create-page.ts +118 -12
- package/src/tools/utils.ts +33 -0
- package/src/types.ts +45 -3
- package/src/utils/constants.ts +19 -0
- package/src/utils/follow-up-gate-schedule-ids.ts +13 -0
- package/src/utils/internal-ai/credentials.ts +146 -93
- package/src/utils/internal-ai/index.ts +10 -0
- package/src/utils/internal-ai/models.ts +18 -0
- package/src/utils/internal-ai/task-title.ts +282 -0
- package/src/utils/page-session.ts +37 -0
- package/templates/schedules/daily-status-report/content.md +88 -2
- package/templates/schedules/daily-swarm-update-check/content.md +16 -40
- package/templates/skills/apps/SKILL.md +1 -1
- package/templates/skills/apps/content.md +1 -1
- package/templates/skills/heartbeat-runbook/content.md +1 -1
- package/templates/skills/pages/config.json +1 -1
- package/templates/skills/pages/content.md +539 -86
- package/templates/skills/pages/files/examples/annotated-pr.html +21 -12
- package/templates/skills/pages/files/examples/multiplayer-board.html +41 -41
- package/templates/skills/pages/files/examples/report-page.html +125 -124
- package/dist/cli-2pnswgh8.js +0 -189
- package/dist/handlers-by3sfxyx.js +0 -32
- package/dist/index-4f2pvxae.js +0 -37
- package/dist/index-98gz7tmp.js +0 -30
- package/dist/index-k08z2cak.js +0 -33
- package/dist/lead-enq35nqp.js +0 -70
- package/dist/worker-gg1576j3.js +0 -70
- package/templates/schedules/daily-workflow-health-audit/config.json +0 -19
- 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
|
|
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
|
|
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
|
+
}
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
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 =
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
1071
|
+
io.readFile,
|
|
937
1072
|
claudeMdPath,
|
|
938
1073
|
opts.agentId,
|
|
939
1074
|
);
|