pmx-canvas 0.7.2 → 0.8.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 (251) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +60 -0
  10. package/Readme.md +44 -16
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +844 -710
  23. package/dist/canvas/index.js +92 -90
  24. package/dist/canvas/mcp-app.html +7878 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +1 -1
  28. package/dist/json-render/index.js +55 -55
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  32. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  33. package/dist/types/client/icons.d.ts +1 -2
  34. package/dist/types/client/mcp-app/index.d.ts +1 -0
  35. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  36. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  37. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  38. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  39. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  40. package/dist/types/client/state/attention-store.d.ts +0 -5
  41. package/dist/types/client/state/canvas-store.d.ts +2 -0
  42. package/dist/types/client/state/context-status-store.d.ts +53 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +4 -1
  44. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  45. package/dist/types/client/state/presence-store.d.ts +5 -0
  46. package/dist/types/client/state/session-store.d.ts +33 -3
  47. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  48. package/dist/types/client/theme/tokens.d.ts +1 -3
  49. package/dist/types/client/types.d.ts +9 -0
  50. package/dist/types/json-render/renderer/index.d.ts +2 -1
  51. package/dist/types/json-render/server.d.ts +3 -1
  52. package/dist/types/mcp/app-server.d.ts +3 -0
  53. package/dist/types/mcp/canvas-access.d.ts +1 -1
  54. package/dist/types/mcp/canvas-app.d.ts +4 -0
  55. package/dist/types/mcp/workbench-app.d.ts +4 -0
  56. package/dist/types/server/agent-presence.d.ts +1 -1
  57. package/dist/types/server/ax-state.d.ts +1 -1
  58. package/dist/types/server/canvas-db.d.ts +8 -0
  59. package/dist/types/server/canvas-geometry.d.ts +25 -0
  60. package/dist/types/server/canvas-operations.d.ts +1 -11
  61. package/dist/types/server/canvas-state.d.ts +25 -3
  62. package/dist/types/server/context-brief.d.ts +8 -1
  63. package/dist/types/server/context-reads.d.ts +30 -7
  64. package/dist/types/server/index.d.ts +6 -1
  65. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  66. package/dist/types/server/operations/http.d.ts +2 -0
  67. package/dist/types/server/operations/invoker.d.ts +4 -2
  68. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  69. package/dist/types/server/server.d.ts +1 -0
  70. package/dist/types/server/spatial-analysis.d.ts +2 -0
  71. package/dist/types/shared/agent-context.d.ts +22 -0
  72. package/dist/types/shared/agent-presence.d.ts +3 -10
  73. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  74. package/dist/types/shared/canvas-app.d.ts +54 -0
  75. package/dist/types/shared/themes.d.ts +1 -1
  76. package/docs/RELEASE.md +7 -3
  77. package/docs/agent-plugin.md +123 -0
  78. package/docs/ax-host-adapter-contract.md +13 -2
  79. package/docs/ax-state-contract.md +40 -0
  80. package/docs/cli.md +7 -2
  81. package/docs/design/AgentContext.dc.html +186 -0
  82. package/docs/design/Before.dc.html +208 -0
  83. package/docs/design/BoardPins.dc.html +151 -0
  84. package/docs/design/Chrome.dc.html +208 -0
  85. package/docs/design/Context.dc.html +239 -0
  86. package/docs/design/Flows.dc.html +181 -0
  87. package/docs/design/Home.dc.html +209 -0
  88. package/docs/design/HomeGraph.dc.html +211 -0
  89. package/docs/design/HomeMap.dc.html +117 -0
  90. package/docs/design/HomeTeam.dc.html +176 -0
  91. package/docs/design/Light.dc.html +28 -0
  92. package/docs/design/Main.dc.html +280 -0
  93. package/docs/design/NearPin.dc.html +260 -0
  94. package/docs/design/Pane600.dc.html +120 -0
  95. package/docs/design/Relations.dc.html +297 -0
  96. package/docs/design/Themes.dc.html +121 -0
  97. package/docs/design/TokenSystem.dc.html +136 -0
  98. package/docs/design/Tokens.dc.html +163 -0
  99. package/docs/design/Types1.dc.html +177 -0
  100. package/docs/design/Types2.dc.html +226 -0
  101. package/docs/design/Types3.dc.html +195 -0
  102. package/docs/design/Uses.dc.html +152 -0
  103. package/docs/design/Vision.dc.html +116 -0
  104. package/docs/design/Zoom.dc.html +82 -0
  105. package/docs/design/canvas.json +274 -0
  106. package/docs/design.md +214 -0
  107. package/docs/environment.md +1 -1
  108. package/docs/http-api.md +80 -15
  109. package/docs/mcp.md +198 -3
  110. package/docs/node-types.md +34 -10
  111. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  112. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  113. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  114. package/docs/product-vision-2026-09.md +47 -7
  115. package/docs/screenshot.png +0 -0
  116. package/docs/sdk.md +1 -0
  117. package/package.json +10 -4
  118. package/plugins/pmx-canvas/LICENSE +21 -0
  119. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  120. package/plugins/pmx-canvas/assets/preview.png +0 -0
  121. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  122. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  123. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  124. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  125. package/plugins/pmx-canvas/mcp.json +12 -0
  126. package/plugins/pmx-canvas/plugin.json +24 -0
  127. package/plugins/pmx-canvas/runtime.json +3 -0
  128. package/plugins/pmx-canvas/runtime.mjs +194 -0
  129. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  130. package/plugins/pmx-canvas/tools.json +2560 -0
  131. package/skills/pmx-canvas/SKILL.md +31 -16
  132. package/skills/pmx-canvas/evals/evals.json +69 -0
  133. package/skills/pmx-canvas/references/full-reference.md +14 -10
  134. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  135. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  136. package/skills/pmx-canvas-testing/SKILL.md +54 -4
  137. package/src/cli/commands/copilot.ts +5 -0
  138. package/src/cli/commands/view.ts +1 -1
  139. package/src/cli/index.ts +8 -3
  140. package/src/client/App.tsx +6 -1
  141. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  142. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  143. package/src/client/canvas/CanvasNode.tsx +208 -220
  144. package/src/client/canvas/CanvasViewport.tsx +6 -2
  145. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  146. package/src/client/canvas/CommandBar.tsx +25 -23
  147. package/src/client/canvas/CommandPalette.tsx +3 -2
  148. package/src/client/canvas/ContextMenu.tsx +11 -0
  149. package/src/client/canvas/EdgeLayer.tsx +1 -1
  150. package/src/client/canvas/ExportDialog.tsx +33 -6
  151. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  152. package/src/client/canvas/IntentLayer.tsx +2 -2
  153. package/src/client/canvas/Minimap.tsx +12 -7
  154. package/src/client/canvas/NodeContextMark.tsx +156 -0
  155. package/src/client/canvas/SessionPanel.tsx +4 -7
  156. package/src/client/canvas/SessionReceipt.tsx +114 -17
  157. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  158. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  159. package/src/client/canvas/ToolRail.tsx +58 -108
  160. package/src/client/canvas/TopBar.tsx +16 -11
  161. package/src/client/icons.tsx +2 -12
  162. package/src/client/mcp-app/index.tsx +137 -0
  163. package/src/client/mcp-app/style.css +10 -0
  164. package/src/client/nodes/AttachmentNode.tsx +8 -1
  165. package/src/client/nodes/AxStepControls.tsx +1 -1
  166. package/src/client/nodes/ContextNode.tsx +9 -9
  167. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  168. package/src/client/nodes/FileNode.tsx +8 -5
  169. package/src/client/nodes/ImageNode.tsx +4 -2
  170. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  171. package/src/client/nodes/LedgerNode.tsx +2 -2
  172. package/src/client/nodes/MarkdownNode.tsx +44 -9
  173. package/src/client/nodes/McpAppNode.tsx +22 -4
  174. package/src/client/nodes/PromptNode.tsx +5 -17
  175. package/src/client/nodes/ResponseNode.tsx +3 -15
  176. package/src/client/nodes/StatusNode.tsx +2 -2
  177. package/src/client/nodes/StatusSummary.tsx +3 -1
  178. package/src/client/nodes/TraceNode.tsx +6 -4
  179. package/src/client/nodes/WebpageNode.tsx +1 -1
  180. package/src/client/nodes/ax-node-actions.ts +2 -2
  181. package/src/client/nodes/iframe-document-url.ts +3 -2
  182. package/src/client/nodes/markdown-sanitize.ts +10 -0
  183. package/src/client/nodes/surface-url.ts +10 -0
  184. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  185. package/src/client/nodes/use-surface-frame.ts +3 -1
  186. package/src/client/nodes/use-workbench-asset.ts +26 -0
  187. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  188. package/src/client/state/attention-bridge.ts +0 -8
  189. package/src/client/state/attention-store.ts +0 -17
  190. package/src/client/state/canvas-store.ts +7 -2
  191. package/src/client/state/context-status-store.ts +66 -0
  192. package/src/client/state/human-store.ts +2 -0
  193. package/src/client/state/iframe-mode.ts +5 -0
  194. package/src/client/state/intent-bridge.ts +24 -6
  195. package/src/client/state/near-pin-store.ts +53 -0
  196. package/src/client/state/presence-store.ts +5 -10
  197. package/src/client/state/session-store.ts +106 -9
  198. package/src/client/state/sse-bridge.ts +26 -7
  199. package/src/client/state/theme-override.ts +1 -1
  200. package/src/client/state/workbench-transport.ts +36 -0
  201. package/src/client/theme/global.css +844 -710
  202. package/src/client/theme/surface-theme.css +151 -46
  203. package/src/client/theme/tokens.ts +7 -11
  204. package/src/client/types.ts +10 -0
  205. package/src/json-render/renderer/index.tsx +58 -38
  206. package/src/json-render/server.ts +79 -15
  207. package/src/mcp/app-server.ts +64 -0
  208. package/src/mcp/canvas-access.ts +14 -11
  209. package/src/mcp/canvas-app.ts +318 -0
  210. package/src/mcp/server.ts +1 -1
  211. package/src/mcp/workbench-app.ts +211 -0
  212. package/src/server/agent-presence.ts +45 -6
  213. package/src/server/ax-context.ts +1 -1
  214. package/src/server/ax-state.ts +1 -1
  215. package/src/server/board-export.ts +5 -2
  216. package/src/server/canvas-db.ts +46 -12
  217. package/src/server/canvas-geometry.ts +64 -0
  218. package/src/server/canvas-operations.ts +6 -24
  219. package/src/server/canvas-state.ts +105 -15
  220. package/src/server/canvas-validation.ts +19 -4
  221. package/src/server/context-brief.ts +39 -2
  222. package/src/server/context-reads.ts +86 -15
  223. package/src/server/demo-state.json +31 -31
  224. package/src/server/html-surface.ts +4 -5
  225. package/src/server/index.ts +19 -3
  226. package/src/server/mcp-app-runtime.ts +11 -0
  227. package/src/server/operations/composites.ts +4 -3
  228. package/src/server/operations/http.ts +4 -4
  229. package/src/server/operations/invoker.ts +13 -4
  230. package/src/server/operations/ops/ax-read.ts +1 -1
  231. package/src/server/operations/ops/ax-timeline.ts +37 -0
  232. package/src/server/operations/ops/boards.ts +1 -1
  233. package/src/server/operations/ops/canvas-wire.ts +3 -6
  234. package/src/server/operations/ops/intent.ts +31 -2
  235. package/src/server/operations/ops/json-render.ts +1 -1
  236. package/src/server/operations/ops/nodes.ts +8 -76
  237. package/src/server/operations/ops/query.ts +8 -2
  238. package/src/server/operations/ops/snapshots.ts +4 -1
  239. package/src/server/operations/ops/viewport.ts +29 -5
  240. package/src/server/operations/registry.ts +38 -4
  241. package/src/server/scope-fence.ts +55 -10
  242. package/src/server/server.ts +86 -6
  243. package/src/server/spatial-analysis.ts +2 -2
  244. package/src/server/workboard.ts +6 -1
  245. package/src/{server → shared}/agent-context.ts +2 -2
  246. package/src/shared/agent-presence.ts +3 -15
  247. package/src/shared/ax-surface-protocol.ts +3 -0
  248. package/src/shared/canvas-app.ts +62 -0
  249. package/src/shared/themes.ts +5 -5
  250. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  251. package/src/client/canvas/kind-colors.ts +0 -23
