pmx-canvas 0.10.0 → 0.12.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 (343) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +3 -1
  2. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  3. package/.github/extensions/pmx-canvas/tools.json +688 -128
  4. package/CHANGELOG.md +69 -0
  5. package/dist/amp-plugin/pmx-canvas/LICENSE +21 -0
  6. package/dist/amp-plugin/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  7. package/dist/amp-plugin/pmx-canvas/index.js +5445 -0
  8. package/dist/amp-plugin/pmx-canvas/runtime.json +3 -0
  9. package/dist/amp-plugin/pmx-canvas/shared-runtime.mjs +194 -0
  10. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/SKILL.md +251 -0
  11. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/evals.json +1086 -0
  12. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/jwt.ts +17 -0
  13. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/login.ts +12 -0
  14. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/middleware.ts +13 -0
  15. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/routes/auth.ts +13 -0
  16. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/investigation-board/src/handlers/users.ts +27 -0
  17. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/mcp.json +36 -0
  18. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/ax-html-control-surface.md +101 -0
  19. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/codex-app-adapter.md +151 -0
  20. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +150 -0
  21. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/full-reference.md +1785 -0
  22. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/github-copilot-app-adapter.md +223 -0
  23. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/html-primitives.md +139 -0
  24. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/importing-context.md +132 -0
  25. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/installing-pmx-canvas.md +251 -0
  26. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/tours-and-recording.md +111 -0
  27. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/workbench-workflows.md +900 -0
  28. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas-orchestration/SKILL.md +141 -0
  29. package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas-testing/SKILL.md +266 -0
  30. package/dist/amp-plugin/pmx-canvas/start-mcp.mjs +14857 -0
  31. package/dist/amp-plugin/pmx-canvas/tools.json +10963 -0
  32. package/dist/canvas/chunks/abnfDiagram-N423BO3Z-cwtsds5d.js +1 -0
  33. package/dist/canvas/chunks/architecture-TIHT7OUA-x63cwejs.js +1 -0
  34. package/dist/canvas/chunks/architectureDiagram-T3A2C74G-b4xc9tpz.js +36 -0
  35. package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-ahp3yxxs.js +132 -0
  36. package/dist/canvas/chunks/c4Diagram-5PPSVZJV-nappnay7.js +10 -0
  37. package/dist/canvas/chunks/classDiagram-JCYQIIEL-k1j2xc1t.js +1 -0
  38. package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-33x08xf2.js +1 -0
  39. package/dist/canvas/chunks/cose-bilkent-JH36ORCC-a428syda.js +1 -0
  40. package/dist/canvas/chunks/cynefin-VYW2F7L2-8v6jxfz8.js +1 -0
  41. package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-nspsqebj.js +62 -0
  42. package/dist/canvas/chunks/dagre-VZM6K2ZE-8y6177fh.js +4 -0
  43. package/dist/canvas/chunks/diagram-7IWD3JNH-vym028wn.js +30 -0
  44. package/dist/canvas/chunks/diagram-B4RE2ZJO-jddqb8sy.js +3 -0
  45. package/dist/canvas/chunks/diagram-LBJQPF4R-bjwbpkrn.js +24 -0
  46. package/dist/canvas/chunks/diagram-Q27KOJAE-j6ph6hcx.js +24 -0
  47. package/dist/canvas/chunks/diagram-UB23O5K3-p09w8jej.js +41 -0
  48. package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-j975ycq5.js +1 -0
  49. package/dist/canvas/chunks/erDiagram-JOGREHBK-sxp6hjn8.js +85 -0
  50. package/dist/canvas/chunks/eventmodeling-45OFAUF4-xzfgn6xg.js +1 -0
  51. package/dist/canvas/chunks/flowDiagram-UKHOOZJN-2nsmkskb.js +1 -0
  52. package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-4tv230y2.js +292 -0
  53. package/dist/canvas/chunks/gitGraph-TEB2WS4Q-macbhand.js +1 -0
  54. package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-mqgmnw9g.js +106 -0
  55. package/dist/canvas/chunks/index-037z5dz2.js +232 -0
  56. package/dist/canvas/chunks/index-081n3har.js +207 -0
  57. package/dist/canvas/chunks/index-5n03dmb9.js +2 -0
  58. package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
  59. package/dist/canvas/chunks/index-60pgzdqe.js +157 -0
  60. package/dist/canvas/chunks/index-6ceya556.js +332 -0
  61. package/dist/canvas/chunks/index-8g1vb57n.js +2 -0
  62. package/dist/canvas/chunks/index-8rjes9r6.js +2 -0
  63. package/dist/canvas/chunks/index-9022d1n9.js +16 -0
  64. package/dist/canvas/chunks/index-9b02042t.js +2 -0
  65. package/dist/canvas/chunks/index-ap4q4vez.js +2 -0
  66. package/dist/canvas/chunks/index-dyp2nhd3.js +2 -0
  67. package/dist/canvas/chunks/index-frvta4tm.js +2 -0
  68. package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
  69. package/dist/canvas/chunks/index-h9ycj8n2.js +2 -0
  70. package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
  71. package/dist/canvas/chunks/index-mergmj97.js +2 -0
  72. package/dist/canvas/chunks/index-nwsya6dj.js +301 -0
  73. package/dist/canvas/chunks/index-nz8qbd1p.js +2 -0
  74. package/dist/canvas/chunks/index-qhfrazv3.js +2 -0
  75. package/dist/canvas/chunks/index-v119nznk.js +4 -0
  76. package/dist/canvas/chunks/index-vpt37m3d.js +89 -0
  77. package/dist/canvas/chunks/index-y9ma6azy.js +2 -0
  78. package/dist/canvas/chunks/index-zx0j3a2z.js +179 -0
  79. package/dist/canvas/chunks/info-DKCQHKI2-a0ykzs9z.js +1 -0
  80. package/dist/canvas/chunks/infoDiagram-6WML65LV-qv97qq6a.js +2 -0
  81. package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-p9306gh0.js +70 -0
  82. package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-sv67zats.js +139 -0
  83. package/dist/canvas/chunks/kanban-definition-27J2QSJJ-k2pfam6p.js +89 -0
  84. package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
  85. package/dist/canvas/chunks/mermaid.core-83r0fdce.js +12 -0
  86. package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-vg2newm1.js +96 -0
  87. package/dist/canvas/chunks/packet-7NZHBO7P-tqnabcdv.js +1 -0
  88. package/dist/canvas/chunks/pegDiagram-VL7TDLO6-k2h7zc5n.js +1 -0
  89. package/dist/canvas/chunks/pie-RZYD4A2V-wke6gyhe.js +1 -0
  90. package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-hg169p1v.js +39 -0
  91. package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-bxtvsq0b.js +7 -0
  92. package/dist/canvas/chunks/radar-I7S5WNFK-w7ka0drw.js +1 -0
  93. package/dist/canvas/chunks/railroad-3IZDKUUU-y3vg318x.js +1 -0
  94. package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-6hqvcz3c.js +1 -0
  95. package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-vcae260m.js +1 -0
  96. package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-0dbyme23.js +1 -0
  97. package/dist/canvas/chunks/railroadDiagram-AXF67PYL-njyhcp4p.js +1 -0
  98. package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-2z6v09gm.js +84 -0
  99. package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-15mzam0e.js +40 -0
  100. package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-rbshyd2c.js +162 -0
  101. package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-sg26xwts.js +1 -0
  102. package/dist/canvas/chunks/stateDiagram-OKZ733FA-6eahp98e.js +1 -0
  103. package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-tty0ggn5.js +1 -0
  104. package/dist/canvas/chunks/swimlanes-SLNWSIFB-ze5db10t.js +1 -0
  105. package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-thze75gq.js +8 -0
  106. package/dist/canvas/chunks/timeline-definition-Z64GVDOM-vnp7jyy8.js +120 -0
  107. package/dist/canvas/chunks/treeView-QDETBFTQ-6ysffnm6.js +1 -0
  108. package/dist/canvas/chunks/treemap-6X3UGDF4-m3h9p066.js +1 -0
  109. package/dist/canvas/chunks/vennDiagram-T6HMQDX7-0qrnb7mj.js +34 -0
  110. package/dist/canvas/chunks/wardley-OPB4EBWU-dp37drwx.js +1 -0
  111. package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-8nxg8wqt.js +78 -0
  112. package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-nj921e7s.js +7 -0
  113. package/dist/canvas/global.css +483 -230
  114. package/dist/canvas/index.js +158 -219
  115. package/dist/canvas/mcp-app.html +4301 -548
  116. package/dist/canvas/mermaid-entry.js +338 -338
  117. package/dist/canvas/surface-theme.css +6 -6
  118. package/dist/json-render/index.css +1 -1
  119. package/dist/json-render/index.js +49 -49
  120. package/dist/types/client/canvas/BarHint.d.ts +5 -3
  121. package/dist/types/client/canvas/BoardMoveDialog.d.ts +2 -0
  122. package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
  123. package/dist/types/client/canvas/EdgeEditor.d.ts +19 -0
  124. package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
  125. package/dist/types/client/canvas/GroupNavigation.d.ts +2 -0
  126. package/dist/types/client/canvas/TextPrompt.d.ts +4 -0
  127. package/dist/types/client/canvas/auto-fit.d.ts +4 -5
  128. package/dist/types/client/mermaid-entry.d.ts +1 -2
  129. package/dist/types/client/mermaid-render.d.ts +1 -0
  130. package/dist/types/client/nodes/BoardNode.d.ts +1 -1
  131. package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
  132. package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
  133. package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
  134. package/dist/types/client/nodes/PromptNode.d.ts +1 -0
  135. package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
  136. package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
  137. package/dist/types/client/nodes/WorkCard.d.ts +7 -0
  138. package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
  139. package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
  140. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +1 -0
  141. package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
  142. package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
  143. package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
  144. package/dist/types/client/state/canvas-store.d.ts +18 -1
  145. package/dist/types/client/state/human-store.d.ts +2 -0
  146. package/dist/types/client/state/intent-bridge.d.ts +5 -2
  147. package/dist/types/client/state/presentation.d.ts +17 -6
  148. package/dist/types/client/state/relation-store.d.ts +11 -0
  149. package/dist/types/client/types.d.ts +1 -1
  150. package/dist/types/json-render/catalog.d.ts +2 -2
  151. package/dist/types/json-render/server.d.ts +2 -1
  152. package/dist/types/server/agent-presence.d.ts +1 -0
  153. package/dist/types/server/ax-interaction.d.ts +1 -0
  154. package/dist/types/server/ax-state-manager.d.ts +10 -2
  155. package/dist/types/server/board-local.d.ts +4 -0
  156. package/dist/types/server/board-move.d.ts +38 -0
  157. package/dist/types/server/canvas-db.d.ts +12 -2
  158. package/dist/types/server/canvas-operations.d.ts +5 -6
  159. package/dist/types/server/canvas-provenance.d.ts +1 -1
  160. package/dist/types/server/canvas-serialization.d.ts +1 -0
  161. package/dist/types/server/canvas-state.d.ts +86 -20
  162. package/dist/types/server/card-relocation.d.ts +6 -0
  163. package/dist/types/server/context-reads.d.ts +3 -1
  164. package/dist/types/server/human-presence.d.ts +1 -1
  165. package/dist/types/server/index.d.ts +26 -10
  166. package/dist/types/server/intent-registry.d.ts +8 -4
  167. package/dist/types/server/knowledge-lifecycle.d.ts +4 -2
  168. package/dist/types/server/mutation-history.d.ts +7 -5
  169. package/dist/types/server/operations/ops/boards.d.ts +0 -1
  170. package/dist/types/server/operations/registry.d.ts +2 -0
  171. package/dist/types/server/tour-control.d.ts +13 -3
  172. package/dist/types/server/trace-manager.d.ts +1 -0
  173. package/dist/types/shared/ax-kinds.d.ts +6 -0
  174. package/dist/types/shared/ax-surface-protocol.d.ts +8 -9
  175. package/dist/types/shared/human-presence.d.ts +1 -0
  176. package/dist/types/shared/lifecycle-status.d.ts +2 -0
  177. package/dist/types/shared/tour.d.ts +8 -0
  178. package/docs/RELEASE.md +21 -14
  179. package/docs/agent-plugin.md +16 -2
  180. package/docs/amp-plugin.md +271 -0
  181. package/docs/cli.md +42 -3
  182. package/docs/design/Chrome.dc.html +32 -6
  183. package/docs/design/GuestName.dc.html +50 -0
  184. package/docs/design/PluginLogo.svg +7 -0
  185. package/docs/design/Types1.dc.html +3 -2
  186. package/docs/design/Types2.dc.html +1 -1
  187. package/docs/design/Types3.dc.html +1 -1
  188. package/docs/design/WorkCards.dc.html +39 -0
  189. package/docs/design/Zoom.dc.html +37 -60
  190. package/docs/design/canvas.json +17 -1
  191. package/docs/design/plugin-logo.png +0 -0
  192. package/docs/design.md +280 -12
  193. package/docs/evals/agents-md-sections.md +38 -0
  194. package/docs/http-api.md +179 -11
  195. package/docs/mcp.md +69 -9
  196. package/docs/node-types.md +109 -4
  197. package/docs/plans/plan-009-tech-debt-backlog.md +3 -3
  198. package/docs/plans/plan-019-post-0.9-roadmap.md +327 -31
  199. package/docs/plans/plan-020-wave-5-work-planning.md +553 -0
  200. package/docs/product-vision-2026-09.md +42 -17
  201. package/docs/sdk.md +18 -0
  202. package/package.json +7 -4
  203. package/plugins/pmx-canvas/assets/preview.png +0 -0
  204. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +3 -1
  205. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  206. package/plugins/pmx-canvas/plugin.json +2 -2
  207. package/plugins/pmx-canvas/runtime.json +1 -1
  208. package/plugins/pmx-canvas/tools.json +688 -128
  209. package/skills/data-analysis/SKILL.md +13 -26
  210. package/skills/frontend-design/LICENSE.txt +177 -0
  211. package/skills/frontend-design/SKILL.md +3 -0
  212. package/skills/pmx-canvas/SKILL.md +232 -849
  213. package/skills/pmx-canvas/evals/evals.json +26 -3
  214. package/skills/pmx-canvas/references/full-reference.md +89 -24
  215. package/skills/pmx-canvas/references/html-primitives.md +7 -0
  216. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  217. package/skills/pmx-canvas/references/tours-and-recording.md +29 -2
  218. package/skills/pmx-canvas/references/workbench-workflows.md +900 -0
  219. package/skills/pmx-canvas-testing/SKILL.md +7 -5
  220. package/skills/published-consumer-e2e/SKILL.md +25 -8
  221. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +51 -77
  222. package/skills/web-artifacts-builder/LICENSE.txt +202 -0
  223. package/skills/web-artifacts-builder/SKILL.md +6 -0
  224. package/skills/web-artifacts-builder/scripts/init-artifact.sh +2 -2
  225. package/src/cli/commands/apps.ts +1 -0
  226. package/src/cli/commands/boards.ts +30 -0
  227. package/src/client/App.tsx +20 -4
  228. package/src/client/canvas/AttentionHistory.tsx +10 -2
  229. package/src/client/canvas/BarHint.tsx +6 -3
  230. package/src/client/canvas/BoardMoveDialog.tsx +225 -0
  231. package/src/client/canvas/CanvasNode.tsx +79 -49
  232. package/src/client/canvas/CanvasViewport.tsx +42 -14
  233. package/src/client/canvas/ContextMenu.tsx +97 -15
  234. package/src/client/canvas/EdgeEditor.tsx +149 -0
  235. package/src/client/canvas/EdgeLayer.tsx +441 -174
  236. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -2
  237. package/src/client/canvas/GroupNavigation.tsx +85 -0
  238. package/src/client/canvas/Presentation.tsx +53 -3
  239. package/src/client/canvas/RelationsPanel.tsx +9 -0
  240. package/src/client/canvas/SelectionBar.tsx +21 -0
  241. package/src/client/canvas/SessionPanel.tsx +35 -1
  242. package/src/client/canvas/TextPrompt.tsx +18 -1
  243. package/src/client/canvas/TopBar.tsx +54 -9
  244. package/src/client/canvas/auto-fit.ts +9 -11
  245. package/src/client/canvas/canvas-area.ts +2 -2
  246. package/src/client/canvas/use-focus-trap.ts +6 -1
  247. package/src/client/mermaid-entry.ts +6 -20
  248. package/src/client/mermaid-render.ts +39 -0
  249. package/src/client/nodes/AxStepControls.tsx +45 -69
  250. package/src/client/nodes/BoardNode.tsx +65 -9
  251. package/src/client/nodes/ContextNode.tsx +88 -396
  252. package/src/client/nodes/ExtAppFrame.tsx +302 -1465
  253. package/src/client/nodes/FileNode.tsx +92 -81
  254. package/src/client/nodes/HtmlNode.tsx +8 -36
  255. package/src/client/nodes/LedgerNode.tsx +31 -17
  256. package/src/client/nodes/MarkdownNode.tsx +14 -24
  257. package/src/client/nodes/McpAppNode.tsx +48 -154
  258. package/src/client/nodes/MermaidNode.tsx +80 -69
  259. package/src/client/nodes/PromptNode.tsx +44 -17
  260. package/src/client/nodes/ResponseNode.tsx +8 -6
  261. package/src/client/nodes/StatusNode.tsx +34 -7
  262. package/src/client/nodes/StatusSummary.tsx +16 -3
  263. package/src/client/nodes/TraceNode.tsx +8 -8
  264. package/src/client/nodes/ViewerFrame.tsx +273 -0
  265. package/src/client/nodes/ViewerHost.tsx +41 -0
  266. package/src/client/nodes/WebpageNode.tsx +22 -24
  267. package/src/client/nodes/WorkCard.tsx +175 -0
  268. package/src/client/nodes/iframe-document-url.ts +20 -27
  269. package/src/client/nodes/primitive-copy.ts +97 -0
  270. package/src/client/nodes/use-ax-surface-bridge.ts +18 -14
  271. package/src/client/nodes/use-iframe-content-height.ts +34 -10
  272. package/src/client/nodes/use-surface-frame.ts +22 -11
  273. package/src/client/nodes/viewer-runtime-state.ts +41 -35
  274. package/src/client/state/attention-bridge.ts +5 -0
  275. package/src/client/state/attention-store.ts +1 -0
  276. package/src/client/state/canvas-store.ts +202 -26
  277. package/src/client/state/human-store.ts +22 -2
  278. package/src/client/state/intent-bridge.ts +20 -5
  279. package/src/client/state/presentation.ts +135 -15
  280. package/src/client/state/relation-store.ts +127 -1
  281. package/src/client/state/sse-bridge.ts +18 -19
  282. package/src/client/theme/global.css +483 -230
  283. package/src/client/theme/surface-theme.css +6 -6
  284. package/src/client/types.ts +1 -0
  285. package/src/json-render/charts/tufte-components.tsx +44 -2
  286. package/src/json-render/renderer/index.css +62 -2
  287. package/src/json-render/renderer/index.tsx +31 -7
  288. package/src/json-render/server.ts +3 -2
  289. package/src/mcp/server.ts +13 -3
  290. package/src/server/agent-presence.ts +43 -23
  291. package/src/server/ax-flow-loop.ts +6 -0
  292. package/src/server/ax-gate-ttl.ts +8 -1
  293. package/src/server/ax-interaction.ts +16 -10
  294. package/src/server/ax-state-manager.ts +17 -4
  295. package/src/server/ax-wait.ts +20 -6
  296. package/src/server/board-export.ts +54 -30
  297. package/src/server/board-local.ts +20 -0
  298. package/src/server/board-move.ts +297 -0
  299. package/src/server/canvas-db.ts +249 -158
  300. package/src/server/canvas-geometry.ts +1 -0
  301. package/src/server/canvas-operations.ts +139 -109
  302. package/src/server/canvas-schema.ts +21 -0
  303. package/src/server/canvas-serialization.ts +2 -0
  304. package/src/server/canvas-state.ts +797 -159
  305. package/src/server/card-relocation.ts +53 -0
  306. package/src/server/code-graph.ts +4 -3
  307. package/src/server/context-reads.ts +83 -55
  308. package/src/server/demo-state.json +140 -114
  309. package/src/server/file-watcher.ts +30 -10
  310. package/src/server/html-primitives.ts +3 -9
  311. package/src/server/html-surface.ts +8 -0
  312. package/src/server/human-presence.ts +18 -5
  313. package/src/server/index.ts +54 -24
  314. package/src/server/intent-registry.ts +43 -5
  315. package/src/server/knowledge-lifecycle.ts +32 -9
  316. package/src/server/lifecycle-status.ts +29 -1
  317. package/src/server/mcp-app-runtime.ts +26 -7
  318. package/src/server/mutation-history.ts +37 -8
  319. package/src/server/operations/composites.ts +6 -3
  320. package/src/server/operations/mcp-contracts.ts +32 -2
  321. package/src/server/operations/mcp.ts +36 -9
  322. package/src/server/operations/ops/annotation.ts +22 -1
  323. package/src/server/operations/ops/ax-await.ts +1 -0
  324. package/src/server/operations/ops/ax-timeline.ts +28 -2
  325. package/src/server/operations/ops/batch.ts +25 -11
  326. package/src/server/operations/ops/boards.ts +45 -6
  327. package/src/server/operations/ops/nodes.ts +70 -0
  328. package/src/server/operations/ops/query.ts +32 -3
  329. package/src/server/operations/ops/snapshots.ts +1 -0
  330. package/src/server/operations/ops/viewport.ts +11 -9
  331. package/src/server/operations/registry.ts +49 -4
  332. package/src/server/server.ts +69 -75
  333. package/src/server/tour-control.ts +97 -12
  334. package/src/server/trace-manager.ts +4 -0
  335. package/src/server/web-artifacts/scripts/init-artifact.sh +2 -2
  336. package/src/shared/agent-context.ts +7 -1
  337. package/src/shared/auto-arrange.ts +5 -4
  338. package/src/shared/ax-kinds.ts +13 -0
  339. package/src/shared/ax-surface-protocol.ts +20 -9
  340. package/src/shared/content-height-reporter.ts +4 -3
  341. package/src/shared/human-presence.ts +1 -0
  342. package/src/shared/lifecycle-status.ts +2 -0
  343. package/src/shared/tour.ts +8 -0
