pmx-canvas 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -23,13 +23,22 @@ import { setCanvasContextPins, syncCanvasRuntimeBackends } from '../../canvas-op
23
23
  import { buildCanvasSummary } from '../../canvas-serialization.js';
24
24
  import { mutationHistory } from '../../mutation-history.js';
25
25
  import { buildSpatialContext, searchNodes } from '../../spatial-analysis.js';
26
- import { defineOperation, type Operation, type OperationMcpToolHost } from '../types.js';
27
- import { isRecord } from './nodes.js';
26
+ import {
27
+ compileContextBrief,
28
+ type ContextBriefLibraryBoard,
29
+ type ContextBriefSourceEntry,
30
+ } from '../../context-brief.js';
31
+ import { summarizeNodeForAgentContext } from '../../agent-context.js';
32
+ import { defineOperation, OperationError, type Operation, type OperationMcpToolHost } from '../types.js';
33
+ import { buildSummaryFromLayout, isRecord } from './nodes.js';
34
+ import { readTargetBoard } from './boards.js';
28
35
 
29
36
  // ── pin.set ───────────────────────────────────────────────────
30
37
 
31
38
  /** Legacy server.ts handleContextPinsUpdate capped the requested list at 20. */
32
39
  const MAX_PINS = 20;
40
+ export const DEFAULT_CONTEXT_BRIEF_BUDGET = 16_000;
41
+ export const MAX_CONTEXT_BRIEF_BUDGET = 100_000;
33
42
 
34
43
  const pinShape = {
35
44
  nodeIds: z.unknown().optional().describe('Array of node IDs to pin'),
@@ -92,6 +101,7 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
92
101
 
93
102
  const searchShape = {
94
103
  q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
104
+ scope: z.unknown().optional().describe('active (default) or library (all boards)'),
95
105
  limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
96
106
  };
97
107
 
@@ -113,12 +123,14 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
113
123
  extraShape: {
114
124
  query: z.string().describe('Search query — matches against node titles, content, and file paths'),
115
125
  limit: z.number().optional().describe('Max results to return (default: 10)'),
126
+ scope: z.enum(['active', 'library']).optional().describe('Search the active board or the full board library'),
116
127
  },
117
128
  // Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
118
129
  // `limit` on every transport; the MCP tool additionally defaults it to 10.
119
130
  buildInput: (input) => ({
120
131
  q: typeof input.query === 'string' ? input.query : '',
121
132
  ...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
133
+ ...(input.scope === 'library' ? { scope: 'library' } : {}),
122
134
  }),
123
135
  formatResult: (result, input) => {
124
136
  const body = isRecord(result) ? result : {};
@@ -154,7 +166,40 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
154
166
  : typeof rawLimit === 'string' && rawLimit.trim() !== ''
155
167
  ? Number(rawLimit)
156
168
  : Number.NaN;
157
- const results = searchNodes(canvasState.getLayout().nodes, q);
169
+ const library = input.scope === 'library';
170
+ const results = library
171
+ ? canvasState.listBoards().flatMap((board) => {
172
+ const read = canvasState.readBoard(board.id, true);
173
+ if (!read) return [];
174
+ const cards = searchNodes(read.layout.nodes, q).map((result) => ({
175
+ ...result,
176
+ boardId: board.id,
177
+ boardTitle: board.name,
178
+ cardId: result.id,
179
+ }));
180
+ const metadata = `${board.name} ${board.category ?? ''}`.toLocaleLowerCase();
181
+ const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
182
+ const readmeText = typeof readme?.data.content === 'string' ? readme.data.content : '';
183
+ if (`${metadata} ${readmeText.toLocaleLowerCase()}`.includes(q.toLocaleLowerCase())) {
184
+ cards.unshift({
185
+ id: board.id,
186
+ type: 'board',
187
+ title: board.name,
188
+ snippet: readmeText.slice(0, 160) || board.category || board.name,
189
+ score: metadata.includes(q.toLocaleLowerCase()) ? 4 : 2,
190
+ boardId: board.id,
191
+ boardTitle: board.name,
192
+ cardId: readme?.id ?? board.id,
193
+ });
194
+ }
195
+ return cards;
196
+ })
197
+ : searchNodes(canvasState.getLayout().nodes, q).map((result) => ({
198
+ ...result,
199
+ boardId: canvasState.activeBoardId,
200
+ cardId: result.id,
201
+ }));
202
+ results.sort((a, b) => b.score - a.score);
158
203
  return {
159
204
  results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
160
205
  query: q,
@@ -162,6 +207,152 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
162
207
  },
163
208
  });