@@ -50,8 +50,8 @@ import {
50
50
  isRecord,
51
51
  pickFiniteNumber,
52
52
  pickPositiveNumber,
53
- resolveCreateGeometry,
54
53
  } from './nodes.js';
54
+ import { resolveCreateGeometry } from '../../canvas-geometry.js';
55
55
 
56
56
  /** Legacy server.ts parseGraphPayloadData: a graph dataset must be an array of records. */
57
57
  export function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> | null {
@@ -22,13 +22,13 @@ import {
22
22
  removeCanvasNode,
23
23
  resolveHtmlContent,
24
24
  scheduleCodeGraphRecompute,
25
- validateCanvasNodePatch,
26
25
  MARKDOWN_NODE_DEFAULT_SIZE,
27
26
  MCP_APP_NODE_DEFAULT_SIZE,
28
27
  IMAGE_NODE_DEFAULT_SIZE,
29
28
  LEDGER_NODE_DEFAULT_SIZE,
30
29
  type CanvasSizeAdjustment,
31
30
  } from '../../canvas-operations.js';
31
+ import { resolveCreateGeometry, resolvePatchGeometry, validateCanvasNodePatch } from '../../canvas-geometry.js';
32
32
  import { normalizeNodeAxCapabilities } from '../../ax-interaction.js';
33
33
  import { applyFileContentToNodeData, readFileNodeContent } from '../../file-content.js';
34
34
  import { unwatchFileForNode, watchFileForNode } from '../../file-watcher.js';
@@ -126,81 +126,6 @@ export function pickPositiveNumber(record: Record<string, unknown>, key: string)
126
126
  return value !== undefined && value > 0 ? value : undefined;
127
127
  }