@@ -12,7 +12,17 @@
12
12
  * the one-shot boot migration into SQLite was retired.
13
13
  */
14
14
 
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
15
16
  import { createHash, randomUUID } from 'node:crypto';
17
+ import {
18
+ prepareBoardMove,
19
+ type BoardMoveInput,
20
+ type BoardMovePreview,
21
+ type BoardMoveResult,
22
+ type MoveBoard,
23
+ } from './board-move.js';
24
+ import { boardMoveBoundary, recordCardRelocation } from './card-relocation.js';
25
+ import { mutationHistory } from './mutation-history.js';
16
26
  import {
17
27
  DEFAULT_WORKSPACE_SETTINGS,
18
28
  workspaceSettingsPatchSchema,
@@ -43,6 +53,8 @@ import {
43
53
  openCanvasDb,
44
54
  saveStateToDB,
45
55
  loadStateFromDB,
56
+ boardStorageVersion,
57
+ assertBoardStorageVersion,
46
58
  saveSnapshotToDB,
47
59
  loadSnapshotFromDB,
48
60
  listSnapshotsFromDB,
@@ -364,6 +376,7 @@ export interface CanvasNodeUpdate {
364
376
  position?: { x: number; y: number };
365
377
  size?: { width: number; height: number };
366
378
  collapsed?: boolean;
379
+ zIndex?: number;
367
380
  }
368
381
 
369
382
  export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
@@ -454,33 +467,232 @@ function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
454
467
  );
455
468
  }