164
209
 
210
+ // ── context.get ───────────────────────────────────────────────
211
+
212
+ const contextShape = {
213
+ consumer: z.unknown().optional().describe('Stable consumer id used for the durable board-scoped cursor'),
214
+ since: z.unknown().optional().describe('Explicit content revision; bypasses the durable consumer cursor'),
215
+ budget: z.unknown().optional().describe('Maximum response size in UTF-16 code units'),
216
+ };
217
+ const contextSchema = z.looseObject(contextShape);
218
+
219
+ const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<string, unknown>>({
220
+ name: 'context.get',
221
+ mutates: false,
222
+ input: contextSchema,
223
+ inputShape: contextShape,
224
+ http: { method: 'GET', path: '/api/canvas/context' },
225
+ handler: (input) => {
226
+ const active = canvasState.getActiveBoard();
227
+ if (!active) throw new OperationError('Open a board before requesting its context.', 409);
228
+ const consumer =
229
+ typeof input.consumer === 'string' && input.consumer.trim() ? input.consumer.trim().slice(0, 200) : null;
230
+ const explicitSince = input.since !== undefined;
231
+ const sinceValue = explicitSince
232
+ ? Number(input.since)
233
+ : consumer
234
+ ? canvasState.getContextBriefCursor(active.id, consumer)
235
+ : null;
236
+ const budgetValue = input.budget === undefined ? DEFAULT_CONTEXT_BRIEF_BUDGET : Number(input.budget);
237
+ const budget = Number.isFinite(budgetValue)
238
+ ? Math.min(MAX_CONTEXT_BRIEF_BUDGET, Math.max(0, Math.floor(budgetValue)))
239
+ : DEFAULT_CONTEXT_BRIEF_BUDGET;
240
+
241
+ // Capture the active target and revision synchronously. Library boards are
242
+ // read from SQLite and are never opened or made writable by this operation.
243
+ const layout = canvasState.getLayout();
244
+ const revision = canvasState.getContentRevision();
245
+ const pinnedNodeIds = [...canvasState.contextPinnedNodeIds];
246
+ const linkedIds = new Map<string, string[]>();
247
+ for (const node of layout.nodes) {
248
+ if (node.type !== 'board' || typeof node.data.boardId !== 'string') continue;
249
+ const ids = linkedIds.get(node.data.boardId) ?? [];
250
+ ids.push(node.id);
251
+ linkedIds.set(node.data.boardId, ids);
252
+ }
253
+ const libraryBoards: ContextBriefLibraryBoard[] = [];
254
+ for (const board of canvasState.listBoards()) {
255
+ if (board.id === active.id) continue;
256
+ if (!linkedIds.has(board.id) && (!active.category || active.category !== board.category)) continue;
257
+ const read = canvasState.readBoard(board.id, true);
258
+ if (!read) continue;
259
+ const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
260
+ const pins = new Set(read.state.contextPins);
261
+ libraryBoards.push({
262
+ boardId: board.id,
263
+ name: board.name,
264
+ category: board.category,
265
+ ...(readme
266
+ ? {
267
+ readme: {
268
+ nodeId: readme.id,
269
+ title: typeof readme.data.title === 'string' ? readme.data.title : readme.id,
270
+ summary: summarizeNodeForAgentContext(readme, { defaultTextLength: 1_000, webpageTextLength: 1_000 }),
271
+ },
272
+ }
273
+ : {}),
274
+ pinnedTitles: read.layout.nodes
275
+ .filter((node) => pins.has(node.id))
276
+ .map((node) => ({
277
+ nodeId: node.id,
278
+ title: typeof node.data.title === 'string' ? node.data.title : node.id,
279
+ })),
280
+ linkIds: linkedIds.get(board.id) ?? [],
281
+ });
282
+ }
283
+ const entries: ContextBriefSourceEntry[] = layout.nodes
284
+ .filter((node) => node.createdBy?.actor === 'human')
285
+ .map((node) => ({
286
+ sourceBoardId: active.id,
287
+ nodeId: node.id,
288
+ reason: 'human' as const,
289
+ title: typeof node.data.title === 'string' ? node.data.title : node.id,
290
+ text: summarizeNodeForAgentContext(node, { defaultTextLength: budget, webpageTextLength: budget }),
291
+ }));
292
+ entries.push(
293
+ ...canvasState
294
+ .getApprovalGates()
295
+ .filter((item) => item.status === 'pending')
296
+ .map((item) => ({
297
+ sourceBoardId: active.id,
298
+ nodeId: `approval:${item.id}`,
299
+ reason: 'ask' as const,
300
+ title: item.title,
301
+ text: item.detail || item.action || 'Approval requested.',
302
+ })),
303
+ ...canvasState
304
+ .getModeRequests()
305
+ .filter((item) => item.status === 'pending')
306
+ .map((item) => ({
307
+ sourceBoardId: active.id,
308
+ nodeId: `mode:${item.id}`,
309
+ reason: 'ask' as const,
310
+ title: `Mode request: ${item.mode}`,
311
+ text: item.reason || 'Mode change requested.',
312
+ })),
313
+ ...canvasState
314
+ .getElicitations()
315
+ .filter((item) => item.status === 'pending')
316
+ .map((item) => ({
317
+ sourceBoardId: active.id,
318
+ nodeId: `elicitation:${item.id}`,
319
+ reason: 'ask' as const,
320
+ title: 'Open question',
321
+ text: item.prompt,
322
+ })),
323
+ );
324
+ entries.push(
325
+ ...canvasState.getPendingSteering({ consumer: consumer ?? undefined, limit: 50 }).map((item) => ({
326
+ sourceBoardId: active.id,
327
+ nodeId: `steering:${item.id}`,
328
+ reason: 'steer' as const,
329
+ title: 'Human steering',
330
+ text: item.message,
331
+ })),
332
+ );
333
+ const result = compileContextBrief({
334
+ activeBoard: { boardId: active.id, name: active.name, category: active.category },
335
+ nodes: layout.nodes,
336
+ pinnedNodeIds,
337
+ contentRevision: revision.revision,
338
+ retentionFloor: revision.retentionFloor,
339
+ tombstones: canvasState.readContentDelta(sinceValue ?? 0).deleted,
340
+ since: explicitSince ? (Number.isSafeInteger(sinceValue) ? sinceValue : Number.NaN) : sinceValue,
341
+ libraryBoards,
342
+ entries,
343
+ budget,
344
+ });
345
+ if (consumer && !explicitSince && result.document && result.nextCursor !== null && !result.invalidCursor) {
346
+ canvasState.advanceContextBriefCursor(active.id, consumer, result.nextCursor);
347
+ }
348
+ return (result.document ?? {
349
+ version: 1,
350
+ budgetUnit: 'utf16-code-units',
351
+ error: 'Budget is too small for the context envelope.',
352
+ }) as unknown as Record<string, unknown>;
353
+ },
354
+ });
355
+
165
356
  // ── history.get (HTTP/CLI only — canvas://history stays a resource) ──