128
128
 
129
- function normalizeGeometryInput(body: Record<string, unknown>): {
130
- x?: number;
131
- y?: number;
132
- width?: number;
133
- height?: number;
134
- position?: { x?: number; y?: number };
135
- size?: { width?: number; height?: number };
136
- } {
137
- const position = getRecord(body.position);
138
- const size = getRecord(body.size);
139
- return {
140
- ...(pickFiniteNumber(body, 'x') !== undefined ? { x: pickFiniteNumber(body, 'x') } : {}),
141
- ...(pickFiniteNumber(body, 'y') !== undefined ? { y: pickFiniteNumber(body, 'y') } : {}),
142
- ...(pickFiniteNumber(body, 'width') !== undefined ? { width: pickFiniteNumber(body, 'width') } : {}),
143
- ...(pickFiniteNumber(body, 'height') !== undefined ? { height: pickFiniteNumber(body, 'height') } : {}),
144
- ...(position
145
- ? {
146
- position: {
147
- ...(pickFiniteNumber(position, 'x') !== undefined ? { x: pickFiniteNumber(position, 'x') } : {}),
148
- ...(pickFiniteNumber(position, 'y') !== undefined ? { y: pickFiniteNumber(position, 'y') } : {}),
149
- },
150
- }
151
- : {}),
152
- ...(size
153
- ? {
154
- size: {
155
- ...(pickFiniteNumber(size, 'width') !== undefined ? { width: pickFiniteNumber(size, 'width') } : {}),
156
- ...(pickFiniteNumber(size, 'height') !== undefined ? { height: pickFiniteNumber(size, 'height') } : {}),
157
- },
158
- }
159
- : {}),
160
- };
161
- }
162
-
163
- export function resolveCreateGeometry(body: Record<string, unknown>): {
164
- x?: number;
165
- y?: number;
166
- width?: number;
167
- height?: number;
168
- } {
169
- const geometry = normalizeGeometryInput(body);
170
- const x = geometry.x ?? geometry.position?.x;
171
- const y = geometry.y ?? geometry.position?.y;
172
- const width = geometry.width ?? geometry.size?.width;
173
- const height = geometry.height ?? geometry.size?.height;
174
- return {
175
- ...(x !== undefined ? { x } : {}),
176
- ...(y !== undefined ? { y } : {}),
177
- ...(width !== undefined ? { width } : {}),
178
- ...(height !== undefined ? { height } : {}),
179
- };
180
- }
181
-
182
- function resolvePatchGeometry(
183
- body: Record<string, unknown>,
184
- existing: CanvasNodeState,
185
- ): {
186
- position?: { x: number; y: number };
187
- size?: { width: number; height: number };
188
- } {
189
- const geometry = normalizeGeometryInput(body);
190
- const x = geometry.x ?? geometry.position?.x;
191
- const y = geometry.y ?? geometry.position?.y;
192
- const width = geometry.width ?? geometry.size?.width;
193
- const height = geometry.height ?? geometry.size?.height;
194
- return {
195
- ...(x !== undefined || y !== undefined
196
- ? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
197
- : {}),
198
- ...(width !== undefined || height !== undefined
199
- ? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
200
- : {}),
201
- };
202
- }
203
-
204
129
  type StringListField = { value?: string[]; error?: string };
205
130
 
