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
@@ -0,0 +1,211 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { listOperations, OperationError } from '../server/operations/index.js';
4
+ import { matchOperationPath, readOperationInput } from '../server/operations/http.js';
5
+ import type { CanvasAccess } from './canvas-access.js';
6
+
7
+ // Explicitly bounded to workbench operations. No shell, automation, arbitrary
8
+ // HTTP proxy, external MCP tool calls, or caller-supplied authority headers.
9
+ const writes = new Set([
10
+ 'board.create',
11
+ 'board.open',
12
+ 'board.update',
13
+ 'board.create-from',
14
+ 'board.delete',
15
+ 'node.add',
16
+ 'jsonrender.add',
17
+ 'graph.add',
18
+ 'node.update',
19
+ 'node.remove',
20
+ 'node.refresh',
21
+ 'edge.add',
22
+ 'edge.update',
23
+ 'edge.remove',
24
+ 'group.create',
25
+ 'group.add',
26
+ 'group.remove',
27
+ 'pin.set',
28
+ 'annotation.add',
29
+ 'annotation.remove',
30
+ 'theme.set',
31
+ 'viewport.set',
32
+ 'canvas.apply-updates',
33
+ 'canvas.clear',
34
+ 'arrange',
35
+ 'node.focus',
36
+ 'view.fit',
37
+ 'snapshot.save',
38
+ 'snapshot.restore',
39
+ 'snapshot.delete',
40
+ 'tour.set',
41
+ 'ax.focus.set',
42
+ 'ax.policy.set',
43
+ 'ax.work.create',
44
+ 'ax.work.update',
45
+ 'ax.review.add',
46
+ 'ax.review.update',
47
+ 'ax.elicitation.respond',
48
+ 'ax.mode.resolve',
49
+ 'ax.steer',
50
+ 'ax.event.record',
51
+ 'ax.presence.set',
52
+ 'ax.interaction.submit',
53
+ 'intent.clear',
54
+ 'import.request',
55
+ 'import.cancel',
56
+ 'import.commit',
57
+ 'export.run',
58
+ ]);
59
+
60
+ function readableAsset(path: string): boolean {
61
+ return (
62
+ [
63
+ '/health',
64
+ '/api/workbench/poll',
65
+ '/api/workbench/state',
66
+ '/api/file',
67
+ '/api/canvas/json-render/view',
68
+ '/api/canvas/file-bytes',
69
+ ].includes(path) ||
70
+ /^\/api\/canvas\/(surface|image|frame-documents|exports)\/[^/]+$/.test(path) ||
71
+ /^\/api\/canvas\/attachments\/[^/]+\/bytes$/.test(path)
72
+ );
73
+ }
74
+
75
+ /** HTTP-shaped replies let the real workbench retain its existing API contracts. */
76
+ export function registerWorkbenchApp(server: McpServer, ensureCanvas: () => Promise<CanvasAccess>): void {
77
+ server.registerTool(
78
+ 'pmx_workbench_request',
79
+ {
80
+ title: 'PMX workbench transport',
81
+ description: 'App-only transport for the PMX workbench. Not an assistant tool.',
82
+ inputSchema: {
83
+ path: z.string().max(8192),
84
+ method: z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD']),
85
+ body: z.string().max(4_000_000).optional(),
86
+ expectedBoardId: z.string().nullable(),
87
+ },
88
+ // Workbench webpage/image actions can fetch user-chosen external URLs.
89
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
90
+ _meta: { ui: { visibility: ['app'] } },
91
+ },
92
+ async (input) => {
93
+ const reply = (status: number, body: string, contentType = 'application/json', encoding = 'text') => ({
94
+ content: [
95
+ { type: 'text' as const, text: status < 400 ? 'Workbench request completed.' : 'Workbench request refused.' },
96
+ ],
97
+ _meta: { response: { status, body, contentType, encoding } },
98
+ });
99
+ try {
100
+ const canvas = await ensureCanvas();
101
+ const base = canvas.remoteBaseUrl ?? `http://127.0.0.1:${canvas.port}`;
102
+ const url = new URL(input.path, base);
103
+ if (
104
+ !input.path.startsWith('/') ||
105
+ input.path.startsWith('//') ||
106
+ input.path.includes('\\') ||
107
+ url.origin !== new URL(base).origin ||
108
+ url.username ||
109
+ url.password ||
110
+ url.hash
111
+ ) {
112
+ throw new OperationError('Only local PMX workbench paths are allowed.', 403);
113
+ }
114
+ const req = new Request(url, {
115
+ method: input.method,
116
+ headers: { 'Content-Type': 'application/json' },
117
+ ...(input.body === undefined ? {} : { body: input.body }),
118
+ });
119
+ for (const op of listOperations()) {
120
+ const route = op.http;
121
+ if (!route || route.method !== input.method) continue;
122
+ const params = matchOperationPath(route.path, url.pathname);
123
+ if (!params) continue;
124
+ if (!url.pathname.startsWith('/api/canvas/') || (input.method !== 'GET' && !writes.has(op.name)))
125
+ throw new OperationError('This action requires the local PMX workbench.', 403);
126
+ const args = route.readInput
127
+ ? await route.readInput(req, params, url)
128
+ : await readOperationInput(req, params, url);
129
+ if (input.method !== 'GET') args.expectedBoardId = input.expectedBoardId;
130
+ // This profile is one agent transport, never browser/system authority
131
+ // or a caller-selected writer whose wider territory could bypass a fence.
132
+ args.source = 'mcp';
133
+ delete args.agentId;
134
+ if (
135
+ op.name === 'ax.interaction.submit' &&
136
+ (args.type === 'ax.approval.resolve' || args.type === 'ax.flow.materialize')
137
+ )
138
+ throw new OperationError('Resolve approvals and materialize flows in the local PMX workbench.', 403);
139
+ if (op.name === 'node.add' || op.name === 'node.update') {
140
+ const data = args.data;
141
+ const nodeType = op.name === 'node.add' ? args.type : (await canvas.getNode(String(args.id)))?.type;
142
+ // Trace tool names describe past activity; they never launch a backend.
143
+ const launchFields = ['transportConfig', 'toolInput', 'serverName', 'appSessionId'];
144
+ if (nodeType !== 'trace') launchFields.push('toolName');
145
+ if (
146
+ (op.name === 'node.add' && args.type === 'mcp-app') ||
147
+ (data && typeof data === 'object' && launchFields.some((field) => field in data))
148
+ )
149
+ throw new OperationError('Configure external MCP apps in the local PMX workbench.', 403);
150
+ }
151
+ if (
152
+ op.name === 'node.update' &&
153
+ ('content' in args || (args.data && typeof args.data === 'object' && 'content' in args.data))
154
+ ) {
155
+ if (typeof args.expectedContentRevision !== 'number')
156
+ throw new OperationError('Read the note before editing: a content revision is required.', 409);
157
+ const node = await canvas.getNode(String(args.id));
158
+ if (node?.data.path) throw new OperationError('Edit file-linked content in the local PMX workbench.', 403);
159
+ }
160
+ const result = await canvas.invoker().invoke(op.name, args);
161
+ return reply(route.status ? route.status(result) : 200, JSON.stringify(result));
162
+ }
163
+ if (
164
+ !((input.method === 'GET' || input.method === 'HEAD') && readableAsset(url.pathname)) &&
165
+ !(input.method === 'POST' && url.pathname === '/api/render')
166
+ ) {
167
+ throw new OperationError('This action requires the local PMX workbench.', 403);
168
+ }
169
+ // Resolve only PMX's internal surface redirects. Never follow to arbitrary
170
+ // hosts or return the workbench HTML (which carries its human boot token).
171
+ let target = url;
172
+ let response: Response | undefined;
173
+ for (let redirects = 0; redirects < 3; redirects++) {
174
+ response = await fetch(target, {
175
+ method: input.method,
176
+ body: input.body,
177
+ headers: { 'Content-Type': 'application/json' },
178
+ redirect: 'manual',
179
+ signal: AbortSignal.timeout(15_000),
180
+ });
181
+ if (response.status < 300 || response.status >= 400) break;
182
+ const next = new URL(response.headers.get('location') ?? '', target);
183
+ if (next.origin !== url.origin || !readableAsset(next.pathname))
184
+ throw new OperationError('External surfaces must be opened outside the embedded workbench.', 403);
185
+ target = next;
186
+ }
187
+ if (!response || (response.status >= 300 && response.status < 400))
188
+ throw new OperationError('Could not resolve the PMX surface.');
189
+ const bytes = await response.arrayBuffer();
190
+ if (bytes.byteLength > 24 * 1024 * 1024)
191
+ throw new OperationError('This asset is too large for the MCP connection.');
192
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
193
+ const text = /^(text\/|application\/(json|javascript))/.test(contentType);
194
+ return reply(
195
+ response.status,
196
+ text ? new TextDecoder().decode(bytes) : Buffer.from(bytes).toString('base64'),
197
+ contentType,
198
+ text ? 'text' : 'base64',
199
+ );
200
+ } catch (error) {
201
+ if (error instanceof OperationError)
202
+ return reply(error.status, JSON.stringify({ ok: false, error: error.message }));
203
+ console.error('[workbench-app] request failed', error);
204
+ return reply(
205
+ 502,
206
+ JSON.stringify({ ok: false, error: 'PMX workbench request failed. Check the daemon and retry.' }),
207
+ );
208
+ }
209
+ },
210
+ );
211
+ }
@@ -32,7 +32,7 @@ import {
32
32
  PRESENCE_TOOLING_SETTLE_MS,
33
33
  TRANSPORT_SOURCES,
34
34
  } from '../shared/agent-presence.js';
