pmx-canvas 0.8.2 → 0.9.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 (335) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +49 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2724 -476
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2899 -596
  8. package/dist/json-render/index.js +20 -20
  9. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  10. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  11. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  12. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  13. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  14. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  15. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  16. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  17. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  19. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  20. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  21. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  22. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  23. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  24. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  25. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  26. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  27. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  28. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  29. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  31. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  32. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  33. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  34. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  35. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  36. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  37. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  38. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  39. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  40. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  41. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  42. package/dist/types/client/icons.d.ts +0 -10
  43. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  44. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  45. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  46. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  47. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  48. package/dist/types/client/state/boards-store.d.ts +81 -9
  49. package/dist/types/client/state/canvas-store.d.ts +14 -1
  50. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  51. package/dist/types/client/state/context-status-store.d.ts +35 -0
  52. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  53. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  54. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  55. package/dist/types/client/state/presence-store.d.ts +4 -4
  56. package/dist/types/client/state/presentation.d.ts +28 -0
  57. package/dist/types/client/state/relation-store.d.ts +10 -0
  58. package/dist/types/client/state/session-store.d.ts +41 -2
  59. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  60. package/dist/types/client/types.d.ts +13 -1
  61. package/dist/types/client/utils/keyboard.d.ts +2 -0
  62. package/dist/types/client/utils/time-ago.d.ts +4 -0
  63. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  64. package/dist/types/server/agent-presence.d.ts +10 -3
  65. package/dist/types/server/ax-context.d.ts +1 -1
  66. package/dist/types/server/canvas-db.d.ts +30 -7
  67. package/dist/types/server/canvas-operations.d.ts +24 -2
  68. package/dist/types/server/canvas-provenance.d.ts +22 -2
  69. package/dist/types/server/canvas-serialization.d.ts +2 -0
  70. package/dist/types/server/canvas-state.d.ts +93 -15
  71. package/dist/types/server/canvas-validation.d.ts +1 -1
  72. package/dist/types/server/context-brief.d.ts +94 -6
  73. package/dist/types/server/context-reads.d.ts +33 -7
  74. package/dist/types/server/edit-summary.d.ts +20 -0
  75. package/dist/types/server/index.d.ts +71 -10
  76. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  77. package/dist/types/server/lifecycle-status.d.ts +10 -0
  78. package/dist/types/server/memory-graph.d.ts +9 -0
  79. package/dist/types/server/mutation-history.d.ts +2 -0
  80. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  81. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  82. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  83. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  84. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  85. package/dist/types/server/operations/ops/query.d.ts +23 -0
  86. package/dist/types/server/operations/registry.d.ts +1 -0
  87. package/dist/types/server/placement.d.ts +7 -0
  88. package/dist/types/server/scope-fence.d.ts +2 -1
  89. package/dist/types/server/spatial-analysis.d.ts +6 -3
  90. package/dist/types/server/tour-control.d.ts +32 -0
  91. package/dist/types/server/wiki-links.d.ts +14 -0
  92. package/dist/types/server/workbench-auth.d.ts +2 -0
  93. package/dist/types/shared/agent-context.d.ts +2 -0
  94. package/dist/types/shared/agent-presence.d.ts +15 -12
  95. package/dist/types/shared/auto-arrange.d.ts +1 -0
  96. package/dist/types/shared/boards.d.ts +4 -4
  97. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  98. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  99. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  100. package/dist/types/shared/memory-graph.d.ts +53 -0
  101. package/dist/types/shared/relations.d.ts +68 -0
  102. package/dist/types/shared/semantic-attention.d.ts +2 -0
  103. package/dist/types/shared/tour.d.ts +38 -0
  104. package/dist/types/shared/workspace-settings.d.ts +11 -0
  105. package/docs/RELEASE.md +21 -3
  106. package/docs/adr-001-bun-only-runtime.md +7 -1
  107. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  108. package/docs/api-reference.md +109 -0
  109. package/docs/ax-host-adapter-contract.md +1 -1
  110. package/docs/ax-state-contract.md +5 -4
  111. package/docs/cli.md +153 -15
  112. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  113. package/docs/design/AgentContext.dc.html +3 -3
  114. package/docs/design/BoardPins.dc.html +37 -22
  115. package/docs/design/Categories.dc.html +208 -0
  116. package/docs/design/CategoryEditor.dc.html +33 -0
  117. package/docs/design/Chrome.dc.html +52 -9
  118. package/docs/design/ContextChip.dc.html +224 -0
  119. package/docs/design/ContextChip600.dc.html +136 -0
  120. package/docs/design/EmptyState.dc.html +45 -0
  121. package/docs/design/Flows.dc.html +23 -1
  122. package/docs/design/HeaderMarks.dc.html +183 -0
  123. package/docs/design/Home.dc.html +19 -1
  124. package/docs/design/HomeGraph.dc.html +86 -202
  125. package/docs/design/HomeMap.dc.html +23 -1
  126. package/docs/design/HomeTeam.dc.html +32 -14
  127. package/docs/design/Lifecycle.dc.html +22 -0
  128. package/docs/design/Lifecycle600.dc.html +18 -0
  129. package/docs/design/LifecycleVariants.dc.html +157 -0
  130. package/docs/design/LinkAuthorship.dc.html +139 -0
  131. package/docs/design/LinksChanged.dc.html +203 -0
  132. package/docs/design/LinksOptions.dc.html +137 -0
  133. package/docs/design/LinksOptions600.dc.html +134 -0
  134. package/docs/design/NearPin.dc.html +9 -10
  135. package/docs/design/Pane600.dc.html +4 -3
  136. package/docs/design/Relations.dc.html +3 -2
  137. package/docs/design/Restore.dc.html +152 -0
  138. package/docs/design/SeeChange.dc.html +267 -0
  139. package/docs/design/Uses.dc.html +12 -6
  140. package/docs/design/canvas.json +146 -19
  141. package/docs/design.md +449 -16
  142. package/docs/environment.md +0 -1
  143. package/docs/evals/curation-effect.md +128 -0
  144. package/docs/http-api.md +459 -37
  145. package/docs/mcp.md +146 -18
  146. package/docs/node-types.md +58 -3
  147. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  148. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  149. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  150. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  151. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  152. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  153. package/docs/product-context-vision-2026-09.md +17 -1
  154. package/docs/product-vision-2026-09.md +1082 -43
  155. package/docs/product-vision-review-2026-09.md +13 -1
  156. package/docs/screenshots/workbench-live-dark.png +0 -0
  157. package/docs/screenshots/workbench-live-light.png +0 -0
  158. package/docs/sdk.md +62 -2
  159. package/package.json +1 -1
  160. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  161. package/plugins/pmx-canvas/plugin.json +1 -1
  162. package/plugins/pmx-canvas/runtime.json +1 -1
  163. package/plugins/pmx-canvas/tools.json +601 -22
  164. package/skills/pmx-canvas/SKILL.md +216 -42
  165. package/skills/pmx-canvas/references/full-reference.md +120 -21
  166. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  167. package/skills/pmx-canvas/references/importing-context.md +132 -0
  168. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  169. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  170. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  171. package/src/cli/agent.ts +2 -0
  172. package/src/cli/commands/ax.ts +12 -0
  173. package/src/cli/commands/boards.ts +87 -11
  174. package/src/cli/commands/edges.ts +28 -0
  175. package/src/cli/commands/groups.ts +7 -0
  176. package/src/cli/commands/nodes.ts +1 -0
  177. package/src/cli/commands/query.ts +34 -2
  178. package/src/cli/commands/record.ts +199 -48
  179. package/src/cli/index.ts +6 -1
  180. package/src/client/App.tsx +177 -70
  181. package/src/client/canvas/AttentionHistory.tsx +0 -2
  182. package/src/client/canvas/BarHint.tsx +40 -2
  183. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  184. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  185. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  186. package/src/client/canvas/BoardPinButton.tsx +33 -0
  187. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  188. package/src/client/canvas/CanvasNode.tsx +360 -237
  189. package/src/client/canvas/CanvasViewport.tsx +45 -7
  190. package/src/client/canvas/CommandBar.tsx +29 -23
  191. package/src/client/canvas/CommandPalette.tsx +5 -12
  192. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  193. package/src/client/canvas/ContextChip.tsx +296 -0
  194. package/src/client/canvas/ContextMenu.tsx +63 -5
  195. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  196. package/src/client/canvas/EdgeLayer.tsx +339 -76
  197. package/src/client/canvas/EmptyState.tsx +33 -18
  198. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  199. package/src/client/canvas/ExternalWriters.tsx +57 -42
  200. package/src/client/canvas/HomeGraphView.tsx +942 -0
  201. package/src/client/canvas/HomeMapView.tsx +641 -0
  202. package/src/client/canvas/HomeReviewView.tsx +167 -0
  203. package/src/client/canvas/HomeView.tsx +687 -160
  204. package/src/client/canvas/IntentLayer.tsx +19 -12
  205. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  206. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  207. package/src/client/canvas/LifecycleFields.tsx +91 -0
  208. package/src/client/canvas/LifecyclePanel.tsx +533 -0
  209. package/src/client/canvas/MemoryInspector.tsx +104 -0
  210. package/src/client/canvas/Minimap.tsx +3 -3
  211. package/src/client/canvas/NearPinLayer.tsx +187 -0
  212. package/src/client/canvas/NodeContextMark.tsx +175 -97
  213. package/src/client/canvas/Presentation.tsx +23 -57
  214. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  215. package/src/client/canvas/RelationsPanel.tsx +82 -0
  216. package/src/client/canvas/SelectionBar.tsx +3 -6
  217. package/src/client/canvas/SessionPanel.tsx +90 -50
  218. package/src/client/canvas/SessionReceipt.tsx +311 -39
  219. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  220. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  221. package/src/client/canvas/ToolRail.tsx +367 -192
  222. package/src/client/canvas/TopBar.tsx +354 -124
  223. package/src/client/canvas/canvas-area.ts +10 -3
  224. package/src/client/canvas/graph-geometry.ts +195 -0
  225. package/src/client/canvas/graph-model.ts +254 -0
  226. package/src/client/canvas/memory-layout.ts +106 -0
  227. package/src/client/canvas/use-chrome-height.ts +20 -0
  228. package/src/client/canvas/use-focus-trap.ts +31 -5
  229. package/src/client/canvas/use-memory-graph.ts +58 -0
  230. package/src/client/canvas/use-node-drag.ts +2 -1
  231. package/src/client/canvas/use-pan-zoom.ts +64 -7
  232. package/src/client/icons.tsx +0 -63
  233. package/src/client/mcp-app/style.css +0 -6
  234. package/src/client/nodes/AxStepControls.tsx +74 -50
  235. package/src/client/nodes/BoardNode.tsx +5 -1
  236. package/src/client/nodes/ContextNode.tsx +18 -11
  237. package/src/client/nodes/ExtAppFrame.tsx +0 -1
  238. package/src/client/nodes/FileNode.tsx +46 -42
  239. package/src/client/nodes/HtmlNode.tsx +32 -0
  240. package/src/client/nodes/ImageNode.tsx +1 -1
  241. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  242. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  243. package/src/client/nodes/MarkdownNode.tsx +377 -109
  244. package/src/client/nodes/McpAppNode.tsx +50 -20
  245. package/src/client/nodes/MdFormatBar.tsx +21 -18
  246. package/src/client/nodes/PromptNode.tsx +1 -1
  247. package/src/client/nodes/ResponseNode.tsx +1 -1
  248. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  249. package/src/client/state/attention-bridge.ts +64 -15
  250. package/src/client/state/boards-store.ts +193 -39
  251. package/src/client/state/canvas-store.ts +99 -12
  252. package/src/client/state/context-chip-store.ts +70 -0
  253. package/src/client/state/context-status-store.ts +53 -0
  254. package/src/client/state/intent-bridge.ts +51 -30
  255. package/src/client/state/knowledge-store.ts +43 -0
  256. package/src/client/state/near-pin-store.ts +115 -6
  257. package/src/client/state/presence-store.ts +7 -10
  258. package/src/client/state/presentation.ts +91 -0
  259. package/src/client/state/relation-store.ts +81 -0
  260. package/src/client/state/session-store.ts +144 -18
  261. package/src/client/state/sse-bridge.ts +206 -29
  262. package/src/client/state/workspace-settings.ts +11 -0
  263. package/src/client/theme/global.css +2724 -476
  264. package/src/client/types.ts +12 -0
  265. package/src/client/utils/keyboard.ts +10 -0
  266. package/src/client/utils/time-ago.ts +14 -0
  267. package/src/json-render/renderer/index.tsx +31 -0
  268. package/src/mcp/canvas-app.ts +12 -2
  269. package/src/mcp/context-uri-template.ts +7 -4
  270. package/src/mcp/server.ts +63 -41
  271. package/src/mcp/workbench-app.ts +39 -6
  272. package/src/server/agent-presence.ts +128 -28
  273. package/src/server/ax-context.ts +27 -13
  274. package/src/server/board-export.ts +12 -1
  275. package/src/server/canvas-db.ts +331 -42
  276. package/src/server/canvas-operations.ts +195 -83
  277. package/src/server/canvas-provenance.ts +53 -22
  278. package/src/server/canvas-serialization.ts +2 -0
  279. package/src/server/canvas-state.ts +950 -122
  280. package/src/server/canvas-validation.ts +9 -2
  281. package/src/server/context-brief.ts +345 -38
  282. package/src/server/context-reads.ts +178 -30
  283. package/src/server/demo-state.json +355 -24
  284. package/src/server/edit-summary.ts +74 -0
  285. package/src/server/html-surface.ts +8 -1
  286. package/src/server/index.ts +181 -11
  287. package/src/server/knowledge-lifecycle.ts +304 -0
  288. package/src/server/lifecycle-status.ts +232 -0
  289. package/src/server/memory-graph.ts +166 -0
  290. package/src/server/mutation-history.ts +18 -6
  291. package/src/server/operations/composites.ts +32 -11
  292. package/src/server/operations/http.ts +7 -2
  293. package/src/server/operations/index.ts +4 -0
  294. package/src/server/operations/mcp.ts +26 -1
  295. package/src/server/operations/ops/ax-presence.ts +1 -1
  296. package/src/server/operations/ops/ax-read.ts +57 -6
  297. package/src/server/operations/ops/ax-timeline.ts +28 -2
  298. package/src/server/operations/ops/backup.ts +5 -3
  299. package/src/server/operations/ops/batch.ts +35 -4
  300. package/src/server/operations/ops/boards.ts +188 -47
  301. package/src/server/operations/ops/canvas-wire.ts +31 -1
  302. package/src/server/operations/ops/edges.ts +36 -2
  303. package/src/server/operations/ops/groups.ts +6 -1
  304. package/src/server/operations/ops/lifecycle.ts +262 -0
  305. package/src/server/operations/ops/memory-graph.ts +45 -0
  306. package/src/server/operations/ops/nodes.ts +107 -24
  307. package/src/server/operations/ops/query.ts +281 -68
  308. package/src/server/operations/ops/snapshots.ts +73 -1
  309. package/src/server/operations/ops/validate.ts +5 -1
  310. package/src/server/operations/ops/viewport.ts +97 -3
  311. package/src/server/operations/registry.ts +74 -25
  312. package/src/server/placement.ts +29 -0
  313. package/src/server/scope-fence.ts +78 -6
  314. package/src/server/server.ts +115 -16
  315. package/src/server/spatial-analysis.ts +48 -23
  316. package/src/server/tour-control.ts +82 -0
  317. package/src/server/wiki-links.ts +137 -0
  318. package/src/server/workbench-auth.ts +11 -0
  319. package/src/shared/agent-context.ts +3 -0
  320. package/src/shared/agent-presence.ts +15 -15
  321. package/src/shared/auto-arrange.ts +2 -0
  322. package/src/shared/boards.ts +4 -4
  323. package/src/shared/knowledge-lifecycle.ts +22 -0
  324. package/src/shared/knowledge-presets.ts +80 -0
  325. package/src/shared/lifecycle-status.ts +26 -0
  326. package/src/shared/memory-graph.ts +74 -0
  327. package/src/shared/relations.ts +98 -0
  328. package/src/shared/semantic-attention.ts +15 -1
  329. package/src/shared/tour.ts +23 -5
  330. package/src/shared/workspace-settings.ts +13 -0
  331. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  332. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  333. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  334. package/dist/types/server/agent-context.d.ts +0 -22
  335. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -39,13 +39,16 @@
