pmx-canvas 0.6.4 → 0.7.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 (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -23,6 +23,7 @@
23
23
  * - POST /api/canvas/graph -> create a native graph node
24
24
  * - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
25
25
  * - GET /api/canvas/file-bytes?nodeId=... -> raw bytes for a file node
26
+ * - GET /api/canvas/exports/<file> -> a written static board export (sandboxed)
26
27
  * - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
27
28
  * - GET /api/workbench/events -> SSE event stream
28
29
  * - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
@@ -38,9 +39,11 @@
38
39
 
39
40
  import { spawnSync } from 'node:child_process';
40
41
  import { randomUUID } from 'node:crypto';
42
+ import { isWorkbenchToken, workbenchToken } from './workbench-auth.js';
43
+ import { withCurrentActor } from './attribution.js';
41
44
  import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
42
45
  import { readFile } from 'node:fs/promises';
43
- import { basename, extname, join, relative, resolve } from 'node:path';
46
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
44
47
  import * as marked from 'marked';
45
48
  import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
46
49
  import {
@@ -67,11 +70,13 @@ import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
67
70
  import { canOpenNodeAsSurface } from '../shared/surface.js';
68
71
  import { validateLocalImageFile } from './image-source.js';
69
72
  import {
73
+ addCanvasNode,
70
74
  cancelCodeGraphRecompute,
71
75
  primeCanvasRuntimeBackends,
72
76
  setCanvasLayoutUpdateEmitter,
73
77
  syncCanvasRuntimeBackends,
74
78
  } from './canvas-operations.js';
79
+ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_NAME_LENGTH } from './document-import.js';
75
80
  import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
76
81
  import { intentRegistry } from './intent-registry.js';
77
82
  import { agentPresence } from './agent-presence.js';
@@ -79,6 +84,8 @@ import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
79
84
  import { diffLayouts } from './mutation-history.js';
80
85
  import { humanPresence } from './human-presence.js';
81
86
  import { startGateTtlSweeper, stopGateTtlSweeper } from './ax-gate-ttl.js';
87
+ import { startBackupScheduler, stopBackupScheduler } from './backup.js';
88
+ import { exportsFolder } from './board-export.js';
82
89
  import { setWebviewRunner } from './operations/webview-runner.js';
83
90
  import { closeNodeAppSession, nodeAppSessionId } from './operations/ops/nodes.js';
84
91
  import { traceManager } from './trace-manager.js';
@@ -143,7 +150,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
143
150
  let snapshot = startSnapshotId
144
151
  ? (canvasState.listSnapshots({ all: true }).find((entry) => entry.id === startSnapshotId) ?? null)
145
152
  : null;
146
- let unchanged = false;
153
+ let unchanged = !startSnapshotId && canvasState.getLayout().nodes.length === 0;
147
154
  // A session that changed NOTHING on the board leaves no snapshot behind —
148
155
  // session churn (idle timeouts, receipt tests, attach/detach cycles) was
149
156
  // flooding the History drawer with identical boards.
@@ -184,6 +191,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
184
191
  // workbench session and would overwrite it. The label is what the receipt shows.
185
192
  emitPrimaryWorkbenchEvent('agent-session-ended', {
186
193
  label: presence.label,
194
+ parentAgentId: presence.parentAgentId ?? null,
187
195
  endedAt,
188
196
  endedBy,
189
197
  counts,
@@ -970,6 +978,39 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
970
978
  const CANVAS_ASSET_VERSION = Date.now().toString(36);
971
979
  const MAX_FRAME_DOCUMENTS = 128;
972
980
  const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
981
+
982
+ async function readBoundedBody(req: Request, limit: number): Promise<Uint8Array | null> {
983
+ if (!req.body) return new Uint8Array();
984
+ const reader = req.body.getReader();
985
+ const chunks: Uint8Array[] = [];
986
+ let size = 0;
987
+ try {
988
+ while (true) {
989
+ const { done, value } = await reader.read();
990
+ if (done) break;
991
+ size += value.byteLength;
992
+ if (size > limit) {
993
+ await reader.cancel();
994
+ return null;
995
+ }
996
+ chunks.push(value);
997
+ }
998
+ } finally {
999
+ reader.releaseLock();
1000
+ }
1001
+ const bytes = new Uint8Array(size);
1002
+ let offset = 0;
1003
+ for (const chunk of chunks) {
1004
+ bytes.set(chunk, offset);
1005
+ offset += chunk.byteLength;
1006
+ }
1007
+ return bytes;
1008
+ }
1009
+
1010
+ function attachmentDisposition(name: string): string {
1011
+ const fallback = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
1012
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`;
1013
+ }
973
1014
  const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
974
1015
  const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
975
1016
  'allow-downloads',
@@ -1111,6 +1152,7 @@ function canvasSpaHtml(): string {
1111
1152
  }
1112
1153
  <script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
1113
1154
  <script>window.__PMX_BOOT_BUNDLE_STAMP = ${JSON.stringify(canvasBundleStamp())};</script>
1155
+ <script>window.__PMX_WORKBENCH_TOKEN = ${JSON.stringify(workbenchToken)};</script>
1114
1156
  <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1115
1157
  </body>
1116
1158
  </html>`;
@@ -1431,15 +1473,17 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1431
1473
  // escaped diagram source, rendered by /canvas/mermaid-entry.js.
1432
1474
  const inlineAssets = url.searchParams.get('inline-assets') === '1';
1433
1475
  const doc = buildHtmlSurfaceDocument(
1434
- buildMermaidSurfaceHtml(source, inlineAssets ? mermaidEntryJsInline() : null),
1476
+ buildMermaidSurfaceHtml(
1477
+ source,
1478
+ inlineAssets ? mermaidEntryJsInline() : null,
1479
+ node.data.fit === 'none' ? 'none' : 'contain',
1480
+ url.searchParams.get('frameToken') ?? '',
1481
+ ),
1435
1482
  {
1436
1483
  theme,
1437
1484
  title: surfaceTitle,
1438
1485
  themeToken: url.searchParams.get('themeToken') ?? undefined,
1439
- // Content-height reporter nonce (lets the node grow to fit the diagram).
1440
- ...(url.searchParams.get('frameToken')
1441
- ? { contentHeightToken: url.searchParams.get('frameToken') as string }
1442
- : {}),
1486
+ // Mermaid reports its natural height itself, before contain scaling.
1443
1487
  // Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
1444
1488
  // are styled without depending on the <link> subresource.
1445
1489
  ...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
@@ -1555,6 +1599,39 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1555
1599
  });
1556
1600
  }
1557
1601
 
1602
+ // ── Serve a written board export (plan 013) ─────────────────
1603
+ // Only names the exporter writes, only from the exports folder. The page runs
1604
+ // its own viewer script, so it is served with an opaque-origin sandbox: it can
1605
+ // never reach this server's API as the workbench.
1606
+ const EXPORT_FILE_NAME = /^[a-z0-9-]+-\d{8}T\d{9}Z(?:-\d+)?\.html$/;
1607
+
1608
+ function handleBoardExportFile(url: URL): Response {
1609
+ const name = decodeURIComponent(url.pathname.slice('/api/canvas/exports/'.length));
1610
+ const folder = exportsFolder();
1611
+ if (!folder || !EXPORT_FILE_NAME.test(name)) return responseText('Export not found', 404);
1612
+ let path: string;
1613
+ try {
1614
+ const realFolder = realpathSync(folder);
1615
+ path = realpathSync(join(folder, name));
1616
+ const rel = relative(realFolder, path);
1617
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || !statSync(path).isFile()) {
1618
+ return responseText('Export not found', 404);
1619
+ }
1620
+ } catch {
1621
+ return responseText('Export not found', 404);
1622
+ }
1623
+ const download = url.searchParams.get('download') === '1';
1624
+ return new Response(Bun.file(path), {
1625
+ headers: {
1626
+ 'Content-Type': 'text/html; charset=utf-8',
1627
+ 'Content-Security-Policy': 'sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox',
1628
+ 'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${name}"`,
1629
+ 'X-Content-Type-Options': 'nosniff',
1630
+ 'Cache-Control': 'no-store',
1631
+ },
1632
+ });
1633
+ }
1634
+
1558
1635
  // ── Serve raw bytes for file nodes (PDF viewer, downloads) ───
1559
1636
  /**
1560
1637
  * Ceiling for the raw byte route. The route streams via Bun.file, so this is a
@@ -3217,6 +3294,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3217
3294
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
3218
3295
  // Unattended-approval policy: hold pending gates whose TTL elapsed.
3219
3296
  startGateTtlSweeper((event, payload) => emitPrimaryWorkbenchEvent(event, payload));
3297
+ startBackupScheduler();
3220
3298
  setCanvasLayoutUpdateEmitter(() => {
3221
3299
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3222
3300
  });
@@ -3370,6 +3448,69 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3370
3448
  // theme/update/viewport/annotation/refresh (plan-009 C1 slices 1-3). A
3371
3449
  // null return falls through to the remaining hand-written routes
3372
3450
  // (SSE, binary, HTML/static serving, and the server-coupled handlers).
3451
+ if (url.pathname === '/api/canvas/attachments' && req.method === 'POST') {
3452
+ const boardId = url.searchParams.get('boardId')?.trim() ?? '';
3453
+ const name = url.searchParams.get('name')?.trim() ?? '';
3454
+ const mime = url.searchParams.get('mime')?.trim() || 'application/octet-stream';
3455
+ const x = Number(url.searchParams.get('x') ?? 40);
3456
+ const y = Number(url.searchParams.get('y') ?? 40);
3457
+ if (!boardId || !name || !Number.isFinite(x) || !Number.isFinite(y))
3458
+ return responseJson({ ok: false, error: 'boardId, name, and finite x/y are required.' }, 400);
3459
+ if (name.length > MAX_ATTACHMENT_NAME_LENGTH)
3460
+ return responseJson(
3461
+ { ok: false, error: `Attachment name exceeds ${MAX_ATTACHMENT_NAME_LENGTH} characters.` },
3462
+ 400,
3463
+ );
3464
+ if (Number(req.headers.get('content-length') ?? 0) > MAX_ATTACHMENT_BYTES)
3465
+ return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
3466
+ const bytes = await readBoundedBody(req, MAX_ATTACHMENT_BYTES);
3467
+ if (!bytes) return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
3468
+ if (bytes.byteLength === 0) return responseJson({ ok: false, error: 'Attachment is empty.' }, 400);
3469
+ if (canvasState.activeBoardId !== boardId)
3470
+ return responseJson({ ok: false, error: 'The captured target board is no longer active.' }, 409);
3471
+ if (!isWorkbenchToken(req.headers.get('x-pmx-workbench-token')))
3472
+ return responseJson({ ok: false, error: 'A trusted human upload is required.' }, 403);
3473
+ const attachment = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
3474
+ canvasState.storeAttachment({ boardId, name, mime, bytes }),
3475
+ );
3476
+ if (!attachment) return responseJson({ ok: false, error: 'Board not found.' }, 404);
3477
+ const { node } = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
3478
+ addCanvasNode({
3479
+ type: 'file',
3480
+ title: name,
3481
+ data: { attachmentId: attachment.id, mime, size: attachment.size },
3482
+ x,
3483
+ y,
3484
+ defaultWidth: 440,
3485
+ defaultHeight: 360,
3486
+ fileMode: 'auto',
3487
+ }),
3488
+ );
3489
+ const created = canvasState.createDocumentImport(attachment.id, { x, y: y + 400 });
3490
+ const documentImport = created;
3491
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3492
+ return responseJson({ ok: true, attachment, nodeId: node.id, import: documentImport }, 201);
3493
+ }
3494
+
3495
+ const attachmentBytesMatch = url.pathname.match(/^\/api\/canvas\/attachments\/([^/]+)\/bytes$/);
3496
+ if (attachmentBytesMatch && (req.method === 'GET' || req.method === 'HEAD')) {
3497
+ const id = decodeURIComponent(attachmentBytesMatch[1]!);
3498
+ const attachment = canvasState.getAttachment(id);
3499
+ if (!attachment || !canvasState.listBoards().some((board) => board.id === attachment.boardId))
3500
+ return responseJson({ ok: false, error: 'Attachment not found.' }, 404);
3501
+ const bytes = canvasState.readAttachmentBytes(id);
3502
+ if (!bytes) return responseJson({ ok: false, error: 'Attachment bytes are missing.' }, 404);
3503
+ return new Response(req.method === 'HEAD' ? null : new Blob([Uint8Array.from(bytes)]), {
3504
+ headers: {
3505
+ 'Content-Type': 'application/octet-stream',
3506
+ 'Content-Length': String(bytes.byteLength),
3507
+ 'Content-Disposition': attachmentDisposition(attachment.name),
3508
+ 'X-Content-Type-Options': 'nosniff',
3509
+ 'Cache-Control': 'no-store',
3510
+ },
3511
+ });
3512
+ }
3513
+
3373
3514
  if (url.pathname.startsWith('/api/')) {
3374
3515
  const operationResponse = await dispatchOperationRoute(req, url);
3375
3516
  if (operationResponse) return operationResponse;
@@ -3393,6 +3534,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3393
3534
 
3394
3535
  // HEAD answers like GET (see the standalone-surface note above): a PDF
3395
3536
  // viewer probes this URL for type/length before fetching the bytes.
3537
+ if (url.pathname.startsWith('/api/canvas/exports/') && (req.method === 'GET' || req.method === 'HEAD')) {
3538
+ return handleBoardExportFile(url);
3539
+ }
3540
+
3396
3541
  if (url.pathname === '/api/canvas/file-bytes' && (req.method === 'GET' || req.method === 'HEAD')) {
3397
3542
  return await handleCanvasFileBytes(url);
3398
3543
  }
@@ -3428,11 +3573,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3428
3573
  }
3429
3574
 
3430
3575
  export function stopCanvasServer(): void {
3576
+ // A failed flush must leave the live server and its runtime state available for recovery.
3577
+ canvasState.close();
3431
3578
  stopGateTtlSweeper();
3579
+ stopBackupScheduler();
3432
3580
  agentPresence.reset();
3433
3581
  humanPresence.reset();
3434
3582
  intentRegistry.reset();
3435
- canvasState.close();
3436
3583
  closeAllMcpAppSessions();
3437
3584
  cancelCodeGraphRecompute();
3438
3585
  setCanvasLayoutUpdateEmitter(null);
@@ -306,14 +306,12 @@ export function searchNodes(
306
306
 
307
307
  for (const node of nodes) {
308
308
  const title = ((node.data.title as string) ?? '').toLowerCase();
309
- const content = (
310
- (node.data.content as string) ??
311
- (node.data.agentSummary as string) ??
312
- (node.data.contentSummary as string) ??
313
- (node.data.description as string) ??
314
- (node.data.fileContent as string) ??
315
- ''
316
- ).toLowerCase();
309
+ // Search the same meaning delivered to agents, without a brief's truncation.
310
+ const fullContent = summarizeNodeForAgentContext(node, {
311
+ defaultTextLength: Number.POSITIVE_INFINITY,
312
+ webpageTextLength: Number.POSITIVE_INFINITY,
313
+ });
314
+ const content = fullContent.toLowerCase();
317
315
  const path = ((node.data.path as string) ?? '').toLowerCase();
318
316
  const description = ((node.data.description as string) ?? '').toLowerCase();
319
317
  const summary = (
@@ -337,16 +335,10 @@ export function searchNodes(
337
335
 
338
336
  if (score === 0) continue;
339
337
 
340
- // Extract a snippet around the first match in content
338
+ // Any query term may have matched, not necessarily the first one.
341
339
  let snippet = '';
342
- const fullContent =
343
- (node.data.content as string) ??
344
- (node.data.agentSummary as string) ??
345
- (node.data.contentSummary as string) ??
346
- (node.data.description as string) ??
347
- (node.data.fileContent as string) ??
348
- '';
349
- const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
340
+ const matches = terms.map((term) => content.indexOf(term)).filter((index) => index >= 0);
341
+ const matchIdx = matches.length ? Math.min(...matches) : -1;
350
342
  if (matchIdx >= 0) {
351
343
  const start = Math.max(0, matchIdx - 40);
352
344
  const end = Math.min(fullContent.length, matchIdx + 80);
@@ -88,6 +88,7 @@ class TraceManager {
88
88
  mcpToolName?: string | null;
89
89
  }): void {
90
90
  if (!this._enabled) return;
91
+ this.dropStaleChain();
91
92
 
92
93
  const id = nextTraceNodeId();
93
94
  const category = categorize(payload.name, payload.mcpServerName);
@@ -178,6 +179,7 @@ class TraceManager {
178
179
 
179
180
  onSubagentStarted(payload: { agentName: string; agentDisplayName?: string }): void {
180
181
  if (!this._enabled) return;
182
+ this.dropStaleChain();
181
183
 
182
184
  const id = nextTraceNodeId();
183
185
  const origin = this.getOrigin();
@@ -264,6 +266,23 @@ class TraceManager {
264
266
  this.broadcastUpdate();
265
267
  }
266
268
 
269
+ /**
270
+ * The chain's last node is gone (another board was opened, or it was
271
+ * deleted): start a new chain instead of wiring edges to a missing node.
272
+ */
273
+ private dropStaleChain(): void {
274
+ if (this.lastTraceNodeId && !canvasState.getNode(this.lastTraceNodeId)) this.resetChain();
275
+ }
276
+
277
+ /** Forget the trace chain without touching nodes. */
278
+ resetChain(): void {
279
+ this.traceNodeIds = [];
280
+ this.lastTraceNodeId = null;
281
+ this.toolCallToNodeId.clear();
282
+ this.traceOrigin = null;
283
+ this.chainIndex = 0;
284
+ }
285
+
267
286
  clearTrace(): void {
268
287
  const traceNodeIds = new Set(this.traceNodeIds);
269
288
  for (const node of canvasState.getLayout().nodes) {
@@ -13,7 +13,7 @@ import {
13
13
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
14
  import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
15
15
  import { canvasState, type CanvasNodeState } from './canvas-state.js';
16
- import { emitCanvasLayoutUpdate } from './canvas-operations.js';
16
+ import { emitCanvasLayoutUpdate, type CanvasSizeAdjustment } from './canvas-operations.js';
17
17
  import { findOpenCanvasPosition } from './placement.js';
18
18
 
19
19
  const BUNDLED_WEB_ARTIFACT_SCRIPTS_DIR = join(import.meta.dir, 'web-artifacts', 'scripts');
@@ -95,12 +95,14 @@ export interface WebArtifactSourceContext {
95
95
  export interface WebArtifactCanvasOpenResult {
96
96
  nodeId: string;
97
97
  url: string;
98
+ sizeAdjustment: CanvasSizeAdjustment;
98
99
  }
99
100
 
100
101
  export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
101
102
  openedInCanvas: boolean;
102
103
  nodeId?: string;
103
104
  url?: string;
105
+ sizeAdjustment?: CanvasSizeAdjustment;
104
106
  startedAt: string;
105
107
  completedAt: string;
106
108
  durationMs: number;
@@ -740,7 +742,11 @@ export function openWebArtifactInCanvas(input: {
740
742
  // This keeps web-artifacts.ts server-independent so the registry op
741
743
  // (operations/ops/app.ts) can import it without pulling in server.ts.
742
744
  emitCanvasLayoutUpdate();
743
- return { nodeId: id, url };
745
+ return {
746
+ nodeId: id,
747
+ url,
748
+ sizeAdjustment: { requested: { width: null, height: null }, applied: node.size, reason: 'defaulted' },
749
+ };
744
750
  }
745
751
 
746
752
  export async function buildWebArtifactOnCanvas(
@@ -748,6 +754,7 @@ export async function buildWebArtifactOnCanvas(
748
754
  openInCanvas?: boolean;
749
755
  },
750
756
  ): Promise<WebArtifactCanvasBuildResult> {
757
+ const boardGeneration = canvasState.boardGeneration;
751
758
  const startedMs = Date.now();
752
759
  const startedAt = new Date(startedMs).toISOString();
753
760
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -759,7 +766,7 @@ export async function buildWebArtifactOnCanvas(
759
766
  durationMs: completedMs - startedMs,
760
767
  timeoutMs,
761
768
  };
762
- if (input.openInCanvas === false) {
769
+ if (input.openInCanvas === false || canvasState.boardGeneration !== boardGeneration) {
763
770
  return { ...build, openedInCanvas: false, ...timing };
764
771
  }
765
772
  const opened = openWebArtifactInCanvas({
@@ -778,6 +785,7 @@ export async function buildWebArtifactOnCanvas(
778
785
  openedInCanvas: true,
779
786
  nodeId: opened.nodeId,
780
787
  url: opened.url,
788
+ sizeAdjustment: opened.sizeAdjustment,
781
789
  ...timing,
782
790
  };
783
791
  }
@@ -1,7 +1,14 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { isIP } from 'node:net';
5
+ import { Readable } from 'node:stream';
6
+
1
7
  const FETCH_TIMEOUT_MS = 10000;
2
8
  const MAX_HTML_LENGTH = 1_000_000;
3
9
  const MAX_TEXT_LENGTH = 50_000;
4
10
  const EXCERPT_LENGTH = 420;
11
+ const MAX_REDIRECTS = 5;
5
12
 
6
13
  export const WEBPAGE_NODE_DEFAULT_SIZE = {
7
14
  width: 520,
@@ -236,24 +243,177 @@ export function summarizeWebpageContent(data: Record<string, unknown>, maxLength
236
243
  return parts.join('\n').trim();
237
244
  }
238
245
 
239
- export async function fetchWebpageSnapshot(inputUrl: string): Promise<WebpageSnapshot> {
246
+ export type AddressScope = 'public' | 'private' | 'link-local';
247
+
248
+ /**
249
+ * Where an IP literal points. `private` covers loopback, RFC 1918, CGNAT and
250
+ * IPv6 ULA; `link-local` is kept apart because it holds cloud metadata
251
+ * (169.254.169.254) and is refused on every hop.
252
+ */
253
+ export function classifyAddress(address: string): AddressScope {
254
+ let ip = address.toLowerCase().replace(/^\[|\]$/g, '');
255
+ // URL canonicalization also covers expanded IPv6 and dotted mapped IPv4.
256
+ // Checking only ::ffff:a.b.c.d misses the hex form used by URL.hostname.
257
+ if (isIP(ip) === 6) ip = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
258
+ const mapped = ip.match(/^::ffff:([\da-f]+):([\da-f]+)$/);
259
+ if (mapped) {
260
+ const high = Number.parseInt(mapped[1], 16);
261
+ const low = Number.parseInt(mapped[2], 16);
262
+ return classifyAddress(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
263
+ }
264
+ if (isIP(ip) === 4) {
265
+ const [a, b] = ip.split('.').map(Number);
266
+ if (a === 169 && b === 254) return 'link-local';
267
+ if (a === 127 || a === 10 || a === 0) return 'private';
268
+ if (a === 172 && b >= 16 && b <= 31) return 'private';
269
+ if (a === 192 && b === 168) return 'private';
270
+ if (a === 100 && b >= 64 && b <= 127) return 'private';
271
+ return 'public';
272
+ }
273
+ if (/^fe[89ab]/.test(ip)) return 'link-local';
274
+ if (ip === '::1' || ip === '::' || /^f[cd]/.test(ip)) return 'private';
275
+ return 'public';
276
+ }
277
+
278
+ interface ResolvedHop {
279
+ addresses: string[];
280
+ scope: AddressScope;
281
+ }
282
+
283
+ type ResolveHost = (hostname: string) => Promise<Array<{ address: string; family: number }>>;
284
+
285
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
286
+ return new Promise((resolve, reject) => {
287
+ const abort = () => reject(new DOMException('The operation was aborted.', 'AbortError'));
288
+ if (signal.aborted) return abort();
289
+ signal.addEventListener('abort', abort, { once: true });
290
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
291
+ });
292
+ }
293
+
294
+ /** Resolves a hop once, retaining the narrowest scope any returned address reaches. */
295
+ async function resolveHop(url: URL, resolveHost: ResolveHost): Promise<ResolvedHop> {
296
+ const host = url.hostname.replace(/^\[|\]$/g, '');
297
+ const addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await resolveHost(host);
298
+ const selected = addresses[0];
299
+ if (!selected || (selected.family !== 4 && selected.family !== 6)) {
300
+ throw new WebpageFetchError(`Could not resolve ${host}.`);
301
+ }
302
+ const scopes = addresses.map((entry) => classifyAddress(entry.address));
303
+ const scope = scopes.includes('link-local') ? 'link-local' : scopes.includes('private') ? 'private' : 'public';
304
+ return { addresses: addresses.map((entry) => entry.address), scope };
305
+ }
306
+
307
+ async function requestHop(url: URL, resolved: ResolvedHop, signal: AbortSignal): Promise<Response> {
308
+ let failure: unknown;
309
+ for (const address of resolved.addresses) {
310
+ try {
311
+ return await requestAddress(url, address, signal);
312
+ } catch (error) {
313
+ if (signal.aborted) throw error;
314
+ failure = error;
315
+ }
316
+ }
317
+ throw failure;
318
+ }
319
+
320
+ function requestAddress(url: URL, address: string, signal: AbortSignal): Promise<Response> {
321
+ return new Promise((resolve, reject) => {
322
+ const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
323
+ url,
324
+ {
325
+ method: 'GET',
326
+ hostname: address,
327
+ servername: isIP(url.hostname.replace(/^\[|\]$/g, '')) ? undefined : url.hostname,
328
+ signal,
329
+ headers: {
330
+ Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1',
331
+ Host: url.host,
332
+ 'User-Agent': 'pmx-canvas webpage node',
333
+ },
334
+ },
335
+ (incoming) => {
336
+ const headers = new Headers();
337
+ for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
338
+ headers.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
339
+ }
340
+ const status = incoming.statusCode ?? 500;
341
+ const empty = status === 204 || status === 205 || status === 304;
342
+ // Node and DOM declare separate types for the same web-stream API.
343
+ const body = empty ? null : (Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>);
344
+ if (empty) incoming.resume();
345
+ resolve(new Response(body, { status, statusText: incoming.statusMessage, headers }));
346
+ },
347
+ );
348
+ request.on('error', reject);
349
+ request.end();
350
+ });
351
+ }
352
+
353
+ async function readBoundedBody(response: Response): Promise<string> {
354
+ const reader = response.body?.getReader();
355
+ if (!reader) return '';
356
+ const decoder = new TextDecoder();
357
+ let body = '';
358
+ let bytes = 0;
359
+ while (bytes < MAX_HTML_LENGTH) {
360
+ const { done, value } = await reader.read();
361
+ if (done) break;
362
+ const remaining = MAX_HTML_LENGTH - bytes;
363
+ body += decoder.decode(value.subarray(0, remaining), { stream: value.byteLength <= remaining });
364
+ bytes += Math.min(value.byteLength, remaining);
365
+ if (value.byteLength > remaining || bytes === MAX_HTML_LENGTH) {
366
+ await reader.cancel();
367
+ break;
368
+ }
369
+ }
370
+ return body + decoder.decode();
371
+ }
372
+
373
+ /**
374
+ * Refuses link-local on every hop, and a redirect that moves a public fetch
375
+ * onto a loopback or private address. A URL the caller points at a local dev
376
+ * server on purpose still loads, and may redirect within the local network.
377
+ */
378
+ export function checkWebpageHop(originScope: AddressScope, hopScope: AddressScope, hopUrl: string): void {
379
+ if (hopScope === 'link-local') {
380
+ throw new WebpageFetchError(`Refused to fetch ${hopUrl}: link-local addresses are not allowed.`);
381
+ }
382
+ if (originScope === 'public' && hopScope === 'private') {
383
+ throw new WebpageFetchError(`Refused redirect to ${hopUrl}: a public page may not redirect to a private address.`);
384
+ }
385
+ }
386
+
387
+ export async function fetchWebpageSnapshot(
388
+ inputUrl: string,
389
+ options: { resolveHost?: ResolveHost; timeoutMs?: number } = {},
390
+ ): Promise<WebpageSnapshot> {
240
391
  const url = normalizeWebpageUrl(inputUrl);
241
392
  const controller = new AbortController();
242
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
393
+ const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
394
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
395
+ const resolveHost = options.resolveHost ?? ((host) => lookup(host, { all: true }));
243
396
 
244
397
  try {
245
- const response = await fetch(url, {
246
- redirect: 'follow',
247
- signal: controller.signal,
248
- headers: {
249
- Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1',
250
- 'User-Agent': 'pmx-canvas webpage node',
251
- },
252
- });
398
+ // Redirects are followed by hand so every hop's host is checked before it is fetched.
399
+ let resolved = await abortable(resolveHop(new URL(url), resolveHost), controller.signal);
400
+ const originScope = resolved.scope;
401
+ let hopUrl = url;
402
+ let response: Response;
403
+ for (let hop = 0; ; hop++) {
404
+ checkWebpageHop(originScope, resolved.scope, hopUrl);
405
+ response = await requestHop(new URL(hopUrl), resolved, controller.signal);
406
+ const location = response.headers.get('location');
407
+ if (response.status < 300 || response.status >= 400 || !location) break;
408
+ await response.body?.cancel();
409
+ if (hop === MAX_REDIRECTS) throw new WebpageFetchError(`Too many redirects while fetching ${url}.`);
410
+ hopUrl = normalizeWebpageUrl(new URL(location, hopUrl).toString());
411
+ resolved = await abortable(resolveHop(new URL(hopUrl), resolveHost), controller.signal);
412
+ }
253
413
 
254
414
  const contentType = response.headers.get('content-type');
255
- const responseUrl = normalizeWebpageUrl(response.url || url);
256
- const body = (await response.text()).slice(0, MAX_HTML_LENGTH);
415
+ const responseUrl = hopUrl;
416
+ const body = await readBoundedBody(response);
257
417
 
258
418
  if (!response.ok) {
259
419
  throw new WebpageFetchError(`Request failed with ${response.status} ${response.statusText}`.trim(), {
@@ -292,7 +452,7 @@ export async function fetchWebpageSnapshot(inputUrl: string): Promise<WebpageSna
292
452
  } catch (error) {
293
453
  if (error instanceof WebpageFetchError) throw error;
294
454
  if (error instanceof Error && error.name === 'AbortError') {
295
- throw new WebpageFetchError(`Timed out after ${FETCH_TIMEOUT_MS}ms while fetching ${url}.`);
455
+ throw new WebpageFetchError(`Timed out after ${timeoutMs}ms while fetching ${url}.`);
296
456
  }
297
457
  throw new WebpageFetchError(error instanceof Error ? error.message : `Failed to fetch ${url}.`);
298
458
  } finally {
@@ -0,0 +1,11 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+
3
+ /** Per-process capability used only to distinguish workbench clicks from HTTP agents. */
4
+ export const workbenchToken = randomBytes(32).toString('base64url');
5
+
6
+ export function isWorkbenchToken(value: string | null): boolean {
7
+ if (!value) return false;
8
+ const actual = Buffer.from(value);
9
+ const expected = Buffer.from(workbenchToken);
10
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
11
+ }
@@ -82,14 +82,14 @@ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
82
82
  }
83
83
  elements[columnId] = {
84
84
  type: 'Stack',
85
- props: { direction: 'vertical', gap: 'sm' },
85
+ props: { direction: 'vertical', gap: 'sm', align: 'stretch' },
86
86
  children: columnChildren,
87
87
  };
88
88
  columnIds.push(columnId);
89
89
  }
90
90
  elements.board = {
91
- type: 'Stack',
92
- props: { direction: 'horizontal', gap: 'md', align: 'start' },
91
+ type: 'Grid',
92
+ props: { columns: columnIds.length, gap: 'md' },
93
93
  children: columnIds,
94
94
  };
95
95
  return { root: 'board', elements };