@desplega.ai/agent-swarm 1.157.0 → 1.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/dist/{acp-adapter-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
  29. package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +29 -15
  47. package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
  59. package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +2 -1
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/modelsdev-reasoning.json +551 -0
  149. package/src/rbac/legacy-policy.ts +16 -0
  150. package/src/rbac/permissions.ts +4 -0
  151. package/src/scheduler/schedule-task.ts +3 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  154. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  155. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  156. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  157. package/src/server.ts +12 -7
  158. package/src/slack/app.ts +35 -4
  159. package/src/slack/connection-state.ts +17 -0
  160. package/src/tasks/context-key.ts +1 -0
  161. package/src/tasks/worker-follow-up.ts +6 -2
  162. package/src/tests/agent-task-activity.test.ts +74 -0
  163. package/src/tests/agents-list-model-display.test.ts +6 -1
  164. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  165. package/src/tests/approval-requests.test.ts +395 -0
  166. package/src/tests/approval-sweeps-config.test.ts +47 -0
  167. package/src/tests/approval-sweeps.test.ts +508 -0
  168. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  169. package/src/tests/apps-spike.test.ts +56 -1
  170. package/src/tests/apps-sync-engine.test.ts +20 -9
  171. package/src/tests/boot-seeds.test.ts +170 -0
  172. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  173. package/src/tests/claude-managed-adapter.test.ts +6 -0
  174. package/src/tests/config-key-filter.test.ts +80 -0
  175. package/src/tests/context-window.test.ts +1 -0
  176. package/src/tests/db-client-busy-retry.test.ts +96 -0
  177. package/src/tests/db-client.test.ts +60 -1
  178. package/src/tests/db-task-read-contract.test.ts +10 -0
  179. package/src/tests/db-task-write-contract.test.ts +9 -0
  180. package/src/tests/docs-index-footprint.test.js +3 -3
  181. package/src/tests/events-http.test.ts +103 -0
  182. package/src/tests/extensions-catalog.test.ts +22 -17
  183. package/src/tests/extensions-pre-task.test.ts +107 -0
  184. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  185. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  186. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  187. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  188. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  189. package/src/tests/heartbeat.test.ts +99 -3
  190. package/src/tests/hook-mcp-config.test.ts +29 -0
  191. package/src/tests/http-api-integration.test.ts +16 -2
  192. package/src/tests/kv-storage.test.ts +62 -0
  193. package/src/tests/linear-outbound-sync.test.ts +120 -5
  194. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  195. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  196. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  197. package/src/tests/memory-hybrid.test.ts +161 -1
  198. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  199. package/src/tests/memory.test.ts +22 -0
  200. package/src/tests/onboard-container-engine.test.ts +240 -0
  201. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  202. package/src/tests/pricing-routes.test.ts +14 -0
  203. package/src/tests/profile-sync-warning.test.ts +8 -4
  204. package/src/tests/promote-draft-task-route.test.ts +19 -0
  205. package/src/tests/provider-registration.test.ts +296 -42
  206. package/src/tests/rbac-engine.test.ts +30 -0
  207. package/src/tests/reasoning-effort.test.ts +8 -0
  208. package/src/tests/script-connections.test.ts +7 -0
  209. package/src/tests/sdk-allowlist.test.ts +39 -0
  210. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  211. package/src/tests/sessions-detail-batch.test.ts +250 -0
  212. package/src/tests/sessions.test.ts +118 -0
  213. package/src/tests/slack-config.test.ts +22 -0
  214. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  215. package/src/tests/status.test.ts +36 -1
  216. package/src/tests/swarm-diff.test.ts +316 -10
  217. package/src/tests/task-completion-idempotency.test.ts +2 -1
  218. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  219. package/src/tests/tool-annotations.test.ts +1 -1
  220. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  221. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  222. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  223. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  224. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  225. package/src/tests/workflow-http-v2.test.ts +99 -8
  226. package/src/tests/workflow-retry-validation.test.ts +56 -0
  227. package/src/tests/workflow-tla-races.test.ts +389 -0
  228. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  229. package/src/tools/cancel-approval-request.ts +57 -0
  230. package/src/tools/memory-search.ts +1 -0
  231. package/src/tools/request-human-input.ts +4 -2
  232. package/src/tools/send-task.ts +4 -0
  233. package/src/tools/templates.ts +19 -0
  234. package/src/tools/tool-config.ts +2 -1
  235. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  236. package/src/types.ts +38 -0
  237. package/src/utils/context-window.ts +1 -0
  238. package/src/workflows/approval-cancel.ts +152 -0
  239. package/src/workflows/approval-notifications.ts +84 -0
  240. package/src/workflows/checkpoint.ts +10 -5
  241. package/src/workflows/definition.ts +21 -0
  242. package/src/workflows/engine.ts +152 -21
  243. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  244. package/src/workflows/recovery.ts +100 -45
  245. package/src/workflows/resume.ts +49 -18
  246. package/src/workflows/retry-poller.ts +71 -21
  247. package/templates/extensions/README.md +1 -0
  248. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  249. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  250. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  251. package/templates/skills/apps/content.md +20 -1
  252. package/templates/skills/code-quality/content.md +26 -1
  253. package/templates/skills/pages/content.md +16 -4
  254. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  255. package/dist/cli-2daksqps.js +0 -378
  256. package/dist/rbac-roles-3gxmnb20.js +0 -37
@@ -32,6 +32,15 @@ export type ModelsDevCache = Record<string, ModelsDevProvider>;
32
32
 
33
33
  export const MODELSDEV_CACHE_PATH = path.join("src", "be", "modelsdev-cache.json");
34
34
 
35
+ /**
36
+ * Parsed snapshots by file path, so a changed `MODELSDEV_CACHE_PATH` is honoured.
37
+ * The file is 8.6 MB and its `JSON.parse` blocks the event loop for ~40 ms, so a
38
+ * second load while the first result is still alive reuses it. References are
39
+ * weak: the parsed tree is ~27 MB of heap that nothing needs pinned once the
40
+ * seed and catalog have read it. Callers must treat the result as read-only.
41
+ */
42
+ const parsedCaches = new Map<string, WeakRef<ModelsDevCache>>();
43
+
35
44
  /**
36
45
  * Resolve the vendored models.dev cache from source checkouts and compiled
37
46
  * Docker images. The API image copies the snapshot to `/app/src/be/...`.
@@ -53,8 +62,12 @@ export function loadModelsDevCache(): ModelsDevCache | null {
53
62
  ];
54
63
 
55
64
  for (const candidate of candidates) {
65
+ const memoized = parsedCaches.get(candidate)?.deref();
66
+ if (memoized) return memoized;
56
67
  try {
57
- return JSON.parse(readFileSync(candidate, "utf-8")) as ModelsDevCache;
68
+ const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as ModelsDevCache;
69
+ parsedCaches.set(candidate, new WeakRef(parsed));
70
+ return parsed;
58
71
  } catch {
59
72
  // try next candidate
60
73
  }
@@ -55,6 +55,8 @@ const ROUTING_PREFIXES_BY_PROVIDER: Record<PricingProvider, readonly string[]> =
55
55
  // A generic ACP target reports whatever model id it likes and has no seeded
56
56
  // rate rows, so there is no canonical key to collapse onto — no-op.
57
57
  acp: [],
58
+ // dsh emits bare model ids today — no-op, same as claude/devin/gemini above.
59
+ dsh: [],
58
60
  };
59
61
 
60
62
  /**
@@ -391,6 +391,7 @@ export interface SwarmSdk {
391
391
 
392
392
  // --- human input ---
393
393
  request_humanInput(args: Record<string, unknown>): Promise<unknown>;
394
+ approval_cancel(args: { requestId: string; reason?: string }): Promise<unknown>;
394
395
  }
395
396
 
396
397
  export interface ScriptStdlib {
@@ -90,7 +90,7 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
90
90
  fable: "claude-fable-5-1",
91
91
  mythos: "claude-mythos-5-1",
92
92
  opus: "claude-opus-5-5",
93
- sonnet: "claude-sonnet-5",
93
+ sonnet: "claude-sonnet-5-5",
94
94
  haiku: "claude-haiku-4-5",
95
95
  };
96
96
 
@@ -98,6 +98,8 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
98
98
  const ANTHROPIC_SHORTNAME_PRICING_FALLBACKS: Record<string, ModelsDevCostBlock> = {
99
99
  // https://platform.claude.com/docs/en/about-claude/pricing (verified 2026-09-22)
100
100
  "claude-opus-5-5": { input: 4, output: 20, cache_read: 0.2, cache_write: 5 },
101
+ // https://platform.claude.com/docs/en/about-claude/pricing (verified 2026-09-28)
102
+ "claude-sonnet-5-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 },
101
103
  "claude-fable-5-1": { input: 10, output: 50, cache_read: 0.25, cache_write: 12.5 },
102
104
  "claude-mythos-5-1": { input: 10, output: 50, cache_read: 0.25, cache_write: 12.5 },
103
105
  };
@@ -58,7 +58,7 @@
58
58
  },
59
59
  {
60
60
  "path": "examples/multiplayer-board.html",
61
- "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 .cursor { color: #67665f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }\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 <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\">Cursor values use each peer's viewport coordinates.</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 const cursor = document.createElement('span');\n cursor.className = 'cursor';\n const data = peer.data || {};\n cursor.textContent = Number.isFinite(data.x) && Number.isFinite(data.y)\n ? `cursor: ${Math.round(data.x)}, ${Math.round(data.y)}`\n : 'cursor: not shared yet';\n item.append(name, cursor);\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 document.addEventListener('pointermove', event => {\n if (board) board.presence.set({ x: event.clientX, y: event.clientY });\n }, { passive: true });\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"
61
+ "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"
62
62
  },
63
63
  {
64
64
  "path": "examples/report-page.html",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type Agent,
2
3
  type AgentTask,
3
4
  type OnUnsupported,
4
5
  PROVIDER_STEER_CAPABILITIES,
@@ -53,11 +54,14 @@ async function providerForTask(task: AgentTask): Promise<ProviderName> {
53
54
  return agent?.harnessProvider ?? agent?.provider ?? task.provider ?? "claude";
54
55
  }
55
56
 
56
- export async function getTaskSteeringFields(task: AgentTask): Promise<{
57
+ export interface TaskSteeringFields {
57
58
  isLeadTask: boolean;
58
59
  supportedSteerModes: SteerMode[];
59
- }> {
60
- const agent = task.agentId ? await getAgentById(task.agentId) : null;
60
+ }
61
+
62
+ type SteeringAgent = Pick<Agent, "isLead" | "provider" | "harnessProvider">;
63
+
64
+ function steeringFieldsFor(task: AgentTask, agent: SteeringAgent | null): TaskSteeringFields {
61
65
  const provider = agent?.harnessProvider ?? agent?.provider ?? task.provider;
62
66
  return {
63
67
  isLeadTask: agent?.isLead ?? false,
@@ -65,6 +69,52 @@ export async function getTaskSteeringFields(task: AgentTask): Promise<{
65
69
  };
66
70
  }
67
71
 
72
+ export async function getTaskSteeringFields(task: AgentTask): Promise<TaskSteeringFields> {
73
+ const agent = task.agentId ? await getAgentById(task.agentId) : null;
74
+ return steeringFieldsFor(task, agent);
75
+ }
76
+
77
+ /** Bound-parameter chunk for `IN (...)`, well under SQLite's variable limit. */
78
+ const AGENT_ID_CHUNK = 500;
79
+
80
+ /**
81
+ * Batched {@link getTaskSteeringFields}: one query per 500 distinct assignees
82
+ * instead of one full agent row per task. Selects only the three columns the
83
+ * steering fields read. Keyed by task id.
84
+ */
85
+ export async function getTaskSteeringFieldsForTasks(
86
+ tasks: AgentTask[],
87
+ ): Promise<Map<string, TaskSteeringFields>> {
88
+ const agentIds = [...new Set(tasks.flatMap((task) => (task.agentId ? [task.agentId] : [])))];
89
+ const agents = new Map<string, SteeringAgent>();
90
+ for (let i = 0; i < agentIds.length; i += AGENT_ID_CHUNK) {
91
+ const ids = agentIds.slice(i, i + AGENT_ID_CHUNK);
92
+ const rows = await getDbClient().query<{
93
+ id: string;
94
+ isLead: number;
95
+ provider: string | null;
96
+ harness_provider: string | null;
97
+ }>(
98
+ `SELECT id, isLead, provider, harness_provider FROM agents WHERE id IN (${ids.map(() => "?").join(",")})`,
99
+ ids,
100
+ );
101
+ for (const row of rows) {
102
+ // Same mapping as rowToAgent for these three fields.
103
+ agents.set(row.id, {
104
+ isLead: row.isLead === 1,
105
+ provider: (row.provider as ProviderName | null) ?? undefined,
106
+ harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
107
+ });
108
+ }
109
+ }
110
+ return new Map(
111
+ tasks.map((task) => [
112
+ task.id,
113
+ steeringFieldsFor(task, task.agentId ? (agents.get(task.agentId) ?? null) : null),
114
+ ]),
115
+ );
116
+ }
117
+
68
118
  /**
69
119
  * Convert an undeliverable steering message into a normal follow-up task.
70
120
  * Step 2 reuses this seam for pending rows discovered during terminal sweeps.
@@ -299,6 +299,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
299
299
  "BUDGET_ADMISSION_DISABLED",
300
300
  "MCP_OAUTH_ALLOW_PRIVATE_HOSTS",
301
301
  "OTEL_TRACE_POLL",
302
+ "OTEL_EXPORT_API_LOGS",
302
303
  "ANONYMIZED_TELEMETRY",
303
304
  "SWARM_HIDE_CLOUD_PROMO",
304
305
  "DB_QUERY_BOUNDED_ENABLED",
@@ -358,6 +359,8 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
358
359
  ),
359
360
  // 0 is meaningful here: "auto-assign nothing this sweep".
360
361
  ...integerValidators(["HEARTBEAT_MAX_AUTO_ASSIGN"], 0),
362
+ // 0 turns approval auto-cancellation off.
363
+ ...integerValidators(["APPROVAL_REQUEST_AUTO_CANCELLATION_DAYS"], 0),
361
364
  // Below ~100 tokens the preamble can't fit a useful summary; above 20000
362
365
  // (~80k chars) it risks the SIGTERM-143 context-saturation failure mode
363
366
  // the cap exists to prevent (see context-preamble.ts).
@@ -145,6 +145,35 @@ export async function getTaskCitations(taskId: string): Promise<TaskCitation[]>
145
145
  return rows.map((row) => ({ ...row, general: row.general === 1 }));
146
146
  }
147
147
 
148
+ /** Bound-parameter chunk for `IN (...)`, well under SQLite's variable limit. */
149
+ const TASK_ID_CHUNK = 500;
150
+
151
+ /**
152
+ * Batched {@link getTaskCitations}: one query per 500 task ids. Every
153
+ * requested id is a key; each list keeps `citation_index` order.
154
+ */
155
+ export async function getTaskCitationsForTasks(
156
+ taskIds: string[],
157
+ ): Promise<Map<string, TaskCitation[]>> {
158
+ const ids = [...new Set(taskIds)];
159
+ const byTask = new Map<string, TaskCitation[]>(ids.map((id) => [id, []]));
160
+ for (let i = 0; i < ids.length; i += TASK_ID_CHUNK) {
161
+ const chunk = ids.slice(i, i + TASK_ID_CHUNK);
162
+ const rows = await getDbClient().query<
163
+ Omit<TaskCitation, "general"> & { general: number; taskId: string }
164
+ >(
165
+ `SELECT task_id AS taskId, citation_index AS "index", kind, ref, label, quote,
166
+ resolved_url AS resolvedUrl, verified, general FROM task_citations
167
+ WHERE task_id IN (${chunk.map(() => "?").join(",")}) ORDER BY task_id, citation_index`,
168
+ chunk,
169
+ );
170
+ for (const { taskId, ...row } of rows) {
171
+ byTask.get(taskId)?.push({ ...row, general: row.general === 1 });
172
+ }
173
+ }
174
+ return byTask;
175
+ }
176
+
148
177
  /**
149
178
  * The completion citation check refuses at most once per task. The refusal
150
179
  * is an `agent_log` row, so it also shows in the task's activity timeline.
package/src/cli.tsx CHANGED
@@ -39,6 +39,7 @@ interface ParsedArgs {
39
39
  preset: string;
40
40
  maxConcurrentTasks: string | undefined;
41
41
  pullPolicy: string | undefined;
42
+ containerEngine: string | undefined;
42
43
  open: boolean;
43
44
  showHelp: boolean;
44
45
  dbPath: string;
@@ -60,6 +61,7 @@ function parseArgs(args: string[]): ParsedArgs {
60
61
  let preset = "";
61
62
  let maxConcurrentTasks: string | undefined;
62
63
  let pullPolicy: string | undefined;
64
+ let containerEngine: string | undefined;
63
65
  let open = false;
64
66
  let showHelp = false;
65
67
  let dbPath = "";
@@ -113,6 +115,12 @@ function parseArgs(args: string[]): ParsedArgs {
113
115
  if (value && !value.startsWith("-")) i++;
114
116
  } else if (arg?.startsWith("--pull-policy=")) {
115
117
  pullPolicy = arg.slice("--pull-policy=".length);
118
+ } else if (arg === "--container-engine") {
119
+ const value = mainArgs[i + 1];
120
+ containerEngine = value && !value.startsWith("-") ? value : "";
121
+ if (value && !value.startsWith("-")) i++;
122
+ } else if (arg?.startsWith("--container-engine=")) {
123
+ containerEngine = arg.slice("--container-engine=".length);
116
124
  } else if (arg === "--open") {
117
125
  open = true;
118
126
  } else if (arg === "--help" || arg === "-h") {
@@ -139,6 +147,7 @@ function parseArgs(args: string[]): ParsedArgs {
139
147
  preset,
140
148
  maxConcurrentTasks,
141
149
  pullPolicy,
150
+ containerEngine,
142
151
  open,
143
152
  showHelp,
144
153
  dbPath,
@@ -154,13 +163,14 @@ const COMMAND_HELP: Record<
154
163
  onboard: {
155
164
  usage: `${binName} onboard [options]`,
156
165
  description:
157
- "Set up a new swarm from scratch using Docker Compose.\nInteractive wizard that collects credentials, generates docker-compose.yml + .env,\nstarts the stack, and verifies health.",
166
+ "Set up a new swarm from scratch using Docker Compose (or Podman with a Compose provider).\nInteractive wizard that collects credentials, generates docker-compose.yml + .env,\nstarts the stack, and verifies health.",
158
167
  options: [
159
168
  " --dry-run Preview what would be generated without writing",
160
169
  " -y, --yes Non-interactive mode (reads from env vars)",
161
170
  " --preset <name> Preset: full, dev, content, research, solo (required with --yes)",
162
171
  " --max-concurrent-tasks <n> Tasks per agent (1-100; default: lead 2, worker 1)",
163
172
  " --pull-policy <policy> Pull policy: always, missing, never (default: always)",
173
+ " --container-engine <engine> auto, docker, podman (default: $AGENT_SWARM_CONTAINER_ENGINE, else auto; auto prefers Docker)",
164
174
  " -h, --help Show this help",
165
175
  ].join("\n"),
166
176
  examples: [
@@ -168,6 +178,7 @@ const COMMAND_HELP: Record<
168
178
  ` ${binName} onboard --dry-run`,
169
179
  ` ${binName} onboard --yes --preset=full`,
170
180
  ` ${binName} onboard --yes --preset=dev`,
181
+ ` ${binName} onboard --container-engine=podman`,
171
182
  ` ANTHROPIC_API_KEY=sk-... ${binName} onboard --yes --preset=solo`,
172
183
  ].join("\n"),
173
184
  },
@@ -647,6 +658,7 @@ function App({ args }: { args: ParsedArgs }) {
647
658
  preset,
648
659
  maxConcurrentTasks,
649
660
  pullPolicy,
661
+ containerEngine,
650
662
  } = args;
651
663
 
652
664
  switch (command) {
@@ -660,6 +672,7 @@ function App({ args }: { args: ParsedArgs }) {
660
672
  preset: preset || undefined,
661
673
  maxConcurrentTasks,
662
674
  pullPolicy,
675
+ containerEngine,
663
676
  }}
664
677
  />
665
678
  );
@@ -1,3 +1,4 @@
1
+ import { composeCommandText, engineForCommands } from "./container-engine.ts";
1
2
  import { expandServices } from "./service-names.ts";
2
3
  import type { OnboardState } from "./types.ts";
3
4
 
@@ -52,6 +53,9 @@ const ENV_AWS_PROFILE = " - AWS_PROFILE=${AWS_PROFILE}";
52
53
  const ENV_BEDROCK_AUTH_MODE = " - BEDROCK_AUTH_MODE=${BEDROCK_AUTH_MODE}";
53
54
  // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
54
55
  const AWS_PROFILE_VOLUME = " - ${HOME}/.aws:/home/worker/.aws:ro";
56
+ // `z` relabels the bind mount for SELinux as shared, since every worker mounts it.
57
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
58
+ const AWS_PROFILE_VOLUME_SELINUX = " - ${HOME}/.aws:/home/worker/.aws:ro,z";
55
59
  // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
56
60
  const ENV_GITHUB_TOKEN = " - GITHUB_TOKEN=${GITHUB_TOKEN}";
57
61
  // biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
@@ -111,11 +115,12 @@ export function generateCompose(state: OnboardState): string {
111
115
  const lines: string[] = [];
112
116
 
113
117
  // Header
114
- lines.push("# Docker Compose for Agent Swarm");
118
+ const engine = engineForCommands(state.containerEngine);
119
+ lines.push(`# ${engine === "podman" ? "Podman" : "Docker"} Compose for Agent Swarm`);
115
120
  lines.push("# Generated by `agent-swarm onboard`");
116
121
  lines.push("#");
117
122
  lines.push("# Usage:");
118
- lines.push("# docker compose --env-file .env up -d");
123
+ lines.push(`# ${composeCommandText(engine, "--env-file .env up -d")}`);
119
124
  lines.push("#");
120
125
  lines.push("# Image version: set AGENT_SWARM_VERSION in .env to a published release.");
121
126
  lines.push("# Blank falls back to `latest`, which moves on every commit to main and is");
@@ -242,7 +247,7 @@ export function generateCompose(state: OnboardState): string {
242
247
  lines.push(" - swarm_shared:/app/shared");
243
248
  lines.push(` - swarm_${svc.sanitizedName}:/app/agent`);
244
249
  if (state.provider === "bedrock" && state.awsProfile) {
245
- lines.push(AWS_PROFILE_VOLUME);
250
+ lines.push(engine === "podman" ? AWS_PROFILE_VOLUME_SELINUX : AWS_PROFILE_VOLUME);
246
251
  }
247
252
  lines.push("");
248
253
  lines.push(" restart: unless-stopped");
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Container engine selection for `agent-swarm onboard`.
3
+ *
4
+ * The generated stack is plain OCI images plus a Compose file, so it runs under
5
+ * Docker Compose or under Podman with a Compose provider (`podman compose`).
6
+ * `auto` keeps the historical behavior: Docker whenever the `docker` binary is
7
+ * present, Podman only as a fallback.
8
+ */
9
+
10
+ export type ContainerEngine = "docker" | "podman";
11
+ export type ContainerEnginePreference = "auto" | ContainerEngine;
12
+
13
+ export const CONTAINER_ENGINE_PREFERENCES: readonly ContainerEnginePreference[] = [
14
+ "auto",
15
+ "docker",
16
+ "podman",
17
+ ];
18
+
19
+ export const CONTAINER_ENGINE_LABELS: Record<ContainerEngine, string> = {
20
+ docker: "Docker",
21
+ podman: "Podman",
22
+ };
23
+
24
+ export const ENGINE_INSTALL_HINTS: Record<ContainerEngine, string> = {
25
+ docker: "Install: brew install --cask docker (macOS) or https://docs.docker.com/get-docker/",
26
+ podman: "Install: https://podman.io/docs/installation",
27
+ };
28
+
29
+ export const PODMAN_COMPOSE_PROVIDER_HINT =
30
+ "`podman compose` needs an external Compose provider. Install podman-compose (dnf/apt install podman-compose, or pipx install podman-compose) or docker-compose v2, or point PODMAN_COMPOSE_PROVIDER at one. See https://docs.agent-swarm.dev/docs/guides/podman-rootless";
31
+
32
+ export function isContainerEnginePreference(value: string): value is ContainerEnginePreference {
33
+ return (CONTAINER_ENGINE_PREFERENCES as readonly string[]).includes(value);
34
+ }
35
+
36
+ /** Env var that selects the engine when `--container-engine` is not passed. */
37
+ export const CONTAINER_ENGINE_ENV = "AGENT_SWARM_CONTAINER_ENGINE";
38
+
39
+ /**
40
+ * Pick the engine preference: the `--container-engine` flag, then
41
+ * `AGENT_SWARM_CONTAINER_ENGINE`, then `auto`. An empty env var counts as unset.
42
+ * An invalid value from either source is an error, never a silent `auto`.
43
+ */
44
+ export function resolveContainerEnginePreference(
45
+ flag: string | undefined,
46
+ env: Record<string, string | undefined>,
47
+ ): { ok: true; value: ContainerEnginePreference } | { ok: false; error: string } {
48
+ const options = CONTAINER_ENGINE_PREFERENCES.join(", ");
49
+ if (flag !== undefined) {
50
+ if (!isContainerEnginePreference(flag)) {
51
+ return { ok: false, error: `Invalid container engine "${flag}". Options: ${options}` };
52
+ }
53
+ return { ok: true, value: flag };
54
+ }
55
+ const fromEnv = env[CONTAINER_ENGINE_ENV];
56
+ if (fromEnv === undefined || fromEnv === "") return { ok: true, value: "auto" };
57
+ if (!isContainerEnginePreference(fromEnv)) {
58
+ return {
59
+ ok: false,
60
+ error: `Invalid ${CONTAINER_ENGINE_ENV} "${fromEnv}". Options: ${options}`,
61
+ };
62
+ }
63
+ return { ok: true, value: fromEnv };
64
+ }
65
+
66
+ /**
67
+ * Engine to use for commands and generated instructions when detection has not
68
+ * run yet. `auto` renders as Docker, matching its preference order.
69
+ */
70
+ export function engineForCommands(preference: ContainerEnginePreference): ContainerEngine {
71
+ return preference === "podman" ? "podman" : "docker";
72
+ }
73
+
74
+ /** `docker compose <args>` / `podman compose <args>` as an argv array. */
75
+ export function composeArgv(engine: ContainerEngine, args: string[]): string[] {
76
+ return [engine, "compose", ...args];
77
+ }
78
+
79
+ /** Human-readable compose command for logs and printed instructions. */
80
+ export function composeCommandText(engine: ContainerEngine, args: string): string {
81
+ return `${engine} compose ${args}`;
82
+ }
83
+
84
+ export interface CommandResult {
85
+ exitCode: number;
86
+ stdout: string;
87
+ stderr: string;
88
+ }
89
+
90
+ export type CommandRunner = (argv: string[], options?: { cwd?: string }) => Promise<CommandResult>;
91
+
92
+ /** Runs a command without throwing. A binary that cannot be spawned reports exit code 127. */
93
+ export const defaultCommandRunner: CommandRunner = async (argv, options) => {
94
+ try {
95
+ const proc = Bun.spawn(argv, {
96
+ cwd: options?.cwd,
97
+ stdout: "pipe",
98
+ stderr: "pipe",
99
+ stdin: "ignore",
100
+ });
101
+ const [stdout, stderr, exitCode] = await Promise.all([
102
+ new Response(proc.stdout).text(),
103
+ new Response(proc.stderr).text(),
104
+ proc.exited,
105
+ ]);
106
+ return { exitCode, stdout, stderr };
107
+ } catch (err) {
108
+ return {
109
+ exitCode: 127,
110
+ stdout: "",
111
+ stderr: err instanceof Error ? err.message : String(err),
112
+ };
113
+ }
114
+ };
115
+
116
+ export interface EngineCheck {
117
+ ok: boolean;
118
+ version?: string;
119
+ error?: string;
120
+ hint?: string;
121
+ }
122
+
123
+ export interface EngineProbe {
124
+ engine: ContainerEngine;
125
+ binary: EngineCheck;
126
+ compose: EngineCheck;
127
+ }
128
+
129
+ export type EngineResolution =
130
+ | { ok: true; engine: ContainerEngine; probe: EngineProbe }
131
+ | { ok: false; engine: ContainerEngine | null; error: string; probe: EngineProbe | null };
132
+
133
+ function firstLine(text: string): string {
134
+ return text.trim().split("\n")[0]?.trim() ?? "";
135
+ }
136
+
137
+ async function checkBinary(engine: ContainerEngine, run: CommandRunner): Promise<EngineCheck> {
138
+ const label = CONTAINER_ENGINE_LABELS[engine];
139
+ const result = await run([engine, "--version"]);
140
+ if (result.exitCode === 0) return { ok: true, version: firstLine(result.stdout) };
141
+ return {
142
+ ok: false,
143
+ error: result.exitCode === 127 ? `${label} not found` : `${label} exited with non-zero status`,
144
+ hint: ENGINE_INSTALL_HINTS[engine],
145
+ };
146
+ }
147
+
148
+ async function checkCompose(engine: ContainerEngine, run: CommandRunner): Promise<EngineCheck> {
149
+ const result = await run(composeArgv(engine, ["version"]));
150
+ if (result.exitCode === 0) {
151
+ // `podman compose` prints its provider banner on stderr; the version is on stdout.
152
+ return { ok: true, version: firstLine(result.stdout) || firstLine(result.stderr) };
153
+ }
154
+ if (engine === "podman") {
155
+ const detail = firstLine(result.stderr) || firstLine(result.stdout);
156
+ return {
157
+ ok: false,
158
+ error: `podman compose has no working Compose provider${detail ? ` (${detail})` : ""}`,
159
+ hint: PODMAN_COMPOSE_PROVIDER_HINT,
160
+ };
161
+ }
162
+ return {
163
+ ok: false,
164
+ error: "Docker Compose v2 not found",
165
+ hint: "Install the Compose v2 plugin: https://docs.docker.com/compose/install/",
166
+ };
167
+ }
168
+
169
+ /** Check one engine: the binary first, then its Compose subcommand. */
170
+ export async function probeEngine(
171
+ engine: ContainerEngine,
172
+ run: CommandRunner = defaultCommandRunner,
173
+ ): Promise<EngineProbe> {
174
+ const binary = await checkBinary(engine, run);
175
+ if (!binary.ok) {
176
+ return { engine, binary, compose: { ok: false, error: "skipped: engine unavailable" } };
177
+ }
178
+ return { engine, binary, compose: await checkCompose(engine, run) };
179
+ }
180
+
181
+ function probeError(probe: EngineProbe): string {
182
+ if (!probe.binary.ok) return probe.binary.error ?? "engine unavailable";
183
+ return probe.compose.error ?? "Compose unavailable";
184
+ }
185
+
186
+ /**
187
+ * Resolve the engine to use. An explicit choice never falls back to the other
188
+ * engine. `auto` picks Docker whenever the `docker` binary exists (so a broken
189
+ * Docker Compose still reports as a Docker problem, as before) and only tries
190
+ * Podman when Docker is absent.
191
+ */
192
+ export async function resolveContainerEngine(
193
+ preference: ContainerEnginePreference,
194
+ run: CommandRunner = defaultCommandRunner,
195
+ ): Promise<EngineResolution> {
196
+ if (preference !== "auto") {
197
+ const probe = await probeEngine(preference, run);
198
+ if (probe.binary.ok && probe.compose.ok) return { ok: true, engine: preference, probe };
199
+ return { ok: false, engine: preference, error: probeError(probe), probe };
200
+ }
201
+
202
+ const docker = await probeEngine("docker", run);
203
+ if (docker.binary.ok) {
204
+ if (docker.compose.ok) return { ok: true, engine: "docker", probe: docker };
205
+ return { ok: false, engine: "docker", error: probeError(docker), probe: docker };
206
+ }
207
+
208
+ const podman = await probeEngine("podman", run);
209
+ if (podman.binary.ok) {
210
+ if (podman.compose.ok) return { ok: true, engine: "podman", probe: podman };
211
+ return { ok: false, engine: "podman", error: probeError(podman), probe: podman };
212
+ }
213
+
214
+ return {
215
+ ok: false,
216
+ engine: null,
217
+ error: "Neither Docker nor Podman was found",
218
+ probe: docker,
219
+ };
220
+ }
@@ -18,6 +18,7 @@ export function generateManifest(state: OnboardState): object {
18
18
  providerLabel: PROVIDER_LABELS[state.provider],
19
19
  providerNotice: state.provider === "bedrock" ? BEDROCK_ALPHA_NOTICE : undefined,
20
20
  harness: state.harness,
21
+ containerEngine: state.containerEngine,
21
22
  services: expanded.map((svc) => ({
22
23
  name: svc.name,
23
24
  templateId: svc.entry.template,
@@ -1,6 +1,7 @@
1
1
  import { Select, Spinner } from "@inkjs/ui";
2
2
  import { Box, Text } from "ink";
3
3
  import { useEffect, useRef, useState } from "react";
4
+ import { composeCommandText, engineForCommands } from "../container-engine.ts";
4
5
  import type { StepProps } from "../types.ts";
5
6
 
6
7
  type HealthStatus = "waiting_api" | "waiting_agents" | "healthy" | "timeout";
@@ -148,7 +149,9 @@ export function HealthCheckStep({ state, addLog, goToNext }: StepProps) {
148
149
  return (
149
150
  <Box flexDirection="column" padding={1}>
150
151
  <Text color="red">{"✗"} API health check timed out after 60s</Text>
151
- <Text dimColor>Try: docker compose logs --tail 50</Text>
152
+ <Text dimColor>
153
+ Try: {composeCommandText(engineForCommands(state.containerEngine), "logs --tail 50")}
154
+ </Text>
152
155
  <Box marginTop={1}>
153
156
  <Select
154
157
  options={[