39
39
 
40
40
  import { spawnSync } from 'node:child_process';
41
41
  import { randomUUID } from 'node:crypto';
42
- import { isWorkbenchToken, workbenchToken } from './workbench-auth.js';
42
+ import { isWorkbenchToken, refuseStaleWorkbenchWrite, workbenchToken } from './workbench-auth.js';
43
43
  import { withCurrentActor } from './attribution.js';
44
44
  import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
45
45
  import { readFile } from 'node:fs/promises';
46
46
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
47
47
  import * as marked from 'marked';
48
+ import { renderWikiMarkdown, resolveWikiLinks, wikiReferences } from './wiki-links.js';
49
+ import { wikiLinksFromData } from '../shared/memory-graph.js';
48
50
  import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
51
+ import { boardCatalogEpoch, boardsPayload } from './operations/ops/boards.js';
49
52
  import {
50
53
  buildAxBridge,
51
54
  buildAxStateBridge,
@@ -83,6 +86,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_NAME_LENGTH } from './document-imp
83
86
  import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
84
87
  import { intentRegistry } from './intent-registry.js';
85
88
  import { agentPresence } from './agent-presence.js';
89
+ import { cardText, changeExcerpts, describeEdit } from './edit-summary.js';
86
90
  import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
87
91
  import { diffLayouts } from './mutation-history.js';