206
131
  function parseStringListField(field: string, value: unknown): StringListField {
@@ -1057,6 +982,7 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
1057
982
 
1058
983
  const nodeUpdateShape = {
1059
984
  id: z.string().describe('Node ID to update'),
985
+ expectedContentRevision: z.number().int().nonnegative().optional().describe('Reject an edit based on stale content'),
1060
986
  intentId: z
1061
987
  .string()
1062
988
  .optional()
@@ -1199,6 +1125,12 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1199
1125
  const id = input.id;
1200
1126
  const existing = canvasState.getNode(id);
1201
1127
  if (!existing) throw new OperationError(`Node "${id}" not found.`, 404);
1128
+ if (input.expectedContentRevision !== undefined) {
1129
+ if (body.refresh === true) throw new OperationError('Conditional edits do not support webpage refresh.');
1130
+ if (input.expectedContentRevision !== (existing.contentRevision ?? 0)) {
1131
+ throw new OperationError('This node changed. Reopen the editor before saving your changes.', 409);
1132
+ }
1133
+ }
1202
1134
  if (existing.type === 'webpage' && body.refresh === true) {
1203
1135
  const rawUrl = typeof body.url === 'string' ? body.url : undefined;
1204
1136
  let url: string | undefined;
@@ -28,7 +28,7 @@ import {
28
28
  type ContextBriefLibraryBoard,
29
29
  type ContextBriefSourceEntry,
30
30
  } from '../../context-brief.js';
31
- import { summarizeNodeForAgentContext } from '../../agent-context.js';
31
+ import { summarizeNodeForAgentContext } from '../../../shared/agent-context.js';
32
32
  import { defineOperation, OperationError, type Operation, type OperationMcpToolHost } from '../types.js';
33
33
  import { buildSummaryFromLayout, isRecord } from './nodes.js';
34
34
  import { readTargetBoard } from './boards.js';
@@ -46,6 +46,7 @@ const pinShape = {
46
46
  .unknown()
47
47
  .optional()
48
48
  .describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
49
+ reason: z.unknown().optional().describe('Why these nodes matter; kept with newly pinned nodes and shown on hover.'),
49
50
  };
50
51
 
51
52
  const pinSchema = z.looseObject(pinShape);
@@ -69,6 +70,10 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
69
70
  .enum(['set', 'add', 'remove'])
70
71
  .optional()
71
72
  .describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
73
+ reason: z
74
+ .string()
75
+ .optional()
76
+ .describe('Why these nodes matter; kept with newly pinned nodes and shown to the human on hover.'),
72
77
  },
73
78
  // The wire body is { ok, count } (legacy HTTP shape); the tool reports the
74
79
  // resulting pin list, so re-read it from the host. (Legacy RemoteCanvasAccess
@@ -91,7 +96,8 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
91
96
  // Legacy 'set' capped at MAX_PINS BEFORE setCanvasContextPins dedupes —
92
97
  // replicated as-is. add/remove (formerly client-side in the MCP access
93
98
  // layer) pass through; setCanvasContextPins normalizes them.
94
- const result = setCanvasContextPins(mode === 'set' ? nodeIds.slice(0, MAX_PINS) : nodeIds, mode);
99
+ const reason = typeof body.reason === 'string' && body.reason.trim() ? body.reason.trim().slice(0, 280) : undefined;
100
+ const result = setCanvasContextPins(mode === 'set' ? nodeIds.slice(0, MAX_PINS) : nodeIds, mode, reason);
95
101
  ctx.emit('context-pins-changed', { count: result.count, nodeIds: result.nodeIds });
96
102
  return { ok: true, count: result.count };
97
103
  },
@@ -318,7 +318,7 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
318
318
  };
319
319
  },
320
320
  },
321
- handler: async ({ id }) => {
321
+ handler: async ({ id }, ctx) => {
322
322
  // Awaits only the synchronous restore; restoreCanvasSnapshot fires the
323
323
  // async ext-app rehydration itself and schedules a deferred
324
324
  // canvas-layout-update through setCanvasLayoutUpdateEmitter when it
@@ -326,6 +326,9 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
326
326
  // add a manual emit here or the frame is doubled.
327
327
  const result = await restoreCanvasSnapshot(id ?? '');
328
328
  if (!result.ok) throw new OperationError('Snapshot not found', 404);
329
+ const nodeIds = [...canvasState.contextPinnedNodeIds];
330
+ ctx.emit('context-pins-changed', { count: nodeIds.length, nodeIds });
331
+ ctx.emit('ax-state-changed', {});
329
332
  return { ok: true };
330
333
  },
331
334
  });
@@ -15,7 +15,13 @@
15
15
  import { z } from 'zod';
16
16
  import { tourSchema, derivedTour } from '../../../shared/tour.js';
17
17
  import { canvasState } from '../../canvas-state.js';
18
- import { arrangeCanvasNodes, clearCanvas, fitCanvasView, setClientViewportSize } from '../../canvas-operations.js';
18
+ import {
19
+ arrangeCanvasNodes,
20
+ clearCanvas,
21
+ fitCanvasView,
22
+ getClientViewportSize,
23
+ setClientViewportSize,
24
+ } from '../../canvas-operations.js';
19
25
  import { validateCanvasLayout } from '../../canvas-validation.js';
20
26
  import { defineOperation, OperationError, type Operation } from '../types.js';
21
27
  import { closeNodeAppSession, isRecord } from './nodes.js';