166
357
 
167
358
  const historyGetShape = {};
@@ -258,7 +449,7 @@ const redoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<str
258
449
 
259
450
  // ── spatial.get (HTTP only — canvas://spatial-context stays a resource) ──
260
451
 
261
- const spatialGetShape = {};
452
+ const spatialGetShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
262
453
 
263
454
  const spatialGetSchema = z.looseObject(spatialGetShape);
264
455
 
@@ -271,12 +462,13 @@ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Re
271
462
  method: 'GET',
272
463
  path: '/api/canvas/spatial-context',
273
464
  },
274
- handler: () => {
275
- const layout = canvasState.getLayout();
465
+ handler: ({ board }) => {
466
+ const target = readTargetBoard(board);
467
+ const layout = target.layout;
276
468
  return buildSpatialContext(
277
469
  layout.nodes,
278
470
  layout.edges,
279
- canvasState.contextPinnedNodeIds,
471
+ target.pinnedNodeIds,
280
472
  layout.annotations,
281
473
  ) as unknown as Record<string, unknown>;
282
474
  },
@@ -284,7 +476,7 @@ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Re
284
476
 
285
477
  // ── summary.get (HTTP only — canvas://summary stays a resource) ──
286
478
 
287
- const summaryGetShape = {};
479
+ const summaryGetShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
288
480
 
289
481
  const summaryGetSchema = z.looseObject(summaryGetShape);
290
482
 
@@ -297,12 +489,17 @@ const summaryGetOperation = defineOperation<z.infer<typeof summaryGetSchema>, Re
297
489
  method: 'GET',
298
490
  path: '/api/canvas/summary',
299
491
  },