88
92
  import { humanPresence } from './human-presence.js';
@@ -159,6 +163,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
159
163
  // or every node when the board was empty at attach (no snapshot was taken).
160
164
  let addedIds = startSnapshotId ? new Set<string>() : new Set(layout.nodes.map((node) => node.id));
161
165
  let modifiedIds = new Set<string>();
166
+ let beforeNodes = new Map<string, CanvasNodeState>();
162
167
  // A session that changed NOTHING on the board leaves no snapshot behind —
163
168
  // session churn (idle timeouts, receipt tests, attach/detach cycles) was
164
169
  // flooding the History drawer with identical boards.
@@ -168,6 +173,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
168
173
  const diff = diffLayouts(snapData.name, snapData, layout);
169
174
  addedIds = new Set(diff.addedNodes.map((node) => node.id));
170
175
  modifiedIds = new Set(diff.modifiedNodes.map((node) => node.id));
176
+ beforeNodes = new Map(snapData.nodes.map((node) => [node.id, node]));
171
177
  if (
172
178
  diff.addedNodes.length === 0 &&
173
179
  diff.removedNodes.length === 0 &&
@@ -197,7 +203,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
197
203
  rejected: ax.approvalGates.filter((gate) => gate.status === 'rejected').length,
198
204
  held: ax.approvalGates.filter((gate) => gate.status === 'held').length,
199
205
  };
200
- const context = sessionContextActivity(layout.nodes, startedAt, addedIds, modifiedIds);
206
+ const context = sessionContextActivity(layout.nodes, startedAt, addedIds, modifiedIds, beforeNodes);
201
207
  // Reading or pinning is something the human wants to see even when the
202
208
  // board itself did not change ("did the agent read what I pinned?").