35
- import { serializeNodeForAgentContext } from './agent-context.js';
35
+ import { serializeNodeForAgentContext } from '../shared/agent-context.js';
36
36
  import type { PmxAxActivityKind } from './ax-state.js';
37
37
  import { type CanvasNodeState, canvasState } from './canvas-state.js';
38
38
  import { OperationError } from './operations/types.js';
@@ -205,6 +205,8 @@ interface StoredPresence extends AgentPresence {
205
205
  lastActivityNodeId: string | null;
206
206
  /** Snapshot of the board taken when this session attached (the receipt diffs against it). */
207
207
  startSnapshotId: string | null;
208
+ /** When this session attached (ISO); the receipt counts reads and pins from here. */
209
+ startedAt: string | null;
208
210
  }
209
211
 
210
212
  function presenceKey(source: string, agentId: string | null | undefined): string {
@@ -230,7 +232,12 @@ export function estimateContextBudget(): ContextBudget {
230
232
  /** Returns the id of the pre-session snapshot the server took, if any. */
231
233
  type SessionStartListener = (presence: AgentPresence) => string | null;
232
234
  export type SessionEndReason = 'human' | 'agent' | 'idle-timeout';
233
- type SessionEndListener = (presence: AgentPresence, startSnapshotId: string | null, endedBy: SessionEndReason) => void;
235
+ type SessionEndListener = (
236
+ presence: AgentPresence,
237
+ startSnapshotId: string | null,
238
+ endedBy: SessionEndReason,
239
+ startedAt: string | null,
240
+ ) => void;
234
241
 
235
242
  export class AgentPresenceRegistry {
236
243
  private readonly presences = new Map<string, StoredPresence>();
@@ -392,6 +399,7 @@ export class AgentPresenceRegistry {
392
399
  phaseSetMs: now,
393
400
  toolingUntilMs: null,
394
401
  startSnapshotId: null,
402
+ startedAt: null,
395
403
  };
396
404
  const wasAttached = stored.attached;
397
405
  stored.lastSeenMs = now;
@@ -448,6 +456,7 @@ export class AgentPresenceRegistry {
448
456
  // moment stays the receipt baseline via the inherited snapshot. A
449
457
  // placeholder that already adopted work is a real session and stays.
450
458
  let inheritedSnapshotId: string | null = null;
459
+ let inheritedStartedAt: string | null = null;
451
460
  if (stored.source !== 'browser') {
452
461
  const placeholder = [...this.presences.values()].find(
453
462
  (presence) =>
@@ -463,13 +472,20 @@ export class AgentPresenceRegistry {
463
472
  if (target === placeholder.sessionId) this.aliases.set(channel, key);
464
473
  }
465
474
  inheritedSnapshotId = placeholder.startSnapshotId;
475
+ inheritedStartedAt = placeholder.startedAt;
466
476
  }
467
477
  }
468
478
  stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
479
+ stored.startedAt = inheritedStartedAt ?? new Date(now).toISOString();
469
480
  }
470
481
  if (input.attached === false) {
471
482
  if (wasAttached)
472
- this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
483
+ this.onSessionEnd(
484
+ this.publicView(stored, now),
485
+ stored.startSnapshotId,
486
+ input.endedBy ?? 'agent',
487
+ stored.startedAt,
488
+ );
473
489
  // An explicit detach always removes the presence. In particular, a
474
490
  // worker that reports attached:false must not be refreshed into another
475
491
  // full activity TTL as an external writer.
@@ -481,7 +497,24 @@ export class AgentPresenceRegistry {
481
497
  }
482
498
  if (input.focusNodeId !== undefined) stored.focusNodeId = input.focusNodeId;
483
499
  if (input.parentAgentId !== undefined) stored.parentAgentId = input.parentAgentId;
484
- if (input.cursor !== undefined) stored.cursor = input.cursor;
500
+ if (input.cursor !== undefined) {
501
+ // A MOVING cursor is the agent visibly working: it keeps a live explicit
502
+ // `tooling` from settling (a host streaming cursor moves after one
503
+ // `tooling` report read as Idle within seconds). A stationary heartbeat
504
+ // is not activity, and `thinking` keeps its own longer clock (below).
505
+ const moved =
506
+ input.cursor !== null && (stored.cursor?.x !== input.cursor.x || stored.cursor?.y !== input.cursor.y);
507
+ if (
508
+ moved &&
509
+ input.phase === undefined &&
510
+ stored.phase === 'tooling' &&
511
+ stored.toolingUntilMs !== null &&
512
+ stored.toolingUntilMs > now
513
+ ) {
514
+ stored.toolingUntilMs = now + PRESENCE_TOOLING_SETTLE_MS;
515
+ }
516
+ stored.cursor = input.cursor;
517
+ }
485
518
  if (input.contextUsage !== undefined) stored.contextUsage = input.contextUsage;
486
519
  if (input.phase === 'tooling' && input.derivedTooling) {
487
520
  // Copilot's finding: an op-derived tooling touch used to CLOBBER the
@@ -585,7 +618,8 @@ export class AgentPresenceRegistry {
585
618
  const removed = this.presences.delete(sessionId);
586
619
  if (removed) {
587
620
  this.dropAliasesTo(sessionId);
588
- if (stored?.attached) this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent');
621
+ if (stored?.attached)
622
+ this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent', stored.startedAt);
589
623
  this.scheduleEmit();
590
624
  }
591
625
  this.maybeStopSweeper();
@@ -700,7 +734,12 @@ export class AgentPresenceRegistry {
700
734
  this.presences.delete(key);
701
735
  this.dropAliasesTo(key);
702
736
  if (presence.attached)
703
- this.onSessionEnd(this.publicView(presence, now), presence.startSnapshotId, 'idle-timeout');
737
+ this.onSessionEnd(
738
+ this.publicView(presence, now),
739
+ presence.startSnapshotId,
740
+ 'idle-timeout',
741
+ presence.startedAt,
742
+ );
704
743
  changed = true;
705
744
  continue;
706
745
  }
@@ -1,4 +1,4 @@
1
- import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
1
+ import { buildAgentContextPreamble, serializeNodeForAgentContext } from '../shared/agent-context.js';
2
2
  import {
3
3
  type PmxAxHumanAnnotation,
4
4
  buildAxContext,
@@ -1,7 +1,7 @@
1
1
  import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
3
3
  import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
4
- import type { AgentContextNode } from './agent-context.js';
4
+ import type { AgentContextNode } from '../shared/agent-context.js';
5
5
  import type { ActorAttribution } from './attribution.js';
6
6
 
7
7
  export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
@@ -208,6 +208,8 @@ async function collect(
208
208
  if (!read) return null;
209
209
  const { board, state } = read;
210
210
  const theme = canvasThemeScheme(state.theme ?? canvasState.theme);
211
+ // Exports collapse to a scheme; surface documents take the theme that draws it.
212
+ const surfaceTheme = theme === 'light' ? 'daylight' : 'harbor';
211
213
  const remoteImages = new Set<string>();
212
214
  const links = new Set<string>();
213
215
  const frameNetworkDestinations = new Set<string>();
@@ -383,7 +385,8 @@ async function collect(
383
385
  const html = text(data.html) || text(data.content);
384
386
  collectFrameNetworkDestinations(html, frameNetworkDestinations);
385
387
  manifest.frames += 1;
386
- if (withFrames) card.frame = buildHtmlSurfaceDocument(html, { theme, title, inlineThemeCss: themeCss });
388
+ if (withFrames)
389
+ card.frame = buildHtmlSurfaceDocument(html, { theme: surfaceTheme, title, inlineThemeCss: themeCss });
387
390
  break;
388
391
  }
389
392
  case 'mermaid':
@@ -395,7 +398,7 @@ async function collect(
395
398
  '<script src="/canvas/mermaid-entry.js"></script>',
396
399
  `<script src="${MERMAID_SRC}"></script>`,
397
400
  ),
398
- { theme, title, inlineThemeCss: themeCss },
401
+ { theme: surfaceTheme, title, inlineThemeCss: themeCss },
399
402
  );
400
403
  }
401
404
  break;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { Database } from 'bun:sqlite';
9
+ import type { ActorAttribution } from './attribution.js';
9
10
  import { CONTEXT_READS_SCHEMA_SQL } from './context-reads.js';
10
11
  import { MIGRATED_SNAPSHOT_CATEGORY, normalizeBoardCategory } from '../shared/boards.js';
11
12
  import { tourSchema, type Tour } from '../shared/tour.js';
@@ -48,7 +49,7 @@ const SCHEMA_VERSION = 2;
48
49
 
49
50
  export type CanvasTheme = CanvasThemeName;
50
51
 
51
- export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'dark'): CanvasTheme {
52
+ export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'harbor'): CanvasTheme {
52
53
  return normalizeCanvasThemeName(value, fallback);
53
54
  }
54
55
 
@@ -122,6 +123,7 @@ const SCHEMA_SQL = `
122
123
  CREATE TABLE IF NOT EXISTS context_pins (
123
124
  board_id TEXT NOT NULL,
124
125
  node_id TEXT NOT NULL,
126
+ meta TEXT,
125
127
  PRIMARY KEY (board_id, node_id)
126
128
  );
127
129
 
@@ -185,6 +187,7 @@ const SCHEMA_SQL = `
185
187
  CREATE TABLE IF NOT EXISTS snapshot_pins (
186
188
  snapshot_id TEXT NOT NULL,
187
189
  node_id TEXT NOT NULL,
190
+ meta TEXT,
188
191
  PRIMARY KEY (snapshot_id, node_id)
189
192
  );
190
193
 
@@ -335,10 +338,29 @@ export interface PersistedCanvasState {
335
338
  edges: CanvasEdge[];
336
339
  annotations?: CanvasAnnotation[];
337
340
  contextPins: string[];
341
+ /** Who pinned each context pin, when and why; pins without an entry have an unknown pinner. */
342
+ contextPinMeta?: Record<string, ContextPinMeta>;
338
343
  ax?: PmxAxState;
339
344
  revisionState?: PersistedRevisionState;
340
345
  }
341
346
 
347
+ export interface ContextPinMeta {
348
+ pinnedBy: ActorAttribution;
349
+ pinnedAt: string;
350
+ reason?: string;
351
+ }
352
+
353
+ function pinMetaJson(state: PersistedCanvasState, nodeId: string): string | null {
354
+ const meta = state.contextPinMeta?.[nodeId];
355
+ return meta ? JSON.stringify(meta) : null;
356
+ }
357
+
358
+ function pinMetaFromRows(rows: Array<{ node_id: string; meta: string | null }>): Record<string, ContextPinMeta> {
359
+ const meta: Record<string, ContextPinMeta> = {};
360
+ for (const row of rows) if (row.meta) meta[row.node_id] = JSON.parse(row.meta) as ContextPinMeta;
361
+ return meta;
362
+ }
363
+
342
364
  export interface PersistedRevisionState {
343
365
  revision: number;
344
366
  floor: number;
@@ -400,6 +422,9 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
400
422
  ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
401
423
  ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
402
424
  ensureColumn(db, 'context_reads', 'board_id', 'board_id TEXT');
425
+ ensureColumn(db, 'context_reads', 'read_nodes', "read_nodes TEXT NOT NULL DEFAULT '{}'");
426
+ ensureColumn(db, 'context_pins', 'meta', 'meta TEXT');
427
+ ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
403
428
  ensureColumn(db, 'boards', 'category', 'category TEXT');
404
429
  ensureColumn(db, 'boards', 'readme_node_id', 'readme_node_id TEXT');
405
430
  ensureColumn(db, 'nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
@@ -470,6 +495,7 @@ function migrateToBoards(db: Database, dbPath: string, boardName: string): void
470
495
  if (existing.has(table)) db.exec(`ALTER TABLE ${table} RENAME TO ${table}_v1`);
471
496
  }
472
497
  if (existing.has('snapshots')) ensureColumn(db, 'snapshots', 'board_id', 'board_id TEXT');
498
+ if (existing.has('snapshot_pins')) ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
473
499
  db.exec(SCHEMA_SQL);
474
500
 
475
501
  if (hasBoard) {
@@ -552,10 +578,10 @@ function snapshotsToBoards(db: Database): void {
552
578
  `INSERT INTO annotations (board_id, ${annotationColumns}) SELECT ?, ${annotationColumns} FROM snapshot_annotations WHERE snapshot_id = ?`,
553
579
  [boardId, snapshot.id],
554
580
  );
555
- db.run('INSERT INTO context_pins (board_id, node_id) SELECT ?, node_id FROM snapshot_pins WHERE snapshot_id = ?', [
556
- boardId,
557
- snapshot.id,
558
- ]);
581
+ db.run(
582
+ 'INSERT INTO context_pins (board_id, node_id, meta) SELECT ?, node_id, meta FROM snapshot_pins WHERE snapshot_id = ?',
583
+ [boardId, snapshot.id],
584
+ );
559
585
  const ax = meta.get('ax_state');
560
586
  if (ax) db.run("INSERT INTO ax_state (board_id, key, value) VALUES (?, 'state', ?)", [boardId, ax]);
561
587
  deleteSnapshotFromDB(db, snapshot.id);
@@ -775,10 +801,12 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
775
801
  }
776
802
  deleteMissingRows(db, 'annotations', 'id', boardId, new Set(annotations.map((annotation) => annotation.id)));
777
803
 
778
- // Save context pins (node_id is the whole row — nothing to update)
779
- const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (board_id, node_id) VALUES (?, ?)');
804
+ // Save context pins with who pinned them
805
+ const insertPin = db.prepare(
806
+ 'INSERT INTO context_pins (board_id, node_id, meta) VALUES (?, ?, ?) ON CONFLICT(board_id, node_id) DO UPDATE SET meta = excluded.meta WHERE context_pins.meta IS NOT excluded.meta',
807
+ );
780
808
  for (const pinId of state.contextPins) {
781
- insertPin.run(boardId, pinId);
809
+ insertPin.run(boardId, pinId, pinMetaJson(state, pinId));
782
810
  }
783
811
  deleteMissingRows(db, 'context_pins', 'node_id', boardId, new Set(state.contextPins));
784
812
 
@@ -905,9 +933,11 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
905
933
  // Load context pins
906
934
  interface PinRow {
907
935
  node_id: string;
936
+ meta: string | null;
908
937
  }
909
- const pinRows = db.query<PinRow, [string]>('SELECT node_id FROM context_pins WHERE board_id = ?').all(id);
938
+ const pinRows = db.query<PinRow, [string]>('SELECT node_id, meta FROM context_pins WHERE board_id = ?').all(id);
910
939
  const contextPins = pinRows.map((row) => row.node_id);
940
+ const contextPinMeta = pinMetaFromRows(pinRows);
911
941
 
912
942
  const axRow = db
913
943
  .query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
@@ -925,6 +955,7 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
925
955
  edges,
926
956
  annotations,
927
957
  contextPins,
958
+ contextPinMeta,
928
959
  ax: parsePersistedAxState(axRow?.value),
929
960
  revisionState: revisionRaw ? (JSON.parse(revisionRaw) as PersistedRevisionState) : undefined,
930
961
  };
@@ -1191,9 +1222,9 @@ export function saveSnapshotToDB(
1191
1222
  }
1192
1223
 
1193
1224
  // Insert snapshot pins
1194
- const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id) VALUES (?, ?)');
1225
+ const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id, meta) VALUES (?, ?, ?)');
1195
1226
  for (const pinId of state.contextPins) {
1196
- insertPin.run(snapshot.id, pinId);
1227
+ insertPin.run(snapshot.id, pinId, pinMetaJson(state, pinId));
1197
1228
  }
1198
1229
  db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
1199
1230
  snapshot.id,
@@ -1348,11 +1379,13 @@ export function loadSnapshotFromDB(
1348
1379
  // Load snapshot pins
1349
1380
  interface PinRow {
1350
1381
  node_id: string;
1382
+ meta: string | null;
1351
1383
  }
1352
1384
  const pinRows = db
1353
- .query<PinRow, [string]>('SELECT node_id FROM snapshot_pins WHERE snapshot_id = ?')
1385
+ .query<PinRow, [string]>('SELECT node_id, meta FROM snapshot_pins WHERE snapshot_id = ?')
1354
1386
  .all(snapshotRow.id);
1355
1387
  const contextPins = pinRows.map((row) => row.node_id);
1388
+ const contextPinMeta = pinMetaFromRows(pinRows);
1356
1389
 
1357
1390
  return {
1358
1391
  snapshot,
@@ -1364,6 +1397,7 @@ export function loadSnapshotFromDB(
1364
1397
  edges,
1365
1398
  annotations,
1366
1399
  contextPins,
1400
+ contextPinMeta,
1367
1401
  ax: parsePersistedAxState(metaMap.get('ax_state')),
1368
1402
  revisionState: metaMap.has('revision_state')
1369
1403
  ? (JSON.parse(metaMap.get('revision_state')!) as PersistedRevisionState)
@@ -0,0 +1,64 @@
1
+ import type { CanvasNodeState, CanvasNodeUpdate } from './canvas-state.js';
2
+
3
+ function record(value: unknown): Record<string, unknown> {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
5
+ }
6
+
7
+ function finite(value: unknown): number | undefined {
8
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
9
+ }
10
+
11
+ /** Flat fields take precedence over nested aliases; absent axes remain absent. */
12
+ export function resolveCreateGeometry(body: Record<string, unknown>) {
13
+ const position = record(body.position);
14
+ const size = record(body.size);
15
+ return {
16
+ x: finite(body.x) ?? finite(position.x),
17
+ y: finite(body.y) ?? finite(position.y),
18
+ width: finite(body.width) ?? finite(size.width),
19
+ height: finite(body.height) ?? finite(size.height),
20
+ };
21
+ }
22
+
23
+ /** Guards and PATCH execution must resolve aliases and single-axis moves identically. */
24
+ export function resolvePatchGeometry(body: Record<string, unknown>, existing: CanvasNodeState) {
25
+ const { x, y, width, height } = resolveCreateGeometry(body);
26
+ return {
27
+ ...(x !== undefined || y !== undefined
28
+ ? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
29
+ : {}),
30
+ ...(width !== undefined || height !== undefined
31
+ ? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
32
+ : {}),
33
+ };
34
+ }
35
+
36
+ export function validateCanvasNodePatch(patch: { position?: unknown; size?: unknown }): string | null {
37
+ if (patch.position !== undefined) {
38
+ const position = record(patch.position);
39
+ if (finite(position.x) === undefined || finite(position.y) === undefined) {
40
+ return 'Position must contain finite x and y values.';
41
+ }
42
+ }
43
+ if (patch.size !== undefined) {
44
+ const size = record(patch.size);
45
+ const width = finite(size.width);
46
+ const height = finite(size.height);
47
+ if (width === undefined || height === undefined) return 'Size must contain finite width and height values.';
48
+ if (width <= 0 || height <= 0) return 'Size width and height must be greater than zero.';
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** Invalid patches cannot count as explicit child-position overrides. */
54
+ export function validCanvasNodeUpdates(input: unknown): CanvasNodeUpdate[] {
55
+ if (!Array.isArray(input)) return [];
56
+ return input.filter((value: unknown): value is CanvasNodeUpdate => {
57
+ const update = record(value);
58
+ return (
59
+ typeof update.id === 'string' &&
60
+ (update.collapsed === undefined || typeof update.collapsed === 'boolean') &&
61
+ validateCanvasNodePatch(update) === null
62
+ );
63
+ });
64
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { mutationHistory } from './mutation-history.js';
24
24
  import { intentRegistry } from './intent-registry.js';
25
25
  import { clampCreateNodeSize } from './canvas-validation.js';
26
+ import { validCanvasNodeUpdates } from './canvas-geometry.js';
26
27
  import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
27
28
  import { searchNodes } from './spatial-analysis.js';
28
29
  import { getCanvasNodeTitle } from './canvas-serialization.js';
@@ -730,26 +731,6 @@ export async function syncCanvasRuntimeBackends(
730
731
  return { rehydrated, failed };
731
732
  }
732
733
 
733
- export function validateCanvasNodePatch(patch: {
734
- position?: { x: number; y: number };
735
- size?: { width: number; height: number };
736
- }): string | null {
737
- if (patch.position) {
738
- if (!Number.isFinite(patch.position.x) || !Number.isFinite(patch.position.y)) {
739
- return 'Position must contain finite x and y values.';
740
- }
741
- }
742
- if (patch.size) {
743
- if (!Number.isFinite(patch.size.width) || !Number.isFinite(patch.size.height)) {
744
- return 'Size must contain finite width and height values.';
745
- }
746
- if (patch.size.width <= 0 || patch.size.height <= 0) {
747
- return 'Size width and height must be greater than zero.';
748
- }
749
- }
750
- return null;
751
- }
752
-
753
734
  let codeGraphTimer: ReturnType<typeof setTimeout> | null = null;
754
735
 
755
736
  function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
@@ -1406,22 +1387,23 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
1406
1387
  }
1407
1388
 
1408
1389
  export function applyCanvasNodeUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
1409
- const safe = updates.filter((update) => validateCanvasNodePatch(update) === null);
1410
- return canvasState.applyUpdates(safe);
1390
+ return canvasState.applyUpdates(validCanvasNodeUpdates(updates));
1411
1391
  }
1412
1392
 
1413
1393
  export function setCanvasContextPins(
1414
1394
  nodeIds: string[],
1415
1395
  mode: CanvasPinMode = 'set',
1396
+ reason?: string,
1416
1397
  ): { count: number; nodeIds: string[] } {
1417
1398
  const normalizePins = (ids: string[]): string[] =>
1418
1399
  ids.filter((id, index) => ids.indexOf(id) === index).slice(0, MAX_CONTEXT_PINS);
1419
1400
  const normalizedNodeIds = normalizePins(nodeIds);
1401
+ const options = reason ? { reason } : {};
1420
1402
  if (mode === 'set') {
1421
- canvasState.setContextPins(normalizedNodeIds);
1403
+ canvasState.setContextPins(normalizedNodeIds, options);
1422
1404
  } else if (mode === 'add') {
1423
1405
  const current = Array.from(canvasState.contextPinnedNodeIds);
1424
- canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]));
1406
+ canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]), options);
1425
1407
  } else {
1426
1408
  const current = Array.from(canvasState.contextPinnedNodeIds);
1427
1409
  canvasState.setContextPins(current.filter((id) => !normalizedNodeIds.includes(id)));