300
- handler: () => buildCanvasSummary() as unknown as Record<string, unknown>,
492
+ handler: ({ board }) => {
493
+ if (typeof board !== 'string' || !board.trim()) return buildCanvasSummary() as unknown as Record<string, unknown>;
494
+ const target = readTargetBoard(board);
495
+ return buildSummaryFromLayout(target.layout, [...target.pinnedNodeIds]);
496
+ },
301
497
  });
302
498
 
303
499
  export const queryOperations: Operation[] = [
304
500
  pinOperation,
305
501
  searchOperation,
502
+ contextOperation,
306
503
  historyGetOperation,
307
504
  undoOperation,
308
505
  redoOperation,
@@ -13,6 +13,7 @@
13
13
  * This module must never import server.ts or index.ts.
14
14
  */
15
15
  import { z } from 'zod';
16
+ import { tourSchema, derivedTour } from '../../../shared/tour.js';
16
17
  import { canvasState } from '../../canvas-state.js';
17
18
  import { arrangeCanvasNodes, clearCanvas, fitCanvasView, setClientViewportSize } from '../../canvas-operations.js';
18
19
  import { validateCanvasLayout } from '../../canvas-validation.js';
@@ -272,7 +273,38 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
272
273
  },
273
274
  });
274
275
 
276
+ const tourSetShape = { tour: tourSchema.nullable().describe('Ordered camera stops, or null to derive from groups') };
277
+ const tourSetOperation = defineOperation({
278
+ name: 'tour.set',
279
+ mutates: true,
280
+ input: z.object(tourSetShape),
281
+ inputShape: tourSetShape,
282
+ http: { method: 'POST', path: '/api/canvas/tour' },
283
+ mcp: { toolName: 'canvas_set_tour', description: 'Persist an ordered board presentation tour.' },
284
+ handler: (input) => {
285
+ canvasState.setTour(input.tour);
286
+ return { ok: true };
287
+ },
288
+ });
289
+ const tourGetOperation = defineOperation({
290
+ name: 'tour.get',
291
+ mutates: false,
292
+ input: z.object({}),
293
+ inputShape: {},
294
+ http: { method: 'GET', path: '/api/canvas/tour' },
295
+ mcp: {
296
+ toolName: 'canvas_get_tour',
297
+ description: 'Read the board tour, deriving groups in reading order when absent.',
298
+ },
299
+ handler: () => ({
300
+ tour: canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes),
301
+ derived: !canvasState.getTour(),
302
+ }),
303
+ });
304
+
275
305
  export const viewportOperations: Operation[] = [
306
+ tourSetOperation,
307
+ tourGetOperation,
276
308
  arrangeOperation,
277
309
  focusOperation,
278
310
  fitOperation,
@@ -7,12 +7,16 @@
7
7
  * Handlers never emit `canvas-layout-update` themselves for the final state —
8
8
  * `mutates: true` is the single source; extra events go through `ctx.emit`.
9
9
  */
10
+ import { AsyncLocalStorage } from 'node:async_hooks';
10
11
  import { canvasState } from '../canvas-state.js';
11
12
  import { intentRegistry } from '../intent-registry.js';
12
13
  import { humanPresence } from '../human-presence.js';
13
- import { setMutationActor } from '../mutation-history.js';
14
+ import { withCurrentActor } from '../attribution.js';
14
15
  import { agentPresence, describeWrite } from '../agent-presence.js';
15
16
  import { checkScopeFence, checkScopeOwnership } from '../scope-fence.js';
17
+ import { CONTEXT_READ_OPS, contextReadFromPayload } from '../context-reads.js';
18
+ import { openBoardForWrite } from '../canvas-operations.js';
19
+ import { boardsPayload } from './ops/boards.js';
16
20
  import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
17
21
  import { OperationError, type Operation, type OperationContext } from './types.js';
18
22
 
@@ -43,18 +47,12 @@ export function setOperationEventEmitter(emitter: OperationEventEmitter | null):
43
47
  operationEventEmitter = emitter;
44
48
  }