203
209
  if (context.read.length > 0 || context.pinned.length > 0) unchanged = false;
@@ -205,12 +211,15 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
205
211
  // workbench session and would overwrite it. The label is what the receipt shows.
206
212
  emitPrimaryWorkbenchEvent('agent-session-ended', {
207
213
  label: presence.label,
214
+ // The writer key (the envelope reserves `sessionId`): the client's live lens matches on it.
215
+ writer: presence.sessionId,
208
216
  parentAgentId: presence.parentAgentId ?? null,
217
+ startedAt,
209
218
  endedAt,
210
219
  endedBy,
211
220
  counts,
212
221
  unchanged,
213
- snapshot: snapshot ? { id: snapshot.id, name: snapshot.name } : null,
222
+ snapshot: snapshot ? { id: snapshot.id, name: snapshot.name, boardId: canvasState.activeBoardId } : null,
214
223
  context,
215
224
  });
216
225
  });
@@ -218,7 +227,13 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
218
227
  interface ReceiptNode {
219
228
  id: string;
220
229
  title: string;
230
+ boardId: string | null;
221
231
  reason?: string;
232
+ /** Edited cards: what the edit did ("rewrote the second paragraph"). */
233
+ change?: string;
234
+ /** Edited cards: the text before and after, cut around the first difference, for "See change". */
235
+ before?: string;
236
+ after?: string;
222
237
  }
223
238
 
224
239
  /**
@@ -231,6 +246,7 @@ function sessionContextActivity(
231
246
  startedAt: string | null,
232
247
  addedIds: Set<string>,
233
248
  modifiedIds: Set<string>,
249
+ beforeNodes: Map<string, CanvasNodeState>,
234
250
  ): {
235
251
  read: ReceiptNode[];
236
252
  pinned: ReceiptNode[];
@@ -239,11 +255,12 @@ function sessionContextActivity(
239
255
  changedSinceRead: ReceiptNode[];
240
256
  } {
241
257
  const byId = new Map(nodes.map((node) => [node.id, node]));
258
+ const boardId = canvasState.activeBoardId;
242
259
  const entry = (node: CanvasNodeState): ReceiptNode => ({
243
260
  id: node.id,
261
+ boardId,
244
262
  title: typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id,
245
263
  });
246
- const boardId = canvasState.activeBoardId;
247
264
  const read = startedAt
248
265
  ? canvasState.getReadNodeIdsSince(boardId, startedAt).flatMap((id) => {
249
266
  const node = byId.get(id);
@@ -256,7 +273,14 @@ function sessionContextActivity(
256
273
  return [{ ...entry(node), ...(meta.reason ? { reason: meta.reason } : {}) }];
257
274
  });
258
275
  const created = nodes.filter((node) => addedIds.has(node.id) && node.createdBy?.actor === 'agent').map(entry);
259
- const edited = nodes.filter((node) => modifiedIds.has(node.id) && node.lastEditedBy?.actor === 'agent').map(entry);
276
+ const edited = nodes
277
+ .filter((node) => modifiedIds.has(node.id) && node.lastEditedBy?.actor === 'agent')
278
+ .map((node) => {
279
+ const before = beforeNodes.get(node.id);
280
+ return before
281
+ ? { ...entry(node), change: describeEdit(before, node), ...changeExcerpts(cardText(before), cardText(node)) }
282
+ : entry(node);
283
+ });
260
284
  const readStatus = new Map(canvasState.getNodeReadStatus(boardId).map((status) => [status.nodeId, status]));
261
285
  const changedSinceRead = [...canvasState.contextPinnedNodeIds].flatMap((id) => {
262
286
  const node = byId.get(id);
@@ -898,19 +922,45 @@ function toSseFrame(event: string, payload: PrimaryWorkbenchEventPayload): Uint8
898
922
  // open and never flush it to the browser, so an EventSource client behind one
899
923
  // receives nothing and the canvas never boots. Every broadcast event is also
900
924
  // recorded here with a monotonic seq; GET /api/workbench/poll replays them as
901
- // a normal, immediately-closed JSON response that any proxy delivers. Bounded:
925
+ // a normal, completed JSON response that any proxy delivers. Bounded:
902
926
  // a client whose `since` fell off the ring gets a fresh connect snapshot,
903
927
  // which fully rebuilds client state (same events an SSE connect sends).
904
928
  const WORKBENCH_EVENT_RING_MAX = 500;
905
929
  let workbenchEventSeq = 0;
906
930
  const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
931
+ const workbenchPollWaiters = new Set<() => void>();
932
+ let remoteEdgesSignature = '';
907
933
 
908
934
  function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
935
+ let remoteEdgesChanged = false;
936
+ if (event === 'canvas-layout-update') {
937
+ // Remote footer titles come from the catalog. Refresh it on endpoint changes
938
+ // (including SDK writes and Undo), never on ordinary geometry broadcasts.
939
+ const signature = JSON.stringify([
940
+ canvasState.activeBoardId,
941
+ canvasState.boardGeneration,
942
+ canvasState
943
+ .getEdges()
944
+ .filter((edge) => edge.toBoardId)
945
+ .map((edge) => [edge.id, edge.from, edge.toBoardId, edge.to]),
946
+ ]);
947
+ remoteEdgesChanged = signature !== remoteEdgesSignature;
948
+ remoteEdgesSignature = signature;
949
+ }
950
+ if (event === 'canvas-layout-update' || event === 'context-pins-changed') {
951
+ payload = {
952
+ ...payload,
953
+ catalogEpoch: boardCatalogEpoch,
954
+ boardGeneration: canvasState.boardGeneration,
955
+ boardId: canvasState.activeBoardId,
956
+ };
957
+ }
909
958
  workbenchEventSeq += 1;
910
959
  workbenchEventRing.push({ seq: workbenchEventSeq, event, payload });
911
960
  if (workbenchEventRing.length > WORKBENCH_EVENT_RING_MAX) {
912
961
  workbenchEventRing.splice(0, workbenchEventRing.length - WORKBENCH_EVENT_RING_MAX);
913
962
  }
963
+ for (const wake of workbenchPollWaiters) wake();
914
964
  const frame = toSseFrame(event, payload);
915
965
  for (const [subscriberId, controller] of workbenchSubscribers.entries()) {
916
966
  try {
@@ -925,6 +975,7 @@ function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPa
925
975
  syncCanvasBrowserOpenedFromSubscribers();
926
976
  }
927
977
  }
978
+ if (remoteEdgesChanged) emitPrimaryWorkbenchEvent('boards-changed', boardsPayload());
928
979
  }
929
980
 
930
981
  function setPrimaryWorkbenchPath(safePath: string, source: string): void {
@@ -2201,6 +2252,9 @@ function buildWorkbenchConnectSnapshot(
2201
2252
  continuity,
2202
2253
  path: primaryWorkbenchPath,
2203
2254
  theme: canvasState.theme,
2255
+ workspaceSettings: canvasState.getWorkspaceSettings(),
2256
+ workbenchToken,
2257
+ catalogEpoch: boardCatalogEpoch,
2204
2258
  // Lets a long-lived client detect that the server was upgraded under it
2205
2259
  // (stale in-memory SPA — 0.4.5 report Finding W) and reload once. The
2206
2260
  // bundle stamp catches DEV rolls too: a rebuild+restart never bumps the
@@ -2243,6 +2297,9 @@ function buildWorkbenchConnectSnapshot(
2243
2297
  events.push({
2244
2298
  event: 'canvas-layout-update',
2245
2299
  payload: {
2300
+ catalogEpoch: boardCatalogEpoch,
2301
+ boardGeneration: canvasState.boardGeneration,
2302
+ boardId: canvasState.activeBoardId,
2246
2303
  layout: canvasState.getLayout(),
2247
2304
  sessionId: primaryWorkbenchSessionId,
2248
2305
  timestamp: new Date().toISOString(),
@@ -2251,6 +2308,7 @@ function buildWorkbenchConnectSnapshot(
2251
2308
  events.push({
2252
2309
  event: 'context-pins-changed',
2253
2310
  payload: {
2311
+ boardId: canvasState.activeBoardId,
2254
2312
  count: canvasState.contextPinnedNodeIds.size,
2255
2313
  nodeIds: Array.from(canvasState.contextPinnedNodeIds),
2256
2314
  sessionId: primaryWorkbenchSessionId,
@@ -2294,15 +2352,32 @@ function buildWorkbenchConnectSnapshot(
2294
2352
  * Proxy-safe transport (Amp orb portals and any proxy that buffers streaming
2295
2353
  * responses): returns the buffered events after `since` — or the full connect
2296
2354
  * snapshot when `since` is absent, from a previous server run, or already
2297
- * evicted from the ring — as a normal JSON response that closes immediately.
2298
- * The client polls every couple of seconds; each response flushes through any
2299
- * proxy. `seq` is the cursor for the next poll.
2355
+ * evicted from the ring — as a normal JSON response. A caught-up client may
2356
+ * wait up to one second for an event instead of leaving a gap between reads.
2357
+ * Each response closes so buffering proxies flush it. `seq` is the next cursor.
2300
2358
  */