456
469
 
470
+ /** The mutable, board-owned partition. Workspace services remain on the manager. */
471
+ interface LiveBoardState {
472
+ id: string | null;
473
+ disposed: boolean;
474
+ cleanup: Array<() => void>;
475
+ nodes: Map<string, CanvasNodeState>;
476
+ edges: Map<string, CanvasEdge>;
477
+ annotations: Map<string, CanvasAnnotation>;
478
+ viewport: ViewportState;
479
+ tour: Tour | undefined;
480
+ contextPinnedNodeIds: Set<string>;
481
+ contextPinMeta: Map<string, ContextPinMeta>;
482
+ storageVersion: number;
483
+ contentRevision: number;
484
+ revisionFloor: number;
485
+ deletionTombstones: NodeDeletionTombstone[];
486
+ generation: number;
487
+ saveTimer: ReturnType<typeof setTimeout> | null;
488
+ persistenceError: { message: string; at: string } | null;
489
+ ax: AxStateManager;
490
+ }
491
+
457
492
  class CanvasStateManager {
458
- private nodes = new Map<string, CanvasNodeState>();
459
- private edges = new Map<string, CanvasEdge>();
460
- private annotations = new Map<string, CanvasAnnotation>();
461
- private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
462
- private _tour: Tour | undefined;
493
+ private readonly boardExecution = new AsyncLocalStorage<LiveBoardState>();
494
+ private readonly liveBoards = new Map<string, LiveBoardState>();
495
+ private nextGeneration = 0;
496
+ private navigationGeneration = 0;
497
+ private readonly hostState: { capability: PmxAxHostCapability | null } = { capability: null };
498
+ private visibleBoard = this.createBoardState();
499
+
500
+ private get boardState(): LiveBoardState {
501
+ const board = this.boardExecution.getStore() ?? this.visibleBoard;
502
+ if (board.disposed) throw new OperationError('The target board was deleted or the workspace was replaced.', 409);
503
+ return board;
504
+ }
505
+
506
+ /** Identity for board-owned runtime caches. Never shared by a replacement board. */
507
+ get boardIdentity(): object {
508
+ return this.boardState;
509
+ }
510
+
511
+ onBoardDispose(cleanup: () => void): () => void {
512
+ const board = this.boardState;
513
+ board.cleanup.push(cleanup);
514
+ return () => {
515
+ const index = board.cleanup.indexOf(cleanup);
516
+ if (index >= 0) board.cleanup.splice(index, 1);
517
+ };
518
+ }
519
+
520
+ private disposeBoardRuntime(board: LiveBoardState): void {
521
+ for (const cleanup of board.cleanup.splice(0)) cleanup();
522
+ }
523
+
524
+ /** The human's navigation target, independent of the calling operation's board. */
525
+ get visibleBoardId(): string | null {
526
+ return this.visibleBoard.id;
527
+ }
528
+
529
+ get visibleBoardGeneration(): number {
530
+ return this.navigationGeneration;
531
+ }
532
+
533
+ get isBackgroundBoard(): boolean {
534
+ return this.boardState !== this.visibleBoard;
535
+ }
536
+
537
+ /** Execute on a live board without activating it, retaining ownership across awaits. */
538
+ withBoard<T>(id: string, fn: () => T): T {
539
+ const board = this.loadBoardState(id);
540
+ return this.boardExecution.run(board, fn);
541
+ }
542
+
543
+ /** Bind callbacks to their board, rather than the context of whoever invokes them. */
544
+ bindBoard<T extends unknown[], R>(fn: (...args: T) => R): (...args: T) => R {
545
+ const board = this.boardState;
546
+ return (...args) => this.boardExecution.run(board, () => fn(...args));
547
+ }
548
+
549
+ /** Viewer/global projections must not inherit the producer's board scope. */
550
+ withVisibleBoard<T>(fn: () => T): T {
551
+ return this.boardExecution.exit(fn);
552
+ }
553
+
554
+ /** Sweep resident runtime owners without loading the entire stored library. */
555
+ forEachLiveBoard(fn: () => void): void {
556
+ for (const board of new Set([this.visibleBoard, ...this.liveBoards.values()])) {
557
+ this.boardExecution.run(board, fn);
558
+ }
559
+ }
560
+
561
+ private loadBoardState(id: string): LiveBoardState {
562
+ const cached = this.liveBoards.get(id);
563
+ if (cached && this._db && cached.storageVersion === boardStorageVersion(this._db, id)) return cached;
564
+ if (cached?.saveTimer || cached?.persistenceError)
565
+ throw new OperationError(
566
+ `Board "${id}" changed in another writer. Reconcile unsaved edits before continuing.`,
567
+ 409,
568
+ );
569
+ const state = this._db ? this.readProjectedBoard(id) : null;
570
+ if (!state) throw new OperationError(`Board "${id}" not found.`, 404);
571
+ const board = cached ?? this.createBoardState();
572
+ board.id = id;
573
+ this.boardExecution.run(board, () => {
574
+ this.initializePersistedWikiLinks(state, id);
575
+ this.applyPersistedState({ ...state, theme: this._theme });
576
+ });
577
+ this.liveBoards.set(id, board);
578
+ return board;
579
+ }
580
+
581
+ private createBoardState(): LiveBoardState {
582
+ return {
583
+ id: null,
584
+ disposed: false,
585
+ cleanup: [],
586
+ nodes: new Map(),
587
+ edges: new Map(),
588
+ annotations: new Map(),
589
+ viewport: { x: 0, y: 0, scale: 1 },
590
+ tour: undefined,
591
+ contextPinnedNodeIds: new Set(),
592
+ contextPinMeta: new Map(),
593
+ storageVersion: 0,
594
+ contentRevision: 0,
595
+ revisionFloor: 0,
596
+ deletionTombstones: [],
597
+ generation: ++this.nextGeneration,
598
+ saveTimer: null,
599
+ persistenceError: null,
600
+ ax: new AxStateManager(
601
+ {
602
+ getNodeIds: () => this.currentNodeIdSet(),
603
+ getDb: () => this._db,
604
+ scheduleSave: () => this.scheduleSave(),
605
+ notifyChange: (type) => this.notifyChange(type),
606
+ recordMutation: (info) => this.recordMutation(info),
607
+ suppressed: (fn) => this.suppressed(fn),
608
+ },
609
+ this.hostState,
610
+ ),
611
+ };
612
+ }
613
+
614
+ private get nodes() {
615
+ return this.boardState.nodes;
616
+ }
617
+ private get edges() {
618
+ return this.boardState.edges;
619
+ }
620
+ private get annotations() {
621
+ return this.boardState.annotations;
622
+ }
623
+ private get _viewport() {
624
+ return this.boardState.viewport;
625
+ }
626
+ private set _viewport(value: ViewportState) {
627
+ this.boardState.viewport = value;
628
+ }
629
+ private get _tour() {
630
+ return this.boardState.tour;
631
+ }
632
+ private set _tour(value: Tour | undefined) {
633
+ this.boardState.tour = value;
634
+ }
635
+ private get _contextPinnedNodeIds() {
636
+ return this.boardState.contextPinnedNodeIds;
637
+ }
638
+ private set _contextPinnedNodeIds(value: Set<string>) {
639
+ this.boardState.contextPinnedNodeIds = value;
640
+ }
641
+ private get _contextPinMeta() {
642
+ return this.boardState.contextPinMeta;
643
+ }
644
+ private set _contextPinMeta(value: Map<string, ContextPinMeta>) {
645
+ this.boardState.contextPinMeta = value;
646
+ }
647
+ private get _storageVersion() {
648
+ return this.boardState.storageVersion;
649
+ }
650
+ private set _storageVersion(value: number) {
651
+ this.boardState.storageVersion = value;
652
+ }
653
+ private get _contentRevision() {
654
+ return this.boardState.contentRevision;
655
+ }
656
+ private set _contentRevision(value: number) {
657
+ this.boardState.contentRevision = value;
658
+ }
659
+ private get _revisionFloor() {
660
+ return this.boardState.revisionFloor;
661
+ }
662
+ private set _revisionFloor(value: number) {
663
+ this.boardState.revisionFloor = value;
664
+ }
665
+ private get _deletionTombstones() {
666
+ return this.boardState.deletionTombstones;
667
+ }
668
+ private set _deletionTombstones(value: NodeDeletionTombstone[]) {
669
+ this.boardState.deletionTombstones = value;
670
+ }
671
+ private get _boardGeneration() {
672
+ return this.boardState.generation;
673
+ }
674
+ private set _boardGeneration(value: number) {
675
+ this.boardState.generation = value;
676
+ }
677
+ private get _saveTimer() {
678
+ return this.boardState.saveTimer;
679
+ }
680
+ private set _saveTimer(value: ReturnType<typeof setTimeout> | null) {
681
+ this.boardState.saveTimer = value;
682
+ }
683
+ private get _lastPersistenceError() {
684
+ return this.boardState.persistenceError;
685
+ }
686
+ private set _lastPersistenceError(value: LiveBoardState['persistenceError']) {
687
+ this.boardState.persistenceError = value;
688
+ }
689
+ private get ax() {
690
+ return this.boardState.ax;
691
+ }
692
+
463
693
  private _theme: CanvasTheme = 'harbor';
464
- private _contextPinnedNodeIds = new Set<string>();
465
- /** Who pinned each pin, when and why; kept only for ids in `_contextPinnedNodeIds`. */
466
- private _contextPinMeta = new Map<string, ContextPinMeta>();
467
694
  private _workspaceRoot = process.cwd();
468
- private _contentRevision = 0;
469
- private _revisionFloor = 0;
470
- private _deletionTombstones: NodeDeletionTombstone[] = [];
471
-
472
- // ── AX state (canvas-bound + timeline + host partitions) ──────────
473
- // Extracted into a dedicated manager (plan-007 Slice A). CanvasStateManager
474
- // holds it and delegates its public AX methods so the SDK/HTTP/MCP surface is
475
- // byte-stable; the manager receives the host hooks it needs as injected deps.
476
- private readonly ax = new AxStateManager({
477
- getNodeIds: () => this.currentNodeIdSet(),
478
- getDb: () => this._db,
479
- scheduleSave: () => this.scheduleSave(),
480
- notifyChange: (type) => this.notifyChange(type),
481
- recordMutation: (info) => this.recordMutation(info),
482
- suppressed: (fn) => this.suppressed(fn),
483
- });
695
+ private moveRuntimeBlockers: () => ReadonlyMap<string, string> = () => new Map();
484
696
 
485
697
  // ── Change listeners (for MCP resource notifications) ──────
486
698
  private _changeListeners: ((type: CanvasChangeType) => void)[] = [];
@@ -492,9 +704,15 @@ class CanvasStateManager {
492
704
  * — e.g. the long-lived MCP subscription — may ignore the return value).
493
705
  */
494
706
  onChange(cb: (type: CanvasChangeType) => void): () => void {
495
- this._changeListeners.push(cb);
707
+ const owner = this.boardExecution.getStore();
708
+ const listener = owner
709
+ ? (type: CanvasChangeType) => {
710
+ if (owner === this.boardState) this.boardExecution.run(owner, () => cb(type));
711
+ }
712
+ : cb;
713
+ this._changeListeners.push(listener);
496
714
  return () => {
497
- const i = this._changeListeners.indexOf(cb);
715
+ const i = this._changeListeners.indexOf(listener);
498
716
  if (i >= 0) this._changeListeners.splice(i, 1);
499
717
  };
500
718
  }
@@ -528,6 +746,7 @@ class CanvasStateManager {
528
746
  }
529
747
 
530
748
  private notifyChange(type: CanvasChangeType): void {
749
+ if (type === 'ax') this.refreshLiveWorkCards();
531
750
  // Iterate a snapshot: a listener (e.g. a blocking-wait via onChange) may dispose
532
751
  // itself synchronously here, and splicing the live array mid-iteration would skip
533
752
  // the next listener for this notification.
@@ -640,8 +859,29 @@ class CanvasStateManager {
640
859
  return { group, childIds, children };
641
860
  }
642
861
 
643
- private normalizeNode(node: CanvasNodeState): CanvasNodeState {
862
+ private normalizeNode(
863
+ node: CanvasNodeState,
864
+ workItems?: PmxAxWorkItem[],
865
+ options: { boardId?: string; projectWork?: boolean } = {},
866
+ ): CanvasNodeState {
644
867
  const data = { ...node.data };
868
+ // These persisted fields are a derived display cache, never a second task record.
869
+ if (options.projectWork !== false && node.type === 'status' && typeof data.workItemId === 'string') {
870
+ const tasks =
871
+ typeof data.workItemBoardId === 'string' && data.workItemBoardId !== options.boardId
872
+ ? this.getWorkItemsOnBoard(data.workItemBoardId)
873
+ : (workItems ?? this.getWorkItems());
874
+ const task = tasks.find((item) => item.id === data.workItemId);
875
+ data.title = task?.title ?? 'Task unavailable';
876
+ data.detail = task?.detail ?? '';
877
+ data.liveWorkAvailable = !!task;
878
+ data.liveWorkSource = task?.agentId ?? task?.source ?? '';
879
+ delete data.progress;
880
+ delete data.content;
881
+ delete data.axWorkStatus;
882
+ if (task) data.workState = task.status;
883
+ else delete data.workState;
884
+ }
645
885
  // Lifecycle is optional metadata, never a card taxonomy or validation clock.
646
886
  delete data.categoryId;
647
887
  if (data.contentOrigin !== undefined && data.contentOrigin !== 'authored' && data.contentOrigin !== 'imported') {
@@ -665,6 +905,104 @@ class CanvasStateManager {
665
905
  };
666
906
  }
667
907
 
908
+ private refreshLiveWorkCards(): void {
909
+ const owner = this.boardState;
910
+ this.forEachLiveBoard(() => {
911
+ // Derived cache refreshes must not turn a clean stale board into an unsaved conflict.
912
+ if (
913
+ this.boardState !== owner &&
914
+ this._db &&
915
+ this._activeBoardId &&
916
+ !this._saveTimer &&
917
+ !this._lastPersistenceError &&
918
+ this._storageVersion !== boardStorageVersion(this._db, this._activeBoardId)
919
+ )
920
+ return;
921
+ this.refreshCurrentLiveWorkCards();
922
+ });
923
+ }
924
+
925
+ private refreshCurrentLiveWorkCards(preserveAttribution = false): void {
926
+ this.withSuppressedRecording(() => {
927
+ const tasks = this.getWorkItems();
928
+ for (const node of this.nodes.values()) {
929
+ if (node.type !== 'status' || typeof node.data.workItemId !== 'string') continue;
930
+ const next = this.normalizeNode(node, tasks);
931
+ if (!this.nodeContentChanged(node, next)) continue;
932
+ if (preserveAttribution) {
933
+ delete next.data.agentSummary;
934
+ delete next.data.summary;
935
+ this.nodes.set(node.id, {
936
+ ...next,
937
+ contentRevision: this.nextContentRevision(),
938
+ contentChangedAt: new Date().toISOString(),
939
+ });
940
+ this.scheduleSave();
941
+ } else this.updateNode(node.id, { data: next.data });
942
+ }
943
+ });
944
+ }
945
+
946
+ /** Reconcile a stored display cache and its cursor clock in the same SQLite transaction. */
947
+ private readProjectedBoard(boardId: string): PersistedCanvasState | null {
948
+ const db = this._db!;
949
+ // Validation inside another write must not change its captured versions or
950
+ // publish resident clocks before that outer transaction commits.
951
+ if (db.inTransaction) return loadStateFromDB(db, boardId);
952
+ const projected: CanvasNodeState[] = [];
953
+ let previousVersion = 0;
954
+ const state = db
955
+ .transaction(() => {
956
+ const state = loadStateFromDB(db, boardId);
957
+ if (!state) return null;
958
+ previousVersion = state.storageVersion ?? 0;
959
+ state.revisionState ??= { revision: 0, floor: 0, tombstones: [] };
960
+ const revision = state.revisionState;
961
+ state.nodes = state.nodes.map((node) => {
962
+ if (node.type !== 'status' || typeof node.data.workItemId !== 'string') return node;
963
+ const next = this.normalizeNode(node, state.ax?.workItems ?? [], { boardId });
964
+ if (!this.nodeContentChanged(node, next)) return node;
965
+ delete next.data.agentSummary;
966
+ delete next.data.summary;
967
+ next.contentRevision = ++revision.revision;
968
+ next.contentChangedAt = new Date().toISOString();
969
+ projected.push(next);
970
+ return next;
971
+ });
972
+ if (projected.length) state.storageVersion = saveStateToDB(db, boardId, state);
973
+ return state;
974
+ })
975
+ .immediate();
976
+ const live = this.liveBoards.get(boardId);
977
+ if (
978
+ state &&
979
+ projected.length &&
980
+ live &&
981
+ !live.saveTimer &&
982
+ !live.persistenceError &&
983
+ live.storageVersion === previousVersion
984
+ ) {
985
+ // Only the derived cache changed. Do not reinstall AX or renew approval TTLs.
986
+ this.boardExecution.run(live, () => {
987
+ for (const node of projected) this.nodes.set(node.id, structuredClone(node));
988
+ this._contentRevision = state.revisionState!.revision;
989
+ this._storageVersion = state.storageVersion!;
990
+ });
991
+ }
992
+ return state;
993
+ }
994
+
995
+ /** Call before capturing a context snapshot, never from per-node getters used during writes. */
996
+ refreshContextWorkCards(): void {
997
+ if (!this._db || !this._activeBoardId) return;
998
+ if (this._saveTimer || this._lastPersistenceError) {
999
+ this.refreshCurrentLiveWorkCards(true);
1000
+ return;
1001
+ }
1002
+ const state = this.readProjectedBoard(this._activeBoardId);
1003
+ if (state && state.storageVersion !== this._storageVersion) this.applyPersistedState(state, false);
1004
+ }
1005
+
668
1006
  private nextContentRevision(): number {
669
1007
  this._contentRevision += 1;
670
1008
  return this._contentRevision;
@@ -705,7 +1043,9 @@ class CanvasStateManager {
705
1043
  }
706
1044
 
707
1045
  private nodeForRead(node: CanvasNodeState): CanvasNodeState {
708
- const resolved = this.resolveNodeDataBlobs(node);
1046
+ const resolved = this.resolveNodeDataBlobs(
1047
+ node.type === 'status' && typeof node.data.workItemId === 'string' ? this.normalizeNode(node) : node,
1048
+ );
709
1049
  return {
710
1050
  ...resolved,
711
1051
  pinned: resolved.pinned || this._contextPinnedNodeIds.has(resolved.id),
@@ -808,7 +1148,6 @@ class CanvasStateManager {
808
1148
  // ── Persistence ────────────────────────────────────────────
809
1149
  private _db: import('bun:sqlite').Database | null = null;
810
1150
  private _dbPath: string | null = null;
811
- private _saveTimer: ReturnType<typeof setTimeout> | null = null;
812
1151
  readonly knowledge = new KnowledgeLifecycle(
813
1152
  () => this._db,
814
1153
  () => this.notifyChange('nodes'),
@@ -1038,7 +1377,7 @@ class CanvasStateManager {
1038
1377
  if (this._db) {
1039
1378
  try {
1040
1379
  const boardId = getActiveBoardIdFromDB(this._db);
1041
- const state = boardId ? loadStateFromDB(this._db, boardId) : null;
1380
+ const state = boardId ? this.readProjectedBoard(boardId) : null;
1042
1381
  if (boardId && state) {
1043
1382
  this.initializePersistedWikiLinks(state, boardId);
1044
1383
  this.applyPersistedState(state);
@@ -1129,15 +1468,24 @@ class CanvasStateManager {
1129
1468
 
1130
1469
  // ── Boards ──────────────────────────────────────────────────
1131
1470
 
1132
- private _activeBoardId: string | null = null;
1133
- private _boardGeneration = 0;
1471
+ private get _activeBoardId(): string | null {
1472
+ return this.boardState.id;
1473
+ }
1474
+ private set _activeBoardId(id: string | null) {
1475
+ this.boardState.id = id;
1476
+ if (id) this.liveBoards.set(id, this.boardState);
1477
+ }
1134
1478
 
1135
1479
  /** Invalidates asynchronous work whenever the loaded board state is replaced. */
1136
1480
  get boardGeneration(): number {
1481
+ return this.boardExecution.getStore() ? this._boardGeneration : this.navigationGeneration;
1482
+ }
1483
+
1484
+ get boardLifetimeGeneration(): number {
1137
1485
  return this._boardGeneration;
1138
1486
  }
1139
1487
 
1140
- /** The open board, or null on Home. */
1488
+ /** The execution board, or the open board outside a scoped operation. */
1141
1489
  get activeBoardId(): string | null {
1142
1490
  return this._activeBoardId;
1143
1491
  }
@@ -1343,62 +1691,67 @@ class CanvasStateManager {
1343
1691
  let before: PersistedCanvasState | undefined;
1344
1692
  const activeLinkRevisions = new Map<string, number>();
1345
1693
  let activeRevision = this._contentRevision;
1346
- const result = db.transaction(() => {
1347
- const previous = requestId
1348
- ? db
1349
- .query<{ board_id: string; fingerprint: string; node: string }, [string]>(
1350
- 'SELECT board_id, fingerprint, node FROM node_creation_requests WHERE request_id = ?',
1351
- )
1352
- .get(requestId)
1353
- : null;
1354
- if (previous) {
1355
- if (previous.board_id !== boardId || previous.fingerprint !== fingerprint)
1356
- throw new OperationError('requestId was already used with different creation input or destination.', 409);
1357
- // Return the original receipt even if the node has since been edited or removed.
1358
- return JSON.parse(previous.node) as T;
1359
- }
1360
- // Include pending edits in catalog resolution and durable relation clocks,
1361
- // without publishing or changing the active board before commit.
1362
- if (this._activeBoardId) saveStateToDB(db, this._activeBoardId, this.persistedState());
1363
- const state = loadStateFromDB(db, boardId);
1364
- if (!state) throw new OperationError(`Board "${boardId}" not found.`, 404);
1365
- const policies = [this.getPolicy(), state.ax?.policy];
1366
- if (policies.some((policy) => policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length)))
1367
- throw new OperationError('Destination/retry creation is refused while either board has a scope fence.', 403);
1368
- state.revisionState = {
1369
- ...state.revisionState,
1370
- revision: state.revisionState?.revision ?? 0,
1371
- floor: state.revisionState?.floor ?? 0,
1372
- tombstones: state.revisionState?.tombstones ?? [],
1373
- };
1374
- before = structuredClone(state);
1375
- const invalidations: Array<[string, string]> = [];
1376
- let next = build(state, (targetBoardId, nodeId) => invalidations.push([targetBoardId, nodeId]));
1377
- // Theme is workspace-global; never let an inactive projection overwrite it.
1378
- state.theme = this._theme;
1379
- saveStateToDB(db, boardId, state);
1380
- const invalidate = (targetBoardId: string, targetNodeId: string) => {
1381
- const revision = invalidateLinksInDB(db, targetBoardId, targetNodeId);
1382
- if (targetBoardId === this._activeBoardId && revision !== undefined) {
1383
- activeLinkRevisions.set(targetNodeId, revision);
1384
- activeRevision = Math.max(activeRevision, revision);
1694
+ let activeStorageVersion = this._storageVersion;
1695
+ const result = db
1696
+ .transaction(() => {
1697
+ const previous = requestId
1698
+ ? db
1699
+ .query<{ board_id: string; fingerprint: string; node: string }, [string]>(
1700
+ 'SELECT board_id, fingerprint, node FROM node_creation_requests WHERE request_id = ?',
1701
+ )
1702
+ .get(requestId)
1703
+ : null;
1704
+ if (previous) {
1705
+ if (previous.board_id !== boardId || previous.fingerprint !== fingerprint)
1706
+ throw new OperationError('requestId was already used with different creation input or destination.', 409);
1707
+ // Return the original receipt even if the node has since been edited or removed.
1708
+ return JSON.parse(previous.node) as T;
1385
1709
  }
1386
- };
1387
- for (const [targetBoardId, nodeId] of invalidations) invalidate(targetBoardId, nodeId);
1388
- if ('data' in next) invalidateIncomingLinksInDB(db, boardId, next.id, invalidate);
1389
- committed = loadStateFromDB(db, boardId)!;
1390
- next = ([...committed.nodes, ...committed.edges].find((candidate) => candidate.id === next.id) ?? next) as T;
1391
- if (active) activeRevision = committed.revisionState!.revision;
1392
- if (requestId)
1393
- db.run('INSERT INTO node_creation_requests VALUES (?, ?, ?, ?)', [
1394
- requestId,
1395
- boardId,
1396
- fingerprint,
1397
- JSON.stringify(next),
1398
- ]);
1399
- return next;
1400
- })();
1710
+ // Include pending edits in catalog resolution and durable relation clocks,
1711
+ // without publishing or changing the active board before commit.
1712
+ this.stageLiveBoards(db);
1713
+ const state = loadStateFromDB(db, boardId);
1714
+ if (!state) throw new OperationError(`Board "${boardId}" not found.`, 404);
1715
+ const policies = [this.getPolicy(), state.ax?.policy];
1716
+ if (policies.some((policy) => policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length)))
1717
+ throw new OperationError('Destination/retry creation is refused while either board has a scope fence.', 403);
1718
+ state.revisionState = {
1719
+ ...state.revisionState,
1720
+ revision: state.revisionState?.revision ?? 0,
1721
+ floor: state.revisionState?.floor ?? 0,
1722
+ tombstones: state.revisionState?.tombstones ?? [],
1723
+ };
1724
+ before = structuredClone(state);
1725
+ const invalidations: Array<[string, string]> = [];
1726
+ let next = build(state, (targetBoardId, nodeId) => invalidations.push([targetBoardId, nodeId]));
1727
+ // Theme is workspace-global; never let an inactive projection overwrite it.
1728
+ state.theme = this._theme;
1729
+ saveStateToDB(db, boardId, state);
1730
+ const invalidate = (targetBoardId: string, targetNodeId: string) => {
1731
+ const revision = invalidateLinksInDB(db, targetBoardId, targetNodeId);
1732
+ if (targetBoardId === this._activeBoardId && revision !== undefined) {
1733
+ activeLinkRevisions.set(targetNodeId, revision);
1734
+ activeRevision = Math.max(activeRevision, revision);
1735
+ }
1736
+ };
1737
+ for (const [targetBoardId, nodeId] of invalidations) invalidate(targetBoardId, nodeId);
1738
+ if ('data' in next) invalidateIncomingLinksInDB(db, boardId, next.id, invalidate);
1739
+ committed = loadStateFromDB(db, boardId)!;
1740
+ next = ([...committed.nodes, ...committed.edges].find((candidate) => candidate.id === next.id) ?? next) as T;
1741
+ if (active) activeRevision = committed.revisionState!.revision;
1742
+ if (this._activeBoardId) activeStorageVersion = boardStorageVersion(db, this._activeBoardId);
1743
+ if (requestId)
1744
+ db.run('INSERT INTO node_creation_requests VALUES (?, ?, ?, ?)', [
1745
+ requestId,
1746
+ boardId,
1747
+ fingerprint,
1748
+ JSON.stringify(next),
1749
+ ]);
1750
+ return next;
1751
+ })
1752
+ .immediate();
1401
1753
  if (committed) {
1754
+ this._storageVersion = activeStorageVersion;
1402
1755
  if (active) {
1403
1756
  const changed = committed.nodes.filter(
1404
1757
  (node) => JSON.stringify(before!.nodes.find((old) => old.id === node.id)) !== JSON.stringify(node),
@@ -1442,16 +1795,48 @@ class CanvasStateManager {
1442
1795
  }
1443
1796
  this._contentRevision = activeRevision;
1444
1797
  if (active || activeLinkRevisions.size) this.notifyChange('nodes');
1798
+ this.reconcileStoredBoards();
1445
1799
  }
1446
1800
  return result;
1447
1801
  }
1448
1802
 
1803
+ /** Called inside a transaction: publish no storage clocks until it commits. */
1804
+ private stageLiveBoards(db: import('bun:sqlite').Database): void {
1805
+ for (const board of this.liveBoards.values()) {
1806
+ if (board !== this.boardState && !board.saveTimer && !board.persistenceError) continue;
1807
+ this.boardExecution.run(board, () => saveStateToDB(db, board.id!, this.persistedState()));
1808
+ }
1809
+ }
1810
+
1811
+ /** A stored multi-board write retains each live owner's runtime and camera. */
1812
+ private reconcileStoredBoards(ids: Iterable<string> = this.liveBoards.keys()): void {
1813
+ if (!this._db) return;
1814
+ for (const id of ids) {
1815
+ const board = this.liveBoards.get(id);
1816
+ if (!board) continue;
1817
+ if (board.storageVersion === boardStorageVersion(this._db, board.id!)) continue;
1818
+ const state = loadStateFromDB(this._db, board.id!)!;
1819
+ this.boardExecution.run(board, () => {
1820
+ if (board.saveTimer) clearTimeout(board.saveTimer);
1821
+ board.saveTimer = null;
1822
+ board.persistenceError = null;
1823
+ this.applyPersistedState(state, false);
1824
+ this.notifyChange('nodes');
1825
+ this.notifyChange('pins');
1826
+ this.notifyChange('ax');
1827
+ });
1828
+ }
1829
+ }
1830
+
1449
1831
  /** Read a board without activating it. This projection is not a write payload. */
1450
1832
  readBoard(
1451
1833
  id: string,
1452
1834
  includeBlobs = true,
1453
1835
  ): { board: CanvasBoard; state: PersistedCanvasState; layout: CanvasLayout } | null {
1454
1836
  if (!this._db) return null;
1837
+ const live = this.liveBoards.get(id);
1838
+ if (id !== this._activeBoardId && live && (live.saveTimer || live.persistenceError))
1839
+ return this.boardExecution.run(live, () => this.readBoard(id, includeBlobs));
1455
1840
  const board = this.listBoards().find((entry) => entry.id === id);
1456
1841
  if (!board) return null;
1457
1842
  if (id === this._activeBoardId) {
@@ -1465,6 +1850,7 @@ class CanvasStateManager {
1465
1850
  board,
1466
1851
  layout,
1467
1852
  state: {
1853
+ storageVersion: this._storageVersion,
1468
1854
  version: 1,
1469
1855
  theme: this._theme,
1470
1856
  tour: layout.tour,
@@ -1479,12 +1865,16 @@ class CanvasStateManager {
1479
1865
  },
1480
1866
  };
1481
1867
  }
1482
- const state = loadStateFromDB(this._db, id);
1868
+ const state = this.readProjectedBoard(id);
1483
1869
  if (!state) return null;
1484
1870
  this.initializePersistedWikiLinks(state, id);
1485
1871
  const pins = new Set(state.contextPins);
1486
1872
  state.nodes = state.nodes.map((node) => ({
1487
- ...(includeBlobs ? this.resolveNodeDataBlobs(node) : node),
1873
+ ...(node.type === 'status' && typeof node.data.workItemId === 'string'
1874
+ ? this.normalizeNode(node, state.ax?.workItems ?? [], { boardId: id })
1875
+ : includeBlobs
1876
+ ? this.resolveNodeDataBlobs(node)
1877
+ : node),
1488
1878
  pinned: node.pinned || pins.has(node.id),
1489
1879
  }));
1490
1880
  return {
@@ -1504,9 +1894,10 @@ class CanvasStateManager {
1504
1894
  /** Every board in the workspace, most recently opened first. */
1505
1895
  listBoards(): CanvasBoard[] {
1506
1896
  if (!this._db) return [];
1507
- return listBoardsFromDB(this._db).map((board) =>
1508
- board.id === this._activeBoardId ? { ...board, nodeCount: this.nodes.size } : board,
1509
- );
1897
+ return listBoardsFromDB(this._db).map((board) => {
1898
+ const live = this.liveBoards.get(board.id);
1899
+ return live ? { ...board, nodeCount: live.nodes.size } : board;
1900
+ });
1510
1901
  }
1511
1902
 
1512
1903
  storeAttachment(input: { boardId: string; name: string; mime: string; bytes: Uint8Array }): Attachment | null {
@@ -1549,10 +1940,15 @@ class CanvasStateManager {
1549
1940
  clearTimeout(this._saveTimer);
1550
1941
  this._saveTimer = null;
1551
1942
  }
1552
- return this._db.transaction(() => {
1553
- if (!this.saveToDisk()) throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
1554
- return updateImport(this._db!, id, 'committed', { committedNodeIds: nodeIds });
1555
- })();
1943
+ let storageVersion = this._storageVersion;
1944
+ const result = this._db
1945
+ .transaction(() => {
1946
+ if (this._activeBoardId) storageVersion = saveStateToDB(this._db!, this._activeBoardId, this.persistedState());
1947
+ return updateImport(this._db!, id, 'committed', { committedNodeIds: nodeIds });
1948
+ })
1949
+ .immediate();
1950
+ this._storageVersion = storageVersion;
1951
+ return result;
1556
1952
  }
1557
1953
 
1558
1954
  getContextBriefCursor(boardId: string, consumer: string): number | null {
@@ -1571,6 +1967,159 @@ class CanvasStateManager {
1571
1967
  return createBoardInDB(this._db, name, folder);
1572
1968
  }
1573
1969
 
1970
+ /** The host supplies runtime ownership, not another copy of board state. */
1971
+ setMoveRuntimeBlockers(read: () => ReadonlyMap<string, string>): void {
1972
+ this.moveRuntimeBlockers = read;
1973
+ }
1974
+
1975
+ moveSelectionToNewBoard(
1976
+ input: BoardMoveInput,
1977
+ ): { preview: BoardMovePreview } | { result: BoardMoveResult; replayed: boolean } {
1978
+ const db = this._db;
1979
+ if (!db) throw new OperationError('Moving cards requires persistent workspace storage.', 409);
1980
+ const name = input.name.trim();
1981
+ if (!name || name.length > 120) throw new OperationError('Board names must be 1–120 characters.');
1982
+ if (!input.requestId.trim() || input.requestId.length > 200)
1983
+ throw new OperationError('A move requires a requestId of 1–200 characters.');
1984
+ const normalized = { ...input, name, nodeIds: [...new Set(input.nodeIds)].sort() };
1985
+ const fingerprint = JSON.stringify({
1986
+ sourceBoardId: input.sourceBoardId,
1987
+ nodeIds: normalized.nodeIds,
1988
+ name,
1989
+ expectedRevision: input.expectedRevision,
1990
+ });
1991
+ let committed: PersistedCanvasState | undefined;
1992
+ const perform = () => {
1993
+ const previous = db
1994
+ .query<{ fingerprint: string; result: string }, [string]>(
1995
+ 'SELECT fingerprint, result FROM board_moves WHERE request_id = ?',
1996
+ )
1997
+ .get(input.requestId);
1998
+ if (previous && !input.preview) {
1999
+ if (previous.fingerprint !== fingerprint)
2000
+ throw new OperationError('requestId was already used for a different move.', 409);
2001
+ return { result: JSON.parse(previous.result) as BoardMoveResult, replayed: true };
2002
+ }
2003
+ if (input.sourceBoardId !== this._activeBoardId)
2004
+ throw new OperationError('The source board is no longer open. Preview the move again.', 409);
2005
+ assertBoardStorageVersion(db, input.sourceBoardId, this._storageVersion);
2006
+ const library = new Map<string, MoveBoard>();
2007
+ for (const board of listBoardsFromDB(db)) {
2008
+ const live = this.liveBoards.get(board.id);
2009
+ const state =
2010
+ live && (board.id === input.sourceBoardId || live.saveTimer || live.persistenceError)
2011
+ ? this.boardExecution.run(live, () => this.persistedState(false))
2012
+ : loadStateFromDB(db, board.id)!;
2013
+ state.nodes = state.nodes.map((node) => this.resolveNodeDataBlobs(node));
2014
+ library.set(board.id, { board, state });
2015
+ }
2016
+ const source = library.get(input.sourceBoardId)!;
2017
+ const destinationId = `board-${randomUUID()}`;
2018
+ const portalId = `board-portal-${randomUUID()}`;
2019
+ const at = new Date().toISOString();
2020
+ const plan = prepareBoardMove(
2021
+ normalized,
2022
+ library,
2023
+ destinationId,
2024
+ portalId,
2025
+ listImports(db, input.sourceBoardId),
2026
+ this.moveRuntimeBlockers(),
2027
+ currentActor(),
2028
+ at,
2029
+ );
2030
+ for (const id of plan.preview.affectedBoardIds) {
2031
+ const policy = library.get(id)!.state.ax?.policy;
2032
+ if (policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length))
2033
+ plan.preview.blockers.push(`${id}: clear the scope fence before relocating board-owned cards/references`);
2034
+ }
2035
+ for (const [id, state] of plan.states) {
2036
+ const original = library.get(id)?.state;
2037
+ if (!original) continue;
2038
+ const locked = humanPresence.lockedNodes(Date.now(), id);
2039
+ for (const node of original.nodes)
2040
+ if (
2041
+ locked.has(node.id) &&
2042
+ JSON.stringify(node) !== JSON.stringify(state.nodes.find((next) => next.id === node.id))
2043
+ )
2044
+ plan.preview.blockers.push(`${node.id}: being edited by ${locked.get(node.id)}`);
2045
+ }
2046
+ if (input.preview) return { preview: plan.preview };
2047
+ if (plan.preview.blockers.length)
2048
+ throw new OperationError('Move refused. Resolve the listed bindings, then preview again.', 409, {
2049
+ blockers: plan.preview.blockers,
2050
+ });
2051
+ db.run(
2052
+ 'INSERT INTO boards (id, name, folder, created_at, relation_vocabulary, readme_node_id) VALUES (?, ?, ?, ?, ?, ?)',
2053
+ [
2054
+ destinationId,
2055
+ name,
2056
+ source.board.folder,
2057
+ at,
2058
+ JSON.stringify(source.board.relationVocabulary ?? null),
2059
+ plan.preview.readmeNodeId,
2060
+ ],
2061
+ );
2062
+ for (const [id, state] of plan.states) {
2063
+ state.theme = this._theme;
2064
+ saveStateToDB(db, id, this.externalizePersistedStateBlobs(state));
2065
+ db.run(
2066
+ 'INSERT INTO board_move_boundaries VALUES (?, ?) ON CONFLICT(board_id) DO UPDATE SET request_id = excluded.request_id',
2067
+ [id, input.requestId],
2068
+ );
2069
+ }
2070
+ if (plan.preview.readmeNodeId) updateBoardInDB(db, input.sourceBoardId, { readmeNodeId: null });
2071
+ for (const attachment of plan.preview.attachmentIds) {
2072
+ db.run('UPDATE attachments SET board_id = ? WHERE id = ? AND board_id = ?', [
2073
+ destinationId,
2074
+ attachment,
2075
+ input.sourceBoardId,
2076
+ ]);
2077
+ db.run('UPDATE document_imports SET board_id = ? WHERE attachment_id = ? AND board_id = ?', [
2078
+ destinationId,
2079
+ attachment,
2080
+ input.sourceBoardId,
2081
+ ]);
2082
+ }
2083
+ recordCardRelocation(db, input.requestId, input.sourceBoardId, destinationId, plan.preview.nodeIds);
2084
+ const result: BoardMoveResult = {
2085
+ sourceBoardId: input.sourceBoardId,
2086
+ destinationBoardId: destinationId,
2087
+ portalId,
2088
+ nodeIds: plan.preview.nodeIds,
2089
+ affectedBoardIds: [...plan.preview.affectedBoardIds, destinationId],
2090
+ };
2091
+ db.run('INSERT INTO board_moves VALUES (?, ?, ?, ?)', [input.requestId, fingerprint, JSON.stringify(result), at]);
2092
+ committed = loadStateFromDB(db, input.sourceBoardId)!;
2093
+ return { result, replayed: false };
2094
+ };
2095
+ const transaction = db.transaction(perform);
2096
+ const result = input.preview ? transaction() : transaction.immediate();
2097
+ if (committed) {
2098
+ // This is not a board activation: retain the camera, runtime partition and
2099
+ // mounted viewers. Only the committed nodes, relations and pins changed.
2100
+ const retained = new Set(committed.nodes.map((node) => node.id));
2101
+ const removed = [...this.nodes.keys()].filter((id) => !retained.has(id));
2102
+ this.nodes.clear();
2103
+ for (const node of committed.nodes) this.nodes.set(node.id, this.resolveNodeDataBlobs(node));
2104
+ this.edges.clear();
2105
+ for (const edge of committed.edges) this.edges.set(edge.id, edge);
2106
+ this._contextPinnedNodeIds = new Set(committed.contextPins);
2107
+ this._contextPinMeta = new Map(Object.entries(committed.contextPinMeta ?? {}));
2108
+ this._storageVersion = committed.storageVersion!;
2109
+ this._contentRevision = committed.revisionState!.revision;
2110
+ this._revisionFloor = committed.revisionState!.floor;
2111
+ this._deletionTombstones = [];
2112
+ for (const id of removed) this.ax.revalidateAfterNodeRemoval(id);
2113
+ mutationHistory.reset();
2114
+ this._lastPersistenceError = null;
2115
+ this.notifyChange('nodes');
2116
+ this.notifyChange('pins');
2117
+ this.notifyChange('ax');
2118
+ if (result.result) this.reconcileStoredBoards(result.result.affectedBoardIds);
2119
+ }
2120
+ return result;
2121
+ }
2122
+
1574
2123
  updateBoard(
1575
2124
  id: string,
1576
2125
  patch: Pick<Parameters<typeof updateBoardInDB>[2], 'name' | 'folder' | 'mapPosition' | 'relationVocabulary'>,
@@ -1663,6 +2212,9 @@ class CanvasStateManager {
1663
2212
  delete data.axStep;
1664
2213
  delete data.axWorkStatus;
1665
2214
  delete data.workItemId;
2215
+ delete data.workItemBoardId;
2216
+ delete data.liveWorkAvailable;
2217
+ delete data.liveWorkSource;
1666
2218
  delete data.approvalGateId;
1667
2219
  delete data.parentGroup;
1668
2220
  if (typeof node.data.parentGroup === 'string' && ids.has(node.data.parentGroup)) {
@@ -1769,35 +2321,35 @@ class CanvasStateManager {
1769
2321
  /** Deletes a board and its snapshots; deleting the open board returns to Home first. */
1770
2322
  deleteBoard(id: string): boolean {
1771
2323
  if (!this._db) return false;
1772
- if (id === this._activeBoardId && !this.switchBoard(null)) return false;
2324
+ if (id === this.visibleBoard.id && !this.withVisibleBoard(() => this.switchBoard(null))) return false;
1773
2325
  // Reverse relations must include outgoing links still debounced on the open board.
1774
- if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
1775
- return deleteBoardFromDB(this._db, id, (boardId, nodeId) => this.invalidateRelationTarget(boardId, nodeId));
2326
+ this.flushToDisk();
2327
+ const removed = deleteBoardFromDB(this._db, id, (boardId, nodeId) =>
2328
+ this.invalidateRelationTarget(boardId, nodeId),
2329
+ );
2330
+ if (removed) {
2331
+ const board = this.liveBoards.get(id);
2332
+ if (board) {
2333
+ board.disposed = true;
2334
+ this.disposeBoardRuntime(board);
2335
+ }
2336
+ this.liveBoards.delete(id);
2337
+ }
2338
+ return removed;
1776
2339
  }
1777
2340
 
1778
2341
  /**
1779
- * Save the open board, then open `id` (or Home for null). The canvas state is
1780
- * replaced wholesale; callers own the runtime side (undo history, app
1781
- * sessions, watchers, SSE) — see `openCanvasBoard` in canvas-operations.
2342
+ * Save pending boards, then select the visible owner. Existing partitions
2343
+ * remain live; canvas-operations starts the selected board's runtime/SSE.
1782
2344
  */
1783
2345
  switchBoard(id: string | null): boolean {
1784
2346
  if (!this._db) return false;
1785
- if (this._saveTimer) {
1786
- clearTimeout(this._saveTimer);
1787
- this._saveTimer = null;
1788
- }
1789
- if (!this.saveToDisk()) {
1790
- throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state before switching boards.');
1791
- }
1792
- let next = this.emptyPersistedState();
1793
- if (id) {
1794
- const state = loadStateFromDB(this._db, id);
1795
- if (!state) return false;
1796
- this.initializePersistedWikiLinks(state, id);
1797
- next = state;
1798
- }
1799
- this.applyPersistedState(next);
1800
- this._activeBoardId = id;
2347
+ if (this.boardExecution.getStore())
2348
+ throw new OperationError('Board-scoped operations cannot change navigation.', 409);
2349
+ if (id && !getBoardFromDB(this._db, id)) return false;
2350
+ this.flushToDisk();
2351
+ this.visibleBoard = id ? this.loadBoardState(id) : this.createBoardState();
2352
+ this.navigationGeneration = ++this.nextGeneration;
1801
2353
  setActiveBoardIdInDB(this._db, id);
1802
2354
  this.notifyChange('nodes');
1803
2355
  this.notifyChange('pins');
@@ -1835,20 +2387,27 @@ class CanvasStateManager {
1835
2387
  private scheduleSave(): void {
1836
2388
  if (!this._db) return;
1837
2389
  if (this._saveTimer) clearTimeout(this._saveTimer);
1838
- this._saveTimer = setTimeout(() => {
1839
- this._saveTimer = null;
1840
- this.saveToDisk();
1841
- }, SAVE_DEBOUNCE_MS);
2390
+ this._saveTimer = setTimeout(
2391
+ this.bindBoard(() => {
2392
+ this._saveTimer = null;
2393
+ this.saveToDisk();
2394
+ }),
2395
+ SAVE_DEBOUNCE_MS,
2396
+ );
1842
2397
  }
1843
2398
 
1844
2399
  flushToDisk(): void {
1845
2400
  if (!this._db) return;
1846
- if (this._saveTimer) {
1847
- clearTimeout(this._saveTimer);
1848
- this._saveTimer = null;
1849
- }
1850
- if (!this.saveToDisk()) {
1851
- throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
2401
+ const current = this.boardState;
2402
+ for (const board of new Set([this.visibleBoard, ...this.liveBoards.values()])) {
2403
+ if (board !== current && !board.saveTimer && !board.persistenceError) continue;
2404
+ this.boardExecution.run(board, () => {
2405
+ if (this._saveTimer) {
2406
+ clearTimeout(this._saveTimer);
2407
+ this._saveTimer = null;
2408
+ }
2409
+ if (!this.saveToDisk()) throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
2410
+ });
1852
2411
  }
1853
2412
  if (this._db) {
1854
2413
  try {
@@ -1860,8 +2419,9 @@ class CanvasStateManager {
1860
2419
  }
1861
2420
  }
1862
2421
 
1863
- private persistedState(): PersistedCanvasState {
1864
- return this.externalizePersistedStateBlobs({
2422
+ private persistedState(externalize = true): PersistedCanvasState {
2423
+ const state: PersistedCanvasState = {
2424
+ storageVersion: this._storageVersion,
1865
2425
  version: 1,
1866
2426
  theme: this._theme,
1867
2427
  tour: this._tour,
@@ -1873,7 +2433,8 @@ class CanvasStateManager {
1873
2433
  contextPinMeta: this.pinMetaRecord(),
1874
2434
  ax: this.getAxState(),
1875
2435
  revisionState: this.revisionState(),
1876
- });
2436
+ };
2437
+ return externalize ? this.externalizePersistedStateBlobs(state) : state;
1877
2438
  }
1878
2439
 
1879
2440
  /** Write current state to SQLite immediately. */
@@ -1887,7 +2448,7 @@ class CanvasStateManager {
1887
2448
  }
1888
2449
  const boardId = this.ensureActiveBoard();
1889
2450
  if (!boardId) return false;
1890
- saveStateToDB(this._db, boardId, this.persistedState());
2451
+ this._storageVersion = saveStateToDB(this._db, boardId, this.persistedState());
1891
2452
  this._lastPersistenceError = null;
1892
2453
  return true;
1893
2454
  } catch (error) {
@@ -1903,18 +2464,20 @@ class CanvasStateManager {
1903
2464
  }
1904
2465
  }
1905
2466
 
1906
- private _lastPersistenceError: { message: string; at: string } | null = null;
1907
-
1908
2467
  /** Health view of state persistence: ok until a save fails, ok again after the next success. */
1909
2468
  get persistenceHealth(): { ok: boolean; lastError: { message: string; at: string } | null } {
1910
- return { ok: this._lastPersistenceError === null, lastError: this._lastPersistenceError };
2469
+ const lastError =
2470
+ [this.visibleBoard, ...this.liveBoards.values()].find((board) => board.persistenceError)?.persistenceError ??
2471
+ null;
2472
+ return { ok: lastError === null, lastError };
1911
2473
  }
1912
2474
 
1913
2475
  /** Close the SQLite database cleanly. Call on server shutdown. */
1914
2476
  close(): void {
1915
2477
  // A failed debounce has no timer left, but its edits still need saving.
1916
2478
  // Leave the database and memory intact if the retry fails.
1917
- if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
2479
+ if ([this.visibleBoard, ...this.liveBoards.values()].some((board) => board.saveTimer || board.persistenceError))
2480
+ this.flushToDisk();
1918
2481
  if (this._db) {
1919
2482
  try {
1920
2483
  finalizeCanvasDbForClose(this._db);
@@ -1928,8 +2491,14 @@ class CanvasStateManager {
1928
2491
  }
1929
2492
  this._db = null;
1930
2493
  }
1931
- this._activeBoardId = null;
1932
- this._boardGeneration += 1;
2494
+ for (const board of new Set([this.visibleBoard, ...this.liveBoards.values()])) {
2495
+ board.disposed = true;
2496
+ this.disposeBoardRuntime(board);
2497
+ }
2498
+ this.liveBoards.clear();
2499
+ this.hostState.capability = null;
2500
+ this.visibleBoard = this.createBoardState();
2501
+ this.navigationGeneration = ++this.nextGeneration;
1933
2502
  }
1934
2503
 
1935
2504
  // ── Snapshots ───────────────────────────────────────────────
@@ -1939,12 +2508,17 @@ class CanvasStateManager {
1939
2508
  return join(this._workspaceRoot, PMX_CANVAS_DIR, SNAPSHOTS_SUBDIR);
1940
2509
  }
1941
2510
 
1942
- private applyPersistedState(state: PersistedCanvasState): void {
2511
+ private applyPersistedState(state: PersistedCanvasState, replaced = true): void {
1943
2512
  // Validate the complete replacement before discarding any live board state.
2513
+ // Keep the cached task fields as the revision comparison baseline. Display
2514
+ // reads project fresh fields; explicit reconciliation stamps and saves them.
1944
2515
  const nodes = (Array.isArray(state.nodes) ? state.nodes : [])
1945
2516
  .filter((node) => node?.id)
1946
- .map((node) => structuredClone(this.normalizeNode(node)));
1947
- this._boardGeneration += 1;
2517
+ .map((node) => structuredClone(this.normalizeNode(node, undefined, { projectWork: false })));
2518
+ if (replaced) {
2519
+ this._boardGeneration = ++this.nextGeneration;
2520
+ if (this.boardState === this.visibleBoard) this.navigationGeneration = ++this.nextGeneration;
2521
+ }
1948
2522
  this._tour = state.tour ? structuredClone(state.tour) : undefined;
1949
2523
  this.nodes.clear();
1950
2524
  this.edges.clear();
@@ -1952,6 +2526,7 @@ class CanvasStateManager {
1952
2526
  this._contextPinnedNodeIds.clear();
1953
2527
  this._contextPinMeta.clear();
1954
2528
  this.ax.resetCanvasBound();
2529
+ this._storageVersion = state.storageVersion ?? 0;
1955
2530
  this._contentRevision = state.revisionState?.revision ?? 0;
1956
2531
  this._revisionFloor = state.revisionState?.floor ?? 0;
1957
2532
  this._deletionTombstones = structuredClone(state.revisionState?.tombstones ?? []);
@@ -2078,6 +2653,7 @@ class CanvasStateManager {
2078
2653
 
2079
2654
  try {
2080
2655
  const payload = this.externalizePersistedStateBlobs({
2656
+ storageVersion: this._storageVersion,
2081
2657
  version: 1,
2082
2658
  theme: this._theme,
2083
2659
  viewport: this._viewport,
@@ -2173,6 +2749,7 @@ class CanvasStateManager {
2173
2749
  restoreSnapshot(idOrName: string): boolean {
2174
2750
  const resolved = this.readResolvedSnapshot(idOrName);
2175
2751
  if (!resolved || resolved.state.version !== 1) return false;
2752
+ this.assertSnapshotRestorable(idOrName);
2176
2753
 
2177
2754
  const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
2178
2755
  version: 1,
@@ -2213,7 +2790,11 @@ class CanvasStateManager {
2213
2790
 
2214
2791
  try {
2215
2792
  const revisionBeforeRestore = this._contentRevision;
2216
- this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
2793
+ this.applyPersistedState({
2794
+ ...nextState,
2795
+ storageVersion: this._storageVersion,
2796
+ revisionState: this.revisionState(),
2797
+ });
2217
2798
  this.restampRestoredContent(previousState.nodes, previousState.edges, revisionBeforeRestore);
2218
2799
  this.scheduleSave();
2219
2800
  this.notifyChange('nodes');
@@ -2226,7 +2807,11 @@ class CanvasStateManager {
2226
2807
  const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
2227
2808
  const edgesBefore = this.getEdges();
2228
2809
  const revisionBeforeReplay = this._contentRevision;
2229
- this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
2810
+ this.applyPersistedState({
2811
+ ...nextState,
2812
+ storageVersion: this._storageVersion,
2813
+ revisionState: this.revisionState(),
2814
+ });
2230
2815
  this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
2231
2816
  this.scheduleSave();
2232
2817
  this.notifyChange('nodes');
@@ -2237,7 +2822,11 @@ class CanvasStateManager {
2237
2822
  const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
2238
2823
  const edgesBefore = this.getEdges();
2239
2824
  const revisionBeforeReplay = this._contentRevision;
2240
- this.applyPersistedState({ ...previousState, revisionState: this.revisionState() });
2825
+ this.applyPersistedState({
2826
+ ...previousState,
2827
+ storageVersion: this._storageVersion,
2828
+ revisionState: this.revisionState(),
2829
+ });
2241
2830
  this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
2242
2831
  this.scheduleSave();
2243
2832
  this.notifyChange('nodes');
@@ -2256,6 +2845,19 @@ class CanvasStateManager {
2256
2845
  }
2257
2846
  }
2258
2847
 
2848
+ /** Old snapshots stay readable, but neither whole-board nor one-card restore crosses a move. */
2849
+ assertSnapshotRestorable(idOrName: string): void {
2850
+ if (!this._db || !this._activeBoardId) return;
2851
+ const resolved = this.readResolvedSnapshot(idOrName);
2852
+ if (!resolved) throw new OperationError('Snapshot not found.', 404);
2853
+ assertBoardStorageVersion(this._db, this._activeBoardId, this._storageVersion);
2854
+ if ((resolved.state.moveBoundary ?? null) !== boardMoveBoundary(this._db, this._activeBoardId))
2855
+ throw new OperationError(
2856
+ 'This snapshot predates a move. Inspect it and reapply desired content to the current card, or restore a whole-library backup.',
2857
+ 409,
2858
+ );
2859
+ }
2860
+
2259
2861
  /** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
2260
2862
  getSnapshotData(idOrName: string): {
2261
2863
  name: string;
@@ -2452,18 +3054,22 @@ class CanvasStateManager {
2452
3054
  : (loadStateFromDB(this._db!, board.id)?.nodes ?? [])
2453
3055
  ).map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
2454
3056
  }));
2455
- this._db.transaction(() => {
2456
- for (const node of legacy) {
2457
- const resolved = this.resolveNodeDataBlobs(node);
2458
- const content = typeof resolved.data.content === 'string' ? resolved.data.content : '';
2459
- node.data = { ...node.data, wikiLinks: resolveWikiLinks(content, [], catalog) };
2460
- this._db!.run('UPDATE nodes SET data = ? WHERE board_id = ? AND id = ?', [
2461
- JSON.stringify(node.data),
2462
- boardId,
2463
- node.id,
2464
- ]);
2465
- }
2466
- })();
3057
+ state.storageVersion = this._db
3058
+ .transaction(() => {
3059
+ assertBoardStorageVersion(this._db!, boardId, state.storageVersion ?? 0);
3060
+ for (const node of legacy) {
3061
+ const resolved = this.resolveNodeDataBlobs(node);
3062
+ const content = typeof resolved.data.content === 'string' ? resolved.data.content : '';
3063
+ node.data = { ...node.data, wikiLinks: resolveWikiLinks(content, [], catalog) };
3064
+ this._db!.run('UPDATE nodes SET data = ? WHERE board_id = ? AND id = ?', [
3065
+ JSON.stringify(node.data),
3066
+ boardId,
3067
+ node.id,
3068
+ ]);
3069
+ }
3070
+ return boardStorageVersion(this._db!, boardId);
3071
+ })
3072
+ .immediate();
2467
3073
  }
2468
3074
 
2469
3075
  private bindWikiLinks(
@@ -2587,7 +3193,7 @@ class CanvasStateManager {
2587
3193
  description: `Added ${cloned.type} node "${(cloned.data.title as string) ?? cloned.id}"`,
2588
3194
  forward: this.suppressed(() => {
2589
3195
  this.nodes.set(cloned.id, {
2590
- ...structuredClone(cloned),
3196
+ ...this.normalizeNode(structuredClone(cloned)),
2591
3197
  contentRevision: this.nextContentRevision(),
2592
3198
  contentChangedAt: new Date().toISOString(),
2593
3199
  lastEditedBy: currentActor(),
@@ -2638,6 +3244,14 @@ class CanvasStateManager {
2638
3244
  updateNode(id: string, patch: Partial<CanvasNodeState>, savedWikiLinks?: WikiLink[] | null): void {
2639
3245
  const existing = this.nodes.get(id);
2640
3246
  if (!existing) return;
3247
+ if (
3248
+ existing.type === 'status' &&
3249
+ typeof existing.data.workItemId === 'string' &&
3250
+ ((patch.type !== undefined && patch.type !== existing.type) ||
3251
+ (patch.data && patch.data.workItemId !== existing.data.workItemId))
3252
+ ) {
3253
+ throw new OperationError('Live work card references are immutable.');
3254
+ }
2641
3255
  if (patch.data) {
2642
3256
  if (patch.data.contentOrigin !== existing.data.contentOrigin) {
2643
3257
  throw new OperationError('Content origin is immutable after creation.');
@@ -2913,6 +3527,10 @@ class CanvasStateManager {
2913
3527
  }
2914
3528
 
2915
3529
  private invalidateRelationTarget(boardId: string, nodeId: string): void {
3530
+ if (boardId !== this._activeBoardId && this.liveBoards.has(boardId)) {
3531
+ this.withBoard(boardId, () => this.invalidateRelationTarget(boardId, nodeId));
3532
+ return;
3533
+ }
2916
3534
  if (boardId === this._activeBoardId) {
2917
3535
  const node = this.nodes.get(nodeId);
2918
3536
  if (node) {
@@ -3220,6 +3838,9 @@ class CanvasStateManager {
3220
3838
  if (update.collapsed !== undefined && update.collapsed !== existing.collapsed) {
3221
3839
  nextPatch.collapsed = update.collapsed;
3222
3840
  }
3841
+ if (update.zIndex !== undefined && update.zIndex !== existing.zIndex) {
3842
+ nextPatch.zIndex = update.zIndex;
3843
+ }
3223
3844
  if (Object.keys(nextPatch).length === 0) {
3224
3845
  skipped++;
3225
3846
  continue;
@@ -3352,6 +3973,7 @@ class CanvasStateManager {
3352
3973
  */
3353
3974
  applyPersistedAxState(ax: unknown): void {
3354
3975
  this.ax.applyPersistedAx(ax);
3976
+ this.refreshLiveWorkCards();
3355
3977
  }
3356
3978
 
3357
3979
  getAxFocus(): PmxAxFocusState {
@@ -3371,6 +3993,14 @@ class CanvasStateManager {
3371
3993
  return this.ax.getWorkItems();
3372
3994
  }
3373
3995
 
3996
+ getWorkItemsOnBoard(boardId: string): PmxAxWorkItem[] {
3997
+ if (boardId === this._activeBoardId) return this.getWorkItems();
3998
+ const live = this.liveBoards.get(boardId);
3999
+ if (live && (live.saveTimer || live.persistenceError))
4000
+ return this.boardExecution.run(live, () => live.ax.getWorkItems());
4001
+ return this._db ? (loadStateFromDB(this._db, boardId)?.ax?.workItems ?? []) : [];
4002
+ }
4003
+
3374
4004
  addWorkItem(
3375
4005
  input: {
3376
4006
  title: string;
@@ -3416,6 +4046,7 @@ class CanvasStateManager {
3416
4046
  for (const nodeId of nodeIds) {
3417
4047
  const node = this.nodes.get(nodeId);
3418
4048
  if (!node) continue;
4049
+ if (node.type === 'status' && typeof node.data.workItemId === 'string') continue;
3419
4050
  // No-op guard: a title-/agentId-only work-item patch must not push
3420
4051
  // history entries and SSE churn for chips that aren't changing.
3421
4052
  if (node.data.axWorkStatus === status) continue;
@@ -3657,11 +4288,15 @@ class CanvasStateManager {
3657
4288
  return this.ax.getAxSteering(q);
3658
4289
  }
3659
4290
 
3660
- getPendingSteering(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
4291
+ getPendingSteering(
4292
+ options: { consumer?: string; limit?: number; addressedOnly?: boolean; offset?: number } = {},
4293
+ ): PmxAxSteeringMessage[] {
3661
4294
  return this.ax.getPendingSteering(options);
3662
4295
  }
3663
4296
 
3664
- getPendingSteeringForContext(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
4297
+ getPendingSteeringForContext(
4298
+ options: { consumer?: string; limit?: number; addressedOnly?: boolean; offset?: number } = {},
4299
+ ): PmxAxSteeringMessage[] {
3665
4300
  return this.ax.getPendingSteeringForContext(options);
3666
4301
  }
3667
4302
 
@@ -3685,6 +4320,8 @@ class CanvasStateManager {
3685
4320
  this._contextReadListener?.(boardId);
3686
4321
  return read;
3687
4322
  } catch (error) {
4323
+ // Explicit receipts need a distinguishable outcome, not best-effort diagnostics.
4324
+ if (input.deliveryId) throw error;
3688
4325
  logCanvasStateWarning('record context read failed', error);
3689
4326
  return null;
3690
4327
  }
@@ -3977,4 +4614,5 @@ class CanvasStateManager {
3977
4614
  // Module-level singleton — safe because Bun is single-threaded and this
3978
4615
  // module is imported once per process. Agent tools and the HTTP server share
3979
4616
  // the same instance; no locking needed.
4617
+ export const boardCatalogEpoch = randomUUID();
3980
4618
  export const canvasState = new CanvasStateManager();