45
49
 
46
- // Depth-counted EMIT suppression (same pattern as, but deliberately distinct
47
- // state from, canvasState's recording suppression: batch suppresses SSE emits
48
- // while sub-ops still record history; undo/redo suppresses recording while
49
- // still emitting SSE — merging the two counters would break one or the other).
50
- // While > 0, emitOperationEvent is a no-op so a meta-op (canvas.batch) can run
51
- // many sub-ops without producing per-entry SSE frames, then emit ONE final
52
- // layout frame itself. Both the `mutates` auto-emit and `ctx.emit` route through
53
- // emitOperationEvent, so this covers both. Re-entrant-safe via the depth counter.
54
- let suppressEmitDepth = 0;
50
+ // Batch suppression belongs to its async call chain, never concurrent human
51
+ // operations (especially board switches while a batch waits on the network).
52
+ const suppressedEmits = new AsyncLocalStorage<boolean>();
55
53
 
56
54
  function emitOperationEvent(event: string, payload: Record<string, unknown> = {}): void {
57
- if (suppressEmitDepth > 0) return;
55
+ if (isEmitSuppressed()) return;
58
56
  operationEventEmitter?.(event, payload);
59
57
  }
60
58
 
@@ -63,17 +61,12 @@ function emitOperationEvent(event: string, payload: Record<string, unknown> = {}
63
61
  * mcpapp.open, whose canvas node is created as a side-effect of `ext-app-open` —
64
62
  * use this to reject loudly instead of silently no-op'ing in a suppressed run. */
65
63
  export function isEmitSuppressed(): boolean {
66
- return suppressEmitDepth > 0;
64
+ return suppressedEmits.getStore() === true;
67
65
  }
68
66
 
69
- /** Run `fn` with all operation SSE emits suppressed; restores depth on finally. */
67
+ /** Suppress this call chain's per-entry frames; the batch emits its final frame afterward. */
70
68
  export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T> {
71
- suppressEmitDepth++;
72
- try {
73
- return await fn();
74
- } finally {
75
- suppressEmitDepth--;
76
- }
69
+ return suppressedEmits.run(true, fn);
77
70
  }
78
71
 
79
72
  const operationContext: OperationContext = { emit: emitOperationEvent };
@@ -208,6 +201,14 @@ export interface ExecuteOperationMeta {
208
201
  * to agents only, and batch inner writes are always agent-originated.
209
202
  */
210
203
  fromWorkbench?: boolean;
204
+ /** Authorship only: true when the per-boot workbench token matched. */
205
+ humanAuthor?: boolean;
206
+ /**
207
+ * An MCP server attached to this daemon, or a host adapter, fetching on an
208
+ * agent's behalf. It records the read the agent actually made itself
209
+ * (POST /api/canvas/ax/context-reads), so this fetch is not recorded.
210
+ */
211
+ proxiedRead?: boolean;
211
212
  }
212
213
 
213
214
  /**
@@ -296,6 +297,20 @@ const PRESENCE_EXEMPT_OPS = new Set([
296
297
  'ax.activity.ingest',
297
298
  'human.presence.set',
298
299
  'human.presence.get',
300
+ // Recording a context read is instrumentation of a read, not a write.
301
+ 'ax.reads.record',
302
+ // Backup and restore are maintenance of the library file, not edits.
303
+ 'backup.run',
304
+ 'backup.schedule',
305
+ 'backup.restore',
306
+ // Writing an export file shares nothing by itself and edits nothing.
307
+ 'export.run',
308
+ // Camera and attention controls are observation/navigation, not edits.
309
+ // They must not create a writer, bump opCount, or refresh an existing one.
310
+ 'viewport.set',
311
+ 'view.fit',
312
+ 'node.focus',
313
+ 'ax.focus.set',
299
314
  ]);
300
315
 
301
316
  /** Layout mutations AND non-GET AX writes (work items, gates, evidence, steering) are agent activity. */
@@ -347,22 +362,105 @@ export async function executeOperation(
347
362
  rawInput: unknown,
348
363
  meta: ExecuteOperationMeta = {},
349
364
  ): Promise<unknown> {
350
- // History entries recorded while this op runs carry who made them (item
351
- // 10: the session panel offers undo on the agent's latest edit).
352
- const historyWriter =
353
- typeof asRecord(rawInput).agentId === 'string' && String(asRecord(rawInput).agentId).trim()
354
- ? String(asRecord(rawInput).agentId).trim()
355
- : (meta.source ?? 'api');
356
- setMutationActor(meta.fromWorkbench ? 'human' : 'agent', historyWriter);
357
- try {
358
- return await executeOperationInner(name, rawInput, meta);
359
- } finally {
360
- setMutationActor(null);
361
- }
365
+ // Attribution follows this call chain across awaits and nested operations.
366
+ const inputAgentId = asRecord(rawInput).agentId;
367
+ return withCurrentActor(
368
+ {
369
+ actor: meta.humanAuthor ? 'human' : 'agent',
370
+ source: meta.humanAuthor ? 'browser' : (meta.source ?? 'api'),
371
+ ...(typeof inputAgentId === 'string' && inputAgentId.trim() ? { agentId: inputAgentId.trim() } : {}),
372
+ },
373
+ async () => {
374
+ const recordRead = CONTEXT_READ_OPS.has(name) && !meta.fromWorkbench && !meta.proxiedRead;
375
+ const input = asRecord(rawInput);
376
+ const requestedBoard =
377
+ recordRead && 'board' in getOperation(name).inputShape && isString(input.board) && input.board.trim()
378
+ ? input.board.trim()
379
+ : null;
380
+ const readBoardId = requestedBoard ?? canvasState.activeBoardId;
381
+ const readPins = recordRead
382
+ ? requestedBoard
383
+ ? (canvasState.readBoard(requestedBoard, false)?.state.contextPins ?? [])
384
+ : [...canvasState.contextPinnedNodeIds]
385
+ : [];
386
+ const result = await executeOperationInner(name, rawInput, meta);
387
+ if (recordRead) {
388
+ canvasState.recordContextRead(
389
+ contextReadFromPayload(
390
+ {
391
+ channel: 'operation',
392
+ resource: name,
393
+ source: meta.source ?? 'api',
394
+ consumer: isString(input.consumer) && input.consumer.trim() ? input.consumer.trim() : null,
395
+ agentId: isString(input.agentId) && input.agentId.trim() ? input.agentId.trim() : null,
396
+ pinnedNodeIds: readPins,
397
+ },
398
+ result,
399
+ ),
400
+ readBoardId,
401
+ );
402
+ }
403
+ return result;
404
+ },
405
+ );
406
+ }
407
+
408
+ /** Opening, switching and deleting boards is the human's; an agent asks for another board. */
409
+ const HUMAN_ONLY_OPS = new Set(['board.open', 'board.delete', 'import.request', 'import.cancel', 'import.commit']);
410
+ const TRUSTED_HUMAN_ONLY_OPS = new Set([
411
+ 'board.open',
412
+ 'board.delete',
413
+ 'import.request',
414
+ 'import.cancel',
415
+ 'import.commit',
416
+ ]);
417
+
418
+ /**
419
+ * Writes that need a board beyond the layout mutations: canvas-bound AX items,
420
+ * pins, snapshots and app nodes. `canvas.clear` on Home clears nothing, so it
421
+ * never opens a board it would then wipe.
422
+ */
423
+ const BOARD_WRITE_OPS = new Set(['pin.set', 'snapshot.save', 'mcpapp.open', 'diagram.open', 'webartifact.build']);
424
+
425
+ const CANVAS_BOUND_AX_WRITE_PREFIXES = [
426
+ 'ax.work.',
427
+ 'ax.review.',
428
+ 'ax.approval.',
429
+ 'ax.elicitation.',
430
+ 'ax.mode.',
431
+ ] as const;
432
+
433
+ function isCanvasBoundAxWrite(name: string): boolean {
434
+ if (name === 'ax.focus.set' || name === 'ax.policy.set') return true;
435
+ return CANVAS_BOUND_AX_WRITE_PREFIXES.some((prefix) => name.startsWith(prefix)) && !name.endsWith('.await');
436
+ }
437
+
438
+ function needsOpenBoard(op: Operation): boolean {
439
+ if (op.name === 'canvas.clear' || PRESENCE_EXEMPT_OPS.has(op.name)) return false;
440
+ return op.mutates || BOARD_WRITE_OPS.has(op.name) || isCanvasBoundAxWrite(op.name);
362
441
  }
363
442
 
364
443
  async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
365
444
  const op = getOperation(name);
445
+ if (HUMAN_ONLY_OPS.has(name) && !meta.fromWorkbench) {
446
+ throw new OperationError(
447
+ 'Only the human opens or deletes boards. Ask them in the chat to open the board you need.',
448
+ 403,
449
+ );
450
+ }
451
+ if (TRUSTED_HUMAN_ONLY_OPS.has(name) && !meta.humanAuthor) {
452
+ throw new OperationError('This action requires trusted human authority.', 403);
453
+ }
454
+ // File drops carry their captured board through the HTTP round trip. Check
455
+ // before opening a recent board from Home, not just before reading the file.
456
+ const capturedBoard = asRecord(rawInput).boardId;
457
+ if (name === 'node.add' && typeof capturedBoard === 'string' && capturedBoard !== canvasState.activeBoardId) {
458
+ throw new OperationError('The board changed while the file was loading.', 409);
459
+ }
460
+ if (!canvasState.activeBoardId && needsOpenBoard(op)) {
461
+ openBoardForWrite();
462
+ if (canvasState.activeBoardId) emitOperationEvent('boards-changed', boardsPayload());
463
+ }
366
464
  // Scope fence (design item 4): an attached agent's writes must stay inside
367
465
  // the fence the human granted. Reads and the human's own writes pass, and
368
466
  // the fence itself is the human's to set — an agent cannot clear or widen it.
@@ -60,7 +60,7 @@ export interface OperationHttpRoute {
60
60
 
61
61
  /** Host capabilities available to MCP result formatters. */
62
62
  export interface OperationMcpToolHost {
63
- getPinnedNodeIds(): Promise<string[]>;
63
+ getPinnedNodeIds(board?: string): Promise<string[]>;
64
64
  /**
65
65
  * Invoke another registered operation over the host's transport (local or
66
66
  * HTTP) — structural subset of OperationInvoker to avoid an import cycle.
@@ -111,7 +111,7 @@ function createTarget(input: Record<string, unknown>, extraNodeIds: string[] = [
111
111
  }
112
112
 
113
113
  /** Ops that rewrite the whole board — never allowed under a fence. */
114
- const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard']);
114
+ const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard', 'tour.set']);
115
115
 
116
116
  /** Describe what a registry op would write, in fence terms. */
117
117
  export function describeOpTarget(op: Operation, rawInput: unknown): FenceTarget {