2301
- function handleWorkbenchPoll(url: URL): Response {
2359
+ async function handleWorkbenchPoll(req: Request, url: URL): Promise<Response> {
2302
2360
  rotatePrimaryWorkbenchSessionIfNeeded();
2303
2361
  const requestedSessionId = url.searchParams.get('session')?.trim() ?? '';
2304
2362
  const sinceRaw = url.searchParams.get('since');
2305
2363
  const since = sinceRaw === null ? Number.NaN : Number(sinceRaw);
2364
+ const waitMs = Math.min(1000, Math.max(0, Number(url.searchParams.get('waitMs')) || 0));
2365
+ if (waitMs > 0 && since === workbenchEventSeq && !req.signal.aborted) {
2366
+ await new Promise<void>((resolve) => {
2367
+ const finish = (): void => {
2368
+ if (!workbenchPollWaiters.delete(finish)) return;
2369
+ clearTimeout(timer);
2370
+ req.signal.removeEventListener('abort', finish);
2371
+ resolve();
2372
+ };
2373
+ const timer = setTimeout(finish, waitMs);
2374
+ workbenchPollWaiters.add(finish);
2375
+ req.signal.addEventListener('abort', finish, { once: true });
2376
+ if (req.signal.aborted) finish();
2377
+ });
2378
+ }
2379
+ if (req.signal.aborted || !server) return new Response(null, { status: 204 });
2380
+ // Recheck retention after waiting: an event burst can evict this cursor.
2306
2381
  const ringHeadSeq =
2307
2382
  workbenchEventRing.length > 0 ? (workbenchEventRing[0] as { seq: number }).seq : workbenchEventSeq + 1;
2308
2383
  const usableSince = Number.isInteger(since) && since >= 0 && since <= workbenchEventSeq && since + 1 >= ringHeadSeq;
@@ -2378,6 +2453,8 @@ function handleWorkbenchEvents(req: Request): Response {
2378
2453
  }
2379
2454
 
2380
2455
  async function handleSave(req: Request): Promise<Response> {
2456
+ const staleWrite = refuseStaleWorkbenchWrite(req);
2457
+ if (staleWrite) return staleWrite;
2381
2458
  const body = await readJson(req);
2382
2459
  if (body === null) return malformedJsonResponse();
2383
2460
  const pathLike = typeof body.path === 'string' ? body.path : '';
@@ -2397,12 +2474,31 @@ async function handleSave(req: Request): Promise<Response> {
2397
2474
  async function handleRender(req: Request): Promise<Response> {
2398
2475
  const body = await readJson(req);
2399
2476
  if (body === null) return malformedJsonResponse();
2477
+ if (body.expectedBoardId !== undefined && body.expectedBoardId !== canvasState.activeBoardId) {
2478
+ return responseJson({ ok: false, error: 'The active board changed. Refresh before retrying this action.' }, 409);
2479
+ }
2400
2480
  const markdown = typeof body.markdown === 'string' ? body.markdown : '';
2401
- const html =
2402
- (marked.parse(normalizeMarkdownExternalUrls(markdown), {
2403
- gfm: true,
2404
- breaks: true,
2405
- }) as string) || '';
2481
+ const node = typeof body.nodeId === 'string' ? canvasState.getNode(body.nodeId) : undefined;
2482
+ const previous = node?.type === 'markdown' ? wikiLinksFromData(node.data) : [];
2483
+ // The editor sends its draft through this same endpoint. Only changed/unsaved
2484
+ // text may resolve titles; persisted text must replay even unresolved bindings.
2485
+ const persisted = node?.type === 'markdown' && node.data.content === markdown;
2486
+ const initializedRefs =
2487
+ node?.type === 'markdown' && node.data.wikiLinks !== undefined && typeof node.data.content === 'string'
2488
+ ? wikiReferences(node.data.content)
2489
+ : [];
2490
+ const refs = wikiReferences(markdown).filter(
2491
+ (ref) => !initializedRefs.includes(ref) || previous.some((link) => link.ref === ref),
2492
+ );
2493
+ const needsResolution = !persisted && refs.some((ref) => !previous.some((link) => link.ref === ref && link.target));
2494
+ const catalog = needsResolution ? canvasState.wikiCatalog() : [];
2495
+ const links = persisted ? previous : refs.map((ref) => resolveWikiLinks(`[[${ref}]]`, previous, catalog)[0]);
2496
+ const targets = new Map<string, ReturnType<typeof canvasState.readBoard>>();
2497
+ const html = renderWikiMarkdown(normalizeMarkdownExternalUrls(markdown), links, (target) => {
2498
+ if (!targets.has(target.boardId)) targets.set(target.boardId, canvasState.readBoard(target.boardId, false));
2499
+ const board = targets.get(target.boardId);
2500
+ return !!board && (!target.cardId || board.layout.nodes.some((card) => card.id === target.cardId));
2501
+ });
2406
2502
  return responseJson({ html });
2407
2503
  }
2408
2504
 
@@ -3354,6 +3450,8 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3354
3450
  // browser only (an MCP notification would make a read cause more reads).
3355
3451
  canvasState.setContextReadListener((boardId) => {
3356
3452
  emitPrimaryWorkbenchEvent('context-status-changed', { boardId });
3453
+ // The live session lens counts reads (AgentContext.dc.html).
3454
+ agentPresence.noteRead();
3357
3455
  });
3358
3456
 
3359
3457
  // Ensure direct HTTP server usage records undo/redo history, not just PmxCanvas.start().
@@ -3517,7 +3615,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3517
3615
  }
3518
3616
 
3519
3617
  if (url.pathname === '/api/workbench/poll' && req.method === 'GET') {
3520
- return handleWorkbenchPoll(url);
3618
+ return handleWorkbenchPoll(req, url);
3521
3619
  }
3522
3620
 
3523
3621
  if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
@@ -3677,6 +3775,7 @@ export function stopCanvasServer(): void {
3677
3775
  server.stop(true);
3678
3776
  server = null;
3679
3777
  }
3778
+ for (const wake of workbenchPollWaiters) wake();
3680
3779
  }
3681
3780
 
3682
3781
  export function getCanvasServerPort(): number | null {
@@ -72,17 +72,8 @@ export interface SpatialContext {
72
72
 
73
73
  // ── Helpers ──────────────────────────────────────────────────────────
74
74
 
75
- /** Euclidean distance between two node centers */
76
- export function centerDistance(a: CanvasNodeState, b: CanvasNodeState): number {
77
- const ax = a.position.x + a.size.width / 2;
78
- const ay = a.position.y + a.size.height / 2;
79
- const bx = b.position.x + b.size.width / 2;
80
- const by = b.position.y + b.size.height / 2;
81
- return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2);
82
- }
83
-
84
75
  /** Gap distance — how far apart two nodes are edge-to-edge (0 if overlapping) */
85
- function gapDistance(a: CanvasNodeState, b: CanvasNodeState): number {
76
+ export function gapDistance(a: CanvasNodeState, b: CanvasNodeState): number {
86
77
  const aRight = a.position.x + a.size.width;
87
78
  const aBottom = a.position.y + a.size.height;
88
79
  const bRight = b.position.x + b.size.width;
@@ -259,7 +250,8 @@ export function detectClusters(nodes: CanvasNodeState[], proximityThreshold = 20
259
250
  }
260
251
 
261
252
  /**
262
- * Find the nearest unpinned nodes to each pinned node.
253
+ * Find the nearest unpinned nodes by their edge-to-edge gap from each pin.
254
+ * The inclusive distance limit is in world pixels, independent of card size.
263
255
  */
264
256
  export function findNeighborhoods(
265
257
  nodes: CanvasNodeState[],
@@ -272,7 +264,7 @@ export function findNeighborhoods(
272
264
 
273
265
  return pinned.map((pin) => {
274
266
  const withDist = unpinned
275
- .map((n) => ({ node: n, distance: centerDistance(pin, n) }))
267
+ .map((n) => ({ node: n, distance: gapDistance(pin, n) }))
276
268
  .filter((d) => d.distance <= maxDistance)
277
269
  .sort((a, b) => a.distance - b.distance)
278
270
  .slice(0, maxNeighbors);
@@ -290,6 +282,38 @@ export function findNeighborhoods(
290
282
  });
291
283
  }
292
284
 
285
+ /** Shared lexical relevance for cards and library metadata. Scores are ranking details. */
286
+ export function searchRelevance(title: string, fields: Array<[string, number]>, query: string): number {
287
+ const normalize = (text: string) => text.toLowerCase().trim().replace(/\s+/g, ' ');
288
+ const q = normalize(query);
289
+ if (!q) return 0;
290
+ const terms = [...new Set(q.split(' '))];
291
+ const texts = [[normalize(title), 3] as const, ...fields.map(([text, weight]) => [normalize(text), weight] as const)];
292
+ const whole = terms.map((term) => {
293
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
294
+ // JS \b is ASCII-only; letters, marks and numbers in any script remain part of a word.
295
+ return new RegExp(`(?<![\\p{L}\\p{M}\\p{N}_])${escaped}(?![\\p{L}\\p{M}\\p{N}_])`, 'u');
296
+ });
297
+ let weight = 0;
298
+ for (const [text, importance] of texts) {
299
+ for (const term of terms) if (text.includes(term)) weight += importance;
300
+ }
301
+ if (!weight) return 0;
302
+ const wholeTerms = whole.filter((pattern) => texts.some(([text]) => pattern.test(text))).length;
303
+ const tier =
304
+ texts[0][0] === q
305
+ ? 5
306
+ : texts.some(([text]) => text === q)
307
+ ? 4
308
+ : wholeTerms === terms.length
309
+ ? 3
310
+ : wholeTerms > 0
311
+ ? 2
312
+ : 1;
313
+ // Field evidence breaks ties but can never overwhelm a better match tier.
314
+ return tier + weight / (weight + 1);
315
+ }
316
+
293
317
  /**
294
318
  * Full-text search across node titles and content.
295
319
  * Returns matching nodes with relevance score.
@@ -322,16 +346,17 @@ export function searchNodes(
322
346
  ).toLowerCase();
323
347
  const url = ((node.data.url as string) ?? '').toLowerCase();
324
348
 
325
- let score = 0;
326
- for (const term of terms) {
327
- // Title matches are worth more
328
- if (title.includes(term)) score += 3;
329
- if (path.includes(term)) score += 2;
330
- if (url.includes(term)) score += 2;
331
- if (description.includes(term)) score += 1;
332
- if (summary.includes(term)) score += 1;
333
- if (content.includes(term)) score += 1;
334
- }
349
+ const score = searchRelevance(
350
+ title,
351
+ [
352
+ [path, 2],
353
+ [url, 2],
354
+ [description, 1],
355
+ [summary, 1],
356
+ [content, 1],
357
+ ],
358
+ q,
359
+ );
335
360
 
336
361
  if (score === 0) continue;
337
362
 
@@ -359,7 +384,7 @@ export function searchNodes(
359
384
  });
360
385
  }
361
386
 
362
- results.sort((a, b) => b.score - a.score);
387
+ results.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
363
388
  return results;
364
389
  }
365
390
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Driving a board tour: the shared stop cursor and eased camera moves.
3
+ *
4
+ * Navigation, not editing: nothing here records undo history, and the cursor
5
+ * is in-memory (like presence) — a restart or board switch starts over at the
6
+ * first stop. The browser animates; the server only decides WHERE to go and
7
+ * broadcasts it, so an agent and every presenting viewer agree on the stop.
8
+ *
9
+ * This module must never import server.ts or index.ts.
10
+ */
11
+ import { derivedTour, resolveStop, tourStopSchema, type Camera, type Tour, type TourStop } from '../shared/tour.js';
12
+ import { canvasState } from './canvas-state.js';
13
+ import { getClientViewportSize } from './canvas-operations.js';
14
+ import { OperationError } from './operations/types.js';
15
+
16
+ export type TourStep = number | 'next' | 'previous';
17
+
18
+ export interface TourStepResult {
19
+ index: number;
20
+ total: number;
21
+ stop: TourStop;
22
+ }
23
+
24
+ let cursor: { boardGeneration: number; tour: string; index: number } | null = null;
25
+
26
+ export function currentTour(): Tour {
27
+ return canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes);
28
+ }
29
+
30
+ /** The stop the tour is on for the active board, or null when not touring. */
31
+ export function tourPosition(tour: Tour = currentTour()): number | null {
32
+ if (cursor && (cursor.boardGeneration !== canvasState.boardGeneration || cursor.tour !== JSON.stringify(tour))) {
33
+ cursor = null;
34
+ }
35
+ return cursor?.index ?? null;
36
+ }
37
+
38
+ /** Move the shared cursor. `next`/`previous` clamp at the ends; an explicit index must exist. */
39
+ export function goToTourStop(step: TourStep): TourStepResult {
40
+ const tour = currentTour();
41
+ const { stops } = tour;
42
+ const at = tourPosition(tour);
43
+ if (stops.length === 0) {
44
+ throw new OperationError('This board has no tour stops. Save a tour or add groups.', 400);
45
+ }
46
+ let index: number;
47
+ if (step === 'next') index = at === null ? 0 : Math.min(stops.length - 1, at + 1);
48
+ else if (step === 'previous') index = at === null ? 0 : Math.max(0, Math.min(stops.length - 1, at - 1));
49
+ else if (Number.isInteger(step) && step >= 0 && step < stops.length) index = step;
50
+ else throw new OperationError(`Tour stop ${step} does not exist (stops 0–${stops.length - 1}).`, 400);
51
+ cursor = { boardGeneration: canvasState.boardGeneration, tour: JSON.stringify(tour), index };
52
+ return { index, total: stops.length, stop: stops[index] as TourStop };
53
+ }
54
+
55
+ export function exitTour(): void {
56
+ cursor = null;
57
+ }
58
+
59
+ /**
60
+ * Resolve an eased camera move and store its end camera as the board viewport
61
+ * (unrecorded). The size is the last window a browser reported, so this is the
62
+ * server's best estimate; the browser re-resolves against its real canvas area.
63
+ */
64
+ export function moveCamera(input: unknown): { stop: TourStop; viewport: Camera } {
65
+ const parsed = tourStopSchema.safeParse(input);
66
+ if (!parsed.success) {
67
+ throw new OperationError('Give exactly one of nodeId, rect {x,y,width,height} or viewport {x,y,scale}.', 400);
68
+ }
69
+ const stop = parsed.data;
70
+ if ('nodeId' in stop.target && !canvasState.getNode(stop.target.nodeId)) {
71
+ throw new OperationError(`Node "${stop.target.nodeId}" not found.`, 404);
72
+ }
73
+ const size = getClientViewportSize() ?? { width: 1440, height: 900 };
74
+ let viewport: Camera;
75
+ try {
76
+ viewport = resolveStop(stop, canvasState.getLayout().nodes, size.width, size.height);
77
+ } catch (error) {
78
+ throw new OperationError(error instanceof Error ? error.message : String(error), 400);
79
+ }
80
+ canvasState.withSuppressedRecording(() => canvasState.setViewport(viewport));
81
+ return { stop, viewport };
82
+ }
@@ -0,0 +1,137 @@
1
+ import { Marked, type Token, type Tokens } from 'marked';
2
+ import type { MemoryTarget, WikiLink } from '../shared/memory-graph.js';
3
+
4
+ interface WikiToken extends Tokens.Generic {
5
+ type: 'wiki';
6
+ ref: string;
7
+ label: string;
8
+ }
9
+
10
+ function wiki(token: Token): token is WikiToken {
11
+ return token.type === 'wiki';
12
+ }
13
+
14
+ const escapeHtml = (text: string) =>
15
+ text.replace(
16
+ /[&<>"']/g,
17
+ (c) =>
18
+ ({
19
+ '&': '&amp;',
20
+ '<': '&lt;',
21
+ '>': '&gt;',
22
+ '"': '&quot;',
23
+ "'": '&#39;',
24
+ })[c]!,
25
+ );
26
+
27
+ // Same language as Marked 15's blockSkip, with ordinary destination characters
28
+ // consumed in runs instead of one regex iteration per byte of an inline image.
29
+ const blockSkip = /\[[^[\]]*?\]\([^\\()]*(?:\\.[^\\()]*|\([^\\()]*(?:\\.[^\\()]*)*\)[^\\()]*)*\)|`[^`]*?`|<[^<>]*?>/g;
30
+
31
+ /** Marked's inline tokenizer keeps wiki syntax out of code spans/fences and HTML attributes. */
32
+ function parser(render: (token: WikiToken) => string): Marked {
33
+ let taskIndex = 0;
34
+ return new Marked(
35
+ { gfm: true, breaks: true },
36
+ {
37
+ renderer: {
38
+ checkbox({ checked }) {
39
+ return `<input data-pmx-task="${taskIndex++}" type="checkbox" disabled${checked ? ' checked' : ''}>`;
40
+ },
41
+ html({ text }) {
42
+ // This attribute belongs to generated tasks, never author-supplied HTML.
43
+ return text.replace(/\bdata-pmx-task\b/gi, 'data-pmx-raw-task');
44
+ },
45
+ },
46
+ tokenizer: {
47
+ space() {
48
+ // Lexer installs rules after constructing the tokenizer. Copy rather
49
+ // than mutating shared rules, before it queues inline parsing.
50
+ this.rules.inline = { ...this.rules.inline, blockSkip };
51
+ return false;
52
+ },
53
+ lheading(src) {
54
+ // Setext headings cannot cross a blank line. Avoid Marked 15's costly
55
+ // unmatched long-line regex unless a possible underline is present.
56
+ const blank = /\n\s*?\n/.exec(src);
57
+ const paragraph = blank ? src.slice(0, blank.index + 1) : src;
58
+ if (!/\n {0,3}(?:=+|-+) *(?:\n|$)/.test(paragraph)) return undefined;
59
+ return false;
60
+ },
61
+ },
62
+ extensions: [
63
+ {
64
+ name: 'wiki',
65
+ level: 'inline',
66
+ start: (src) => src.indexOf('[['),
67
+ tokenizer(src) {
68
+ const match = /^\[\[([^\]\n]+)\]\]/.exec(src);
69
+ if (!match) return undefined;
70
+ const [ref, ...alias] = match[1].split('|');
71
+ if (!ref.trim()) return undefined;
72
+ return { type: 'wiki', raw: match[0], ref: ref.trim(), label: alias.join('|').trim() || ref.trim() };
73
+ },
74
+ renderer: (token) => (wiki(token) ? render(token) : false),
75
+ },
76
+ ],
77
+ },
78
+ );
79
+ }
80
+
81
+ export function wikiReferences(content: string): string[] {
82
+ const marked = parser(() => '');
83
+ const refs = new Set<string>();
84
+ marked.walkTokens(marked.lexer(content), (token) => {
85
+ if (wiki(token)) refs.add(token.ref);
86
+ });
87
+ return [...refs];
88
+ }
89
+
90
+ export interface WikiBoard {
91
+ id: string;
92
+ name: string;
93
+ cards: Array<{ id: string; title: string }>;
94
+ }
95
+
96
+ /** IDs win over names; title matches must be unique. Never retarget an already bound link. */
97
+ export function resolveWikiLinks(content: string, previous: WikiLink[], boards: WikiBoard[]): WikiLink[] {
98
+ const resolve = (ref: string): MemoryTarget | undefined => {
99
+ const separator = ref.indexOf('#');
100
+ const boardName = (separator < 0 ? ref : ref.slice(0, separator)).trim();
101
+ const cardName = separator < 0 ? null : ref.slice(separator + 1).trim();
102
+ const exact = boards.find((board) => board.id === boardName);
103
+ const matches = exact
104
+ ? [exact]
105
+ : boards.filter((board) => board.name.toLocaleLowerCase() === boardName.toLocaleLowerCase());
106
+ if (matches.length !== 1) return undefined;
107
+ const board = matches[0];
108
+ if (cardName === null) return { boardId: board.id };
109
+ const exactCard = board.cards.find((card) => card.id === cardName);
110
+ const cards = exactCard
111
+ ? [exactCard]
112
+ : board.cards.filter((card) => card.title.toLocaleLowerCase() === cardName.toLocaleLowerCase());
113
+ return cards.length === 1 ? { boardId: board.id, cardId: cards[0].id } : undefined;
114
+ };
115
+ return wikiReferences(content).map((ref) => {
116
+ const target = previous.find((link) => link.ref === ref)?.target ?? resolve(ref);
117
+ return { ref, ...(target ? { target } : {}) };
118
+ });
119
+ }
120
+
121
+ /** Only server-owned bindings may navigate; the client still sanitizes rendered HTML. */
122
+ export function renderWikiMarkdown(
123
+ content: string,
124
+ links: WikiLink[],
125
+ exists: (target: MemoryTarget) => boolean,
126
+ ): string {
127
+ return parser((token) => {
128
+ const target = links.find((link) => link.ref === token.ref)?.target;
129
+ const attributes =
130
+ target && exists(target)
131
+ ? `data-wiki-board="${escapeHtml(target.boardId)}"${target.cardId ? ` data-wiki-card="${escapeHtml(target.cardId)}"` : ''}`
132
+ : target
133
+ ? 'data-wiki-missing="true"'
134
+ : '';
135
+ return `<a href="#" class="wiki-link${target && exists(target) ? '' : ' is-unresolved'}" ${attributes} data-wiki-ref="${escapeHtml(token.ref)}" data-wiki-markdown="${escapeHtml(token.raw)}" data-wiki-label="${escapeHtml(token.label)}">${escapeHtml(token.label)}</a>`;
136
+ }).parse(content) as string;
137
+ }
@@ -9,3 +9,14 @@ export function isWorkbenchToken(value: string | null): boolean {
9
9
  const expected = Buffer.from(workbenchToken);
10
10
  return actual.length === expected.length && timingSafeEqual(actual, expected);
11
11
  }
12
+
13
+ /** Refuse an old tab's writes after a daemon restart, without downgrading its actor. */
14
+ export function refuseStaleWorkbenchWrite(req: Request): Response | null {
15
+ const token = req.headers.get('x-pmx-workbench-token');
16
+ if (req.method === 'GET' || req.headers.get('x-pmx-workbench') !== '1' || token === null || isWorkbenchToken(token))
17
+ return null;
18
+ return Response.json(
19
+ { ok: false, error: 'Workbench connection changed. Reconnect before saving.' },
20
+ { status: 409, headers: { 'Cache-Control': 'no-store' } },
21
+ );
22
+ }
@@ -10,6 +10,8 @@ export interface AgentContextNode {
10
10
  kind: string;
11
11
  title: string | null;
12
12
  content: string | null;
13
+ /** Context excerpts do not serialize the card's complete incident relations. */
14
+ linksComplete?: false;
13
15
  metadata?: Record<string, unknown>;
14
16
  position?: { x: number; y: number };
15
17
  }
@@ -421,6 +423,7 @@ export function serializeNodeForAgentContext(
421
423
  kind: getCanvasNodeKind(node),
422
424
  title: typeof node.data.title === 'string' ? node.data.title : null,
423
425
  content: summarizeNodeForAgentContext(node, options) || null,
426
+ linksComplete: false,
424
427
  ...(metadata ? { metadata } : {}),
425
428
  ...(options.includePosition ? { position: node.position } : {}),
426
429
  };