@@ -230,6 +236,10 @@ const viewportSetShape = {
230
236
  x: z.unknown().optional().describe('Viewport x offset'),
231
237
  y: z.unknown().optional().describe('Viewport y offset'),
232
238
  scale: z.unknown().optional().describe('Viewport zoom scale'),
239
+ center: z
240
+ .unknown()
241
+ .optional()
242
+ .describe('World point {x, y} to centre in the canvas area (instead of x/y); uses scale or the current zoom'),
233
243
  recordHistory: z.unknown().optional().describe('Pass false to skip the undo-history entry'),
234
244
  clientWidth: z.unknown().optional().describe("The reporting client's window width in CSS pixels"),
235
245
  clientHeight: z.unknown().optional().describe("The reporting client's window height in CSS pixels"),
@@ -255,12 +265,26 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
255
265
  setClientViewportSize(body.clientWidth, body.clientHeight);
256
266
  // A size-only report is not a camera command. Echoing the stored camera
257
267
  // here would cancel an in-flight browser focus with its stale fallback.
258
- if (![body.x, body.y, body.scale].some((value) => typeof value === 'number')) return { ok: true };
259
- const next = {
268
+ const center = body.center;
269
+ if (center === undefined && ![body.x, body.y, body.scale].some((value) => typeof value === 'number'))
270
+ return { ok: true };
271
+ const scale = typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale;
272
+ let next = {
260
273
  x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
261
274
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
262
- scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
275
+ scale,
263
276
  };
277
+ if (center !== undefined) {
278
+ if (!isRecord(center) || typeof center.x !== 'number' || typeof center.y !== 'number') {
279
+ throw new OperationError('center must be a world point {x, y}.');
280
+ }
281
+ if (body.x !== undefined || body.y !== undefined) throw new OperationError('Pass center or x/y, not both.');
282
+ // Screen-space translate (architecture rule 9): put the world point at
283
+ // the middle of the canvas area the browser last reported (fit's default
284
+ // size when no tab has reported one).
285
+ const area = getClientViewportSize() ?? { width: 1440, height: 900 };
286
+ next = { x: area.width / 2 - center.x * scale, y: area.height / 2 - center.y * scale, scale };
287
+ }
264
288
  if (body.recordHistory === false) {
265
289
  canvasState.withSuppressedRecording(() => {
266
290
  canvasState.setViewport(next);
@@ -269,7 +293,7 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
269
293
  canvasState.setViewport(next);
270
294
  }
271
295
  ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
272
- return { ok: true };
296
+ return { ok: true, viewport: canvasState.viewport };
273
297
  },
274
298
  });
275
299
 
@@ -12,8 +12,9 @@ import { canvasState } from '../canvas-state.js';
12
12
  import { intentRegistry } from '../intent-registry.js';
13
13
  import { humanPresence } from '../human-presence.js';
14
14
  import { withCurrentActor } from '../attribution.js';
15
+ import { withoutExternalMcp } from '../mcp-app-runtime.js';
15
16
  import { agentPresence, describeWrite } from '../agent-presence.js';
16
- import { checkScopeFence, checkScopeOwnership } from '../scope-fence.js';
17
+ import { checkScopeFence, checkScopeOwnership, describeOpTarget } from '../scope-fence.js';
17
18
  import { CONTEXT_READ_OPS, contextReadFromPayload } from '../context-reads.js';
18
19
  import { openBoardForWrite } from '../canvas-operations.js';
19
20
  import { boardsPayload } from './ops/boards.js';
@@ -108,6 +109,20 @@ function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxInte
108
109
  * chunk is exactly the high-frequency churn class the batch exemption exists
109
110
  * for; the stream's CREATING call (no nodeId yet) still ghosts once.
110
111
  */
112
+ /**
113
+ * A linked create that carries no x/y lands on its ghost, so the node appears
114
+ * where the human watched it form. A group built from children keeps the frame
115
+ * its children give it.
116
+ */
117
+ function landOnGhost(name: string, rawInput: unknown, intentId: string): unknown {
118
+ if (!INTENT_KINDS_BY_OPERATION[name]?.includes('create')) return rawInput;
119
+ const input = asRecord(rawInput);
120
+ if (typeof input.x === 'number' || typeof input.y === 'number') return rawInput;
121
+ if (name === 'group.create' && Array.isArray(input.childIds) && input.childIds.length > 0) return rawInput;
122
+ const position = intentRegistry.list().find((intent) => intent.id === intentId)?.position;
123
+ return position ? { ...input, x: position.x, y: position.y } : rawInput;
124
+ }
125
+
111
126
  function autoGhostExempt(name: string, rawInput: unknown): boolean {
112
127
  if (name !== 'jsonrender.stream') return false;
113
128
  const input =
@@ -238,6 +253,11 @@ function heldByHuman(name: string, rawInput: unknown): { nodeId: string; name: s
238
253
  if (locked.size === 0) return null;
239
254
  const input = asRecord(rawInput);
240
255
  const ids = [input.id, input.nodeId, input.groupId, input.from, input.to].filter(isString);
256
+ if (name === 'canvas.apply-updates' || name === 'node.update') {
257
+ // Fence and lock targets must agree, including translated children and
258
+ // the automatic parent frames re-fitted by a child write.
259
+ ids.push(...(describeOpTarget(getOperation(name), rawInput).nodeIds ?? []));
260
+ }
241
261
  if (MEMBERSHIP_OPS.has(name)) {
242
262
  for (const key of ['childIds', 'children']) {
243
263
  const list = input[key];
@@ -383,7 +403,12 @@ export async function executeOperation(
383
403
  ? (canvasState.readBoard(requestedBoard, false)?.state.contextPins ?? [])
384
404
  : [...canvasState.contextPinnedNodeIds]
385
405
  : [];
386
- const result = await executeOperationInner(name, rawInput, meta);
406
+ const run = () => executeOperationInner(name, rawInput, meta);
407
+ // False is a caller-imposed restriction, never a grant of authority.
408
+ // DELETE invokers encode it as a query string.
409
+ const result = await (input.allowExternalMcp === false || input.allowExternalMcp === 'false'
410
+ ? withoutExternalMcp(run)
411
+ : run());
387
412
  if (recordRead) {
388
413
  canvasState.recordContextRead(
389
414
  contextReadFromPayload(
@@ -442,6 +467,14 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
442
467
  if (TRUSTED_HUMAN_ONLY_OPS.has(name) && !meta.humanAuthor) {
443
468
  throw new OperationError('This action requires trusted human authority.', 403);
444
469
  }
470
+ // Embedded clients carry their captured board through the transport. Refuse
471
+ // stale writes before Home can auto-open a recent board or any mutation runs.
472
+ const rawExpectedBoard = asRecord(rawInput).expectedBoardId;
473
+ // HTTP DELETE query values are strings, including Home's JSON null.
474
+ const expectedBoard = rawExpectedBoard === 'null' ? null : rawExpectedBoard;
475
+ if (expectedBoard !== undefined && expectedBoard !== canvasState.activeBoardId) {
476
+ throw new OperationError('The active board changed. Refresh before retrying this action.', 409);
477
+ }
445
478
  // File drops carry their captured board through the HTTP round trip. Check
446
479
  // before opening a recent board from Home, not just before reading the file.
447
480
  const capturedBoard = asRecord(rawInput).boardId;
@@ -488,15 +521,16 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
488
521
  throw new OperationError(`Operation "${name}" cannot be committed through a ghost intent.`);
489
522
  }
490
523
  if (intentId) {
524
+ const input = landOnGhost(name, rawInput, intentId);
491
525
  return intentRegistry.runCommit(
492
526
  intentId,
493
527
  allowedKinds!,
494
528
  async () => {
495
- const result = await op.execute(rawInput, operationContext);
529
+ const result = await op.execute(input, operationContext);
496
530
  if (op.mutates) {
497
531
  emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
498
532
  }
499
- notePresence(name, rawInput, meta, op, result);
533
+ notePresence(name, input, meta, op, result);
500
534
  return result;
501
535
  },
502
536
  settledNodeId,
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { type FenceRect, fenceRectFromNodes } from '../shared/scope-fence.js';
27
27
  import { canvasState } from './canvas-state.js';
28
+ import { resolvePatchGeometry, validCanvasNodeUpdates } from './canvas-geometry.js';
28
29
  import type { Operation } from './operations/types.js';
29
30
 
30
31
  /** Bounding box of the fenced nodes plus padding; null when none of them exist. */
@@ -103,6 +104,48 @@ function groupChildren(input: Record<string, unknown>): string[] {
103
104
  return 'children' in data ? stringList(data.children) : [];
104
105
  }
105
106
 
107
+ /** Automatic frames are write targets when their descendants can cause a re-fit.
108
+ * Conservatively include them even if the resulting bounds happen to be equal.
109
+ * A manual frame does not re-fit, so propagation stops there. */
110
+ function includeAutoFrameParents(nodeIds: string[]): string[] {
111
+ const targets = new Set(nodeIds);
112
+ for (const id of targets) {
113
+ const parentId = canvasState.getNode(id)?.data.parentGroup;
114
+ if (typeof parentId !== 'string') continue;
115
+ const parent = canvasState.getNode(parentId);
116
+ if (parent?.type === 'group' && parent.data.frameMode !== 'manual') targets.add(parentId);
117
+ }
118
+ return [...targets];
119
+ }
120
+
121
+ /** `canvasState.applyUpdates`: group moves translate children unless that child has its own position update. */
122
+ function applyUpdatesTarget(input: Record<string, unknown>): FenceTarget {
123
+ const updates = validCanvasNodeUpdates(input.updates);
124
+ const explicitPositionIds = new Set(
125
+ updates.filter((update) => update.position !== undefined).map((update) => update.id),
126
+ );
127
+ const nodeIds: string[] = [];
128
+ const points: Array<{ x: number; y: number }> = [];
129
+ for (const update of updates) {
130
+ const id = update.id;
131
+ nodeIds.push(id);
132
+ if (!update.position) continue;
133
+ const { x, y } = update.position;
134
+ points.push({ x, y });
135
+ const node = canvasState.getNode(id);
136
+ if (node?.type !== 'group') continue;
137
+ const deltaX = x - node.position.x;
138
+ const deltaY = y - node.position.y;
139
+ for (const childId of stringList(node.data.children)) {
140
+ if (explicitPositionIds.has(childId)) continue;
141
+ nodeIds.push(childId);
142
+ const child = canvasState.getNode(childId);
143
+ if (child) points.push({ x: child.position.x + deltaX, y: child.position.y + deltaY });
144
+ }
145
+ }
146
+ return { nodeIds: includeAutoFrameParents(nodeIds), points };
147
+ }
148
+
106
149
  function createTarget(input: Record<string, unknown>, extraNodeIds: string[] = []): FenceTarget {
107
150
  const x = Number(input.x);
108
151
  const y = Number(input.y);
@@ -118,17 +161,19 @@ export function describeOpTarget(op: Operation, rawInput: unknown): FenceTarget
118
161
  const input = asRecord(rawInput);
119
162
  if (BOARD_WIDE_OPS.has(op.name)) return { boardWide: true };
120
163
  switch (op.name) {
164
+ case 'canvas.apply-updates':
165
+ return applyUpdatesTarget(input);
121
166
  case 'node.update': {
122
- // A group's membership change reparents (and may reposition) its children.
123
- const nodeIds = [str(input.id), ...groupChildren(input)];
124
- // A move's DESTINATION must also land inside the fence — otherwise an
125
- // agent relocates a fenced node outward, and since the fence rect is
126
- // derived from live node positions, that grows its own writable region
127
- // past the human-seeded region.
128
- const x = Number(input.x);
129
- const y = Number(input.y);
130
- if (Number.isFinite(x) && Number.isFinite(y)) return { points: [{ x, y }], nodeIds };
131
- return { nodeIds };
167
+ const id = str(input.id);
168
+ const existing = canvasState.getNode(id);
169
+ const geometry = existing ? resolvePatchGeometry(input, existing) : {};
170
+ // Share the bulk translation rules after resolving PATCH aliases and
171
+ // omitted axes. Check children's destinations as well as the group origin.
172
+ const target = applyUpdatesTarget({ updates: [{ id, ...geometry }] });
173
+ return {
174
+ ...target,
175
+ nodeIds: includeAutoFrameParents([id, ...(target.nodeIds ?? []), ...groupChildren(input)]),
176
+ };
132
177
  }
133
178
  case 'node.remove':
134
179
  return { nodeIds: [str(input.id)] };
@@ -62,8 +62,10 @@ import { getMcpAppHostSnapshot } from './mcp-app-host.js';
62
62
  import { closeMcpAppSession, closeAllMcpAppSessions } from './mcp-app-runtime.js';
63
63
  import { findOpenCanvasPosition } from './placement.js';
64
64
  import { overlapsAny } from '../shared/placement.js';
65
+ import { CANVAS_APP_WRITE_CONTRACT } from '../shared/canvas-app.js';
66
+ import { withoutExternalMcp } from './mcp-app-runtime.js';
65
67
  import { mutationHistory } from './mutation-history.js';
66
- import { buildAgentContextPreamble } from './agent-context.js';
68
+ import { buildAgentContextPreamble } from '../shared/agent-context.js';
67
69
  import { buildCanvasAxSurfaceSnapshot } from './ax-context.js';
68
70
  import { resolveNodeAxCapabilities } from './ax-interaction.js';
69
71
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
@@ -145,20 +147,27 @@ agentPresence.setSessionStartListener((presence) => {
145
147
  const startedAt = new Date().toISOString();
146
148
  return canvasState.saveSnapshot(`Before session · ${presence.label} · ${startedAt.slice(11, 16)}`)?.id ?? null;
147
149
  });
148
- agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
150
+ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, startedAt) => {
149
151
  const ax = canvasState.getAxState();
150
152
  const endedAt = new Date().toISOString();
153
+ const layout = canvasState.getLayout();
151
154
  let snapshot = startSnapshotId
152
155
  ? (canvasState.listSnapshots({ all: true }).find((entry) => entry.id === startSnapshotId) ?? null)
153
156
  : null;
154
- let unchanged = !startSnapshotId && canvasState.getLayout().nodes.length === 0;
157
+ let unchanged = !startSnapshotId && layout.nodes.length === 0;
158
+ // Nodes the session added and changed: the diff against the start snapshot,
159
+ // or every node when the board was empty at attach (no snapshot was taken).
160
+ let addedIds = startSnapshotId ? new Set<string>() : new Set(layout.nodes.map((node) => node.id));
161
+ let modifiedIds = new Set<string>();
155
162
  // A session that changed NOTHING on the board leaves no snapshot behind —
156
163
  // session churn (idle timeouts, receipt tests, attach/detach cycles) was
157
164
  // flooding the History drawer with identical boards.
158
165
  if (snapshot) {
159
166
  const snapData = canvasState.getSnapshotData(snapshot.id);
160
167
  if (snapData) {
161
- const diff = diffLayouts(snapData.name, snapData, canvasState.getLayout());
168
+ const diff = diffLayouts(snapData.name, snapData, layout);
169
+ addedIds = new Set(diff.addedNodes.map((node) => node.id));
170
+ modifiedIds = new Set(diff.modifiedNodes.map((node) => node.id));
162
171
  if (
163
172
  diff.addedNodes.length === 0 &&
164
173
  diff.removedNodes.length === 0 &&
@@ -188,6 +197,10 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
188
197
  rejected: ax.approvalGates.filter((gate) => gate.status === 'rejected').length,
189
198
  held: ax.approvalGates.filter((gate) => gate.status === 'held').length,
190
199
  };
200
+ const context = sessionContextActivity(layout.nodes, startedAt, addedIds, modifiedIds);
201
+ // Reading or pinning is something the human wants to see even when the
202
+ // board itself did not change ("did the agent read what I pinned?").
203
+ if (context.read.length > 0 || context.pinned.length > 0) unchanged = false;
191
204
  // No agent `sessionId` here: the SSE envelope reserves that key for the
192
205
  // workbench session and would overwrite it. The label is what the receipt shows.
193
206
  emitPrimaryWorkbenchEvent('agent-session-ended', {
@@ -198,9 +211,63 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
198
211
  counts,
199
212
  unchanged,
200
213
  snapshot: snapshot ? { id: snapshot.id, name: snapshot.name } : null,
214
+ context,
201
215
  });
202
216
  });
203
217
 
218
+ interface ReceiptNode {
219
+ id: string;
220
+ title: string;
221
+ reason?: string;
222
+ }
223
+
224
+ /**
225
+ * What an agent session did with the board's context (docs/design/AgentContext.dc.html
226
+ * lens and receipt): nodes read since it attached, pins it made, nodes it created and
227
+ * edited, and pinned nodes whose content changed after the latest read.
228
+ */
229
+ function sessionContextActivity(
230
+ nodes: CanvasNodeState[],
231
+ startedAt: string | null,
232
+ addedIds: Set<string>,
233
+ modifiedIds: Set<string>,
234
+ ): {
235
+ read: ReceiptNode[];
236
+ pinned: ReceiptNode[];
237
+ created: ReceiptNode[];
238
+ edited: ReceiptNode[];
239
+ changedSinceRead: ReceiptNode[];
240
+ } {
241
+ const byId = new Map(nodes.map((node) => [node.id, node]));
242
+ const entry = (node: CanvasNodeState): ReceiptNode => ({
243
+ id: node.id,
244
+ title: typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id,
245
+ });
246
+ const boardId = canvasState.activeBoardId;
247
+ const read = startedAt
248
+ ? canvasState.getReadNodeIdsSince(boardId, startedAt).flatMap((id) => {
249
+ const node = byId.get(id);
250
+ return node ? [entry(node)] : [];
251
+ })
252
+ : [];
253
+ const pinned = Object.entries(canvasState.getContextPinMeta()).flatMap(([id, meta]) => {
254
+ const node = byId.get(id);
255
+ if (!node || meta.pinnedBy.actor !== 'agent' || !startedAt || meta.pinnedAt < startedAt) return [];
256
+ return [{ ...entry(node), ...(meta.reason ? { reason: meta.reason } : {}) }];
257
+ });
258
+ 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);
260
+ const readStatus = new Map(canvasState.getNodeReadStatus(boardId).map((status) => [status.nodeId, status]));
261
+ const changedSinceRead = [...canvasState.contextPinnedNodeIds].flatMap((id) => {
262
+ const node = byId.get(id);
263
+ const status = readStatus.get(id);
264
+ // Only pins this session read: the receipt says "changed after <agent> read it".
265
+ const readThisSession = status && startedAt !== null && status.lastReadAt >= startedAt;
266
+ return node && status && readThisSession && (node.contentRevision ?? 0) > status.readRevision ? [entry(node)] : [];
267
+ });
268
+ return { read, pinned, created, edited, changedSinceRead };
269
+ }
270
+
204
271
  // Webview-runner wiring (plan-008 Wave 3): the webview ops never import this
205
272
  // module — the Bun.WebView automation runner is injected here, mirroring the
206
273
  // setOperationEventEmitter pattern. The closures call the real automation
@@ -1181,6 +1248,7 @@ const CANVAS_STATIC_MIME: Record<string, string> = {
1181
1248
  '.json': 'application/json',
1182
1249
  '.map': 'application/json',
1183
1250
  '.wasm': 'application/wasm',
1251
+ '.woff2': 'font/woff2',
1184
1252
  };
1185
1253
 
1186
1254
  const CANVAS_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
@@ -1737,10 +1805,12 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
1737
1805
  const html = await buildJsonRenderViewerHtml({
1738
1806
  title,
1739
1807
  spec,
1808
+ nodeId,
1809
+ stateToken: url.searchParams.get('stateToken') ?? undefined,
1740
1810
  ...(theme ? { theme } : {}),
1741
1811
  ...(display ? { display } : {}),
1742
1812
  ...(devtoolsEnabled ? { devtools: true } : {}),
1743
- ...(axToken ? { nodeId, axToken } : {}),
1813
+ ...(axToken ? { axToken } : {}),
1744
1814
  // Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
1745
1815
  ...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1746
1816
  // Content-fit: report natural height (charts render intrinsic) so the node grows.
@@ -3261,6 +3331,7 @@ export interface CanvasServerOptions {
3261
3331
  workspaceRoot?: string;
3262
3332
  autoOpenBrowser?: boolean;
3263
3333
  allowPortFallback?: boolean;
3334
+ allowExternalMcp?: boolean;
3264
3335
  }
3265
3336
 
3266
3337
  export function startCanvasServer(options: CanvasServerOptions = {}): string | null {
@@ -3279,6 +3350,12 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3279
3350
  primaryWorkbenchAutoOpenEnabled = options.autoOpenBrowser;
3280
3351
  }
3281
3352
 
3353
+ // A recorded agent read changes what the workbench marks as read; tell the
3354
+ // browser only (an MCP notification would make a read cause more reads).
3355
+ canvasState.setContextReadListener((boardId) => {
3356
+ emitPrimaryWorkbenchEvent('context-status-changed', { boardId });
3357
+ });
3358
+
3282
3359
  // Ensure direct HTTP server usage records undo/redo history, not just PmxCanvas.start().
3283
3360
  canvasState.onMutation((info) => {
3284
3361
  mutationHistory.record({
@@ -3302,7 +3379,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3302
3379
  if (loaded) {
3303
3380
  console.log(' Canvas state restored from .pmx-canvas/canvas.db');
3304
3381
  primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
3305
- void syncCanvasRuntimeBackends({ forceRehydrateExtApps: true, alreadyPrimed: true }).finally(() => {
3382
+ const rehydrate = () => syncCanvasRuntimeBackends({ forceRehydrateExtApps: true, alreadyPrimed: true });
3383
+ const pending = options.allowExternalMcp === false ? withoutExternalMcp(rehydrate) : rehydrate();
3384
+ void pending.finally(() => {
3306
3385
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3307
3386
  });
3308
3387
  }
@@ -3359,6 +3438,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3359
3438
  workspace: activeWorkspaceRoot,
3360
3439
  pid: process.pid,
3361
3440
  version: serverPackageVersion(),
3441
+ contracts: [CANVAS_APP_WRITE_CONTRACT],
3362
3442
  persistence: canvasState.persistenceHealth,
3363
3443
  });
3364
3444
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { CanvasAnnotation, CanvasNodeState, CanvasEdge } from './canvas-state.js';
14
- import { summarizeNodeForAgentContext } from './agent-context.js';
14
+ import { summarizeNodeForAgentContext } from '../shared/agent-context.js';
15
15
 
16
16
  // ── Types ────────────────────────────────────────────────────────────
17
17
 
@@ -73,7 +73,7 @@ export interface SpatialContext {
73
73
  // ── Helpers ──────────────────────────────────────────────────────────
74
74
 
75
75
  /** Euclidean distance between two node centers */
76
- function centerDistance(a: CanvasNodeState, b: CanvasNodeState): number {
76
+ export function centerDistance(a: CanvasNodeState, b: CanvasNodeState): number {
77
77
  const ax = a.position.x + a.size.width / 2;
78
78
  const ay = a.position.y + a.size.height / 2;
79
79
  const bx = b.position.x + b.size.width / 2;
@@ -77,7 +77,12 @@ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
77
77
  };
78
78
  cardChildren.push(`${cardId}-detail`);
79
79
  }
80
- elements[cardId] = { type: 'Card', props: { title: item.title }, children: cardChildren };
80
+ // wrap-anywhere: an unbreakable token in a title must wrap, not spill into the next column.
81
+ elements[cardId] = {
82
+ type: 'Card',
83
+ props: { title: item.title, className: 'wrap-anywhere' },
84
+ children: cardChildren,
85
+ };
81
86
  columnChildren.push(cardId);
82
87
  }
83
88
  elements[columnId] = {
@@ -1,5 +1,5 @@
1
- import type { CanvasNodeState } from './canvas-state.js';
2
- import { getCanvasNodeKind } from '../shared/canvas-node-kind.js';
1
+ import type { CanvasNodeState } from '../server/canvas-state.js';
2
+ import { getCanvasNodeKind } from './canvas-node-kind.js';
3
3
 
4
4
  const DEFAULT_CONTEXT_TEXT_LENGTH = 700;
5
5
  const DEFAULT_WEBPAGE_CONTEXT_TEXT_LENGTH = 1600;