pmx-canvas 0.6.5 → 0.7.1

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 (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  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/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -52,6 +52,7 @@ import {
52
52
  } from '../../canvas-serialization.js';
53
53
  import { WEBPAGE_NODE_DEFAULT_SIZE, normalizeWebpageUrl } from '../../webpage-node.js';
54
54
  import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
55
+ import { readTargetBoard } from './boards.js';
55
56
 
56
57
  // ── Node types ────────────────────────────────────────────────
57
58
  // Single definition site for the basic node-type list: drives the HTTP type
@@ -59,6 +60,7 @@ import { defineOperation, OperationError, type Operation, type OperationContext
59
60
  // and the MCP type enum.
60
61
  export const NODE_TYPES = [
61
62
  'markdown',
63
+ 'board',
62
64
  'status',
63
65
  'context',
64
66
  'ledger',
@@ -91,6 +93,8 @@ export function defaultNodeSize(type: string): { width: number; height: number }
91
93
  return { width: 640, height: 460 };
92
94
  case 'markdown':
93
95
  return MARKDOWN_NODE_DEFAULT_SIZE;
96
+ case 'board':
97
+ return { width: 360, height: 160 };
94
98
  case 'mcp-app':
95
99
  return MCP_APP_NODE_DEFAULT_SIZE;
96
100
  case 'image':
@@ -400,6 +404,12 @@ export function createBasicCanvasNode(
400
404
  ): { node: CanvasNodeState; needsCodeGraphRecompute: boolean; sizeAdjustment?: CanvasSizeAdjustment } {
401
405
  const type = typeof body.type === 'string' ? body.type : '';
402
406
  const extraData = isRecord(body.data) ? body.data : undefined;
407
+ if (
408
+ extraData &&
409
+ ('attachmentId' in extraData || (isRecord(extraData.source) && 'attachmentId' in extraData.source))
410
+ ) {
411
+ throw new OperationError('Attachment identity and import provenance are server-managed.');
412
+ }
403
413
  if (type === 'html') {
404
414
  if ('html' in body && typeof body.html !== 'string') {
405
415
  throw new OperationError('HTML node field "html" must be a string.');
@@ -500,6 +510,25 @@ export function buildNodePatch(
500
510
  existing: CanvasNodeState,
501
511
  body: Record<string, unknown>,
502
512
  ): { patch: Partial<CanvasNodeState>; groupChildIds?: string[] } {
513
+ if (body.type !== undefined && body.type !== existing.type) throw new OperationError('Node type is immutable.');
514
+ if (
515
+ existing.type === 'file' &&
516
+ typeof existing.data.attachmentId === 'string' &&
517
+ (body.path !== undefined || body.content !== undefined)
518
+ ) {
519
+ throw new OperationError('Attached file nodes cannot be repointed or changed into disk-backed files.');
520
+ }
521
+ if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
522
+ const data = body.data as Record<string, unknown>;
523
+ const source = data.source;
524
+ const importedSource = source && typeof source === 'object' && 'attachmentId' in source;
525
+ const attachedFileContent =
526
+ existing.type === 'file' &&
527
+ typeof existing.data.attachmentId === 'string' &&
528
+ ('path' in data || 'content' in data || 'fileContent' in data);
529
+ if ('attachmentId' in data || importedSource || attachedFileContent)
530
+ throw new OperationError('Attachment identity and import provenance are immutable.');
531
+ }
503
532
  // Same dead-field traps as create (gauntlet, 2026-08-27): reject loudly
504
533
  // instead of dropping — and note that patching `content` on a file node
505
534
  // updates the FILE CONTENT snapshot, it does not re-point the node.
@@ -657,7 +686,11 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
657
686
  // The node should appear before the (slow) page fetch completes; the
658
687
  // registry emits the final layout update after the handler returns.
659
688
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
689
+ const boardGeneration = canvasState.boardGeneration;
660
690
  const refreshed = await refreshCanvasWebpageNode(id);
691
+ if (canvasState.boardGeneration !== boardGeneration) {
692
+ throw new OperationError('The board changed while the webpage was loading.', 409);
693
+ }
661
694
  const created = canvasState.getNode(id) ?? node;
662
695
  return {
663
696
  node: created,
@@ -974,6 +1007,7 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
974
1007
 
975
1008
  const nodeGetShape = {
976
1009
  id: z.string().describe('The node ID to retrieve'),
1010
+ board: z.unknown().optional().describe('Board id to read without opening it.'),
977
1011
  includeBlobs: z.unknown().optional().describe('Include full blob payloads instead of blob summaries.'),
978
1012
  };
979
1013
 
@@ -993,18 +1027,25 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
993
1027
  description:
994
1028
  'Get a single node by ID. Defaults to compact metadata; pass full:true to include full data/tool results.',
995
1029
  extraShape: {
1030
+ board: z.string().optional().describe('Board id to read without opening it.'),
996
1031
  full: z.boolean().optional().describe('Include full node data, including mcp-app tool results. Default false.'),
997
1032
  verbose: z.boolean().optional().describe('Alias for full:true.'),
998
1033
  },
999
- buildInput: (input) => ({ id: input.id, includeBlobs: true }),
1034
+ buildInput: (input) => ({ id: input.id, board: input.board, includeBlobs: true }),
1000
1035
  formatResult: (result, input) => {
1001
1036
  const node = result as CanvasNodeState;
1002
1037
  const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
1003
1038
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
1004
1039
  },
1005
1040
  },
1006
- handler: ({ id, includeBlobs }) => {
1041
+ handler: ({ id, includeBlobs, board }) => {
1007
1042
  const full = includeBlobs === true || includeBlobs === 'true';
1043
+ if (typeof board === 'string' && board.trim()) {
1044
+ const target = readTargetBoard(board, full);
1045
+ const node = target.layout.nodes.find((candidate) => candidate.id === id);
1046
+ if (!node) throw new OperationError(`Node "${id}" not found on board "${board}".`, 404);
1047
+ return full ? serializeCanvasNode(node) : serializeCanvasNodeWithBlobSummaries(node);
1048
+ }
1008
1049
  const node = full ? canvasState.getNode(id) : canvasState.getNodeForPersistence(id);
1009
1050
  if (!node) throw new OperationError(`Node "${id}" not found.`, 404);
1010
1051
  const responseNode = withContextPinReadState(node);
@@ -1297,6 +1338,7 @@ const nodeRemoveOperation = defineOperation<z.infer<typeof nodeRemoveSchema>, Re
1297
1338
  // ── layout.get ────────────────────────────────────────────────
1298
1339
 
1299
1340
  const layoutGetShape = {
1341
+ board: z.unknown().optional().describe('Board id to read without opening it.'),
1300
1342
  includeBlobs: z.unknown().optional().describe('Include full blob payloads instead of blob summaries.'),
1301
1343
  };
1302
1344
 
@@ -1316,23 +1358,33 @@ const layoutGetOperation = defineOperation<z.infer<typeof layoutGetSchema>, Reco
1316
1358
  description:
1317
1359
  'Get the canvas layout. Defaults to a compact agent-safe projection; pass full:true for full node data.',
1318
1360
  extraShape: {
1361
+ board: z.string().optional().describe('Board id to read without opening it.'),
1319
1362
  full: z
1320
1363
  .boolean()
1321
1364
  .optional()
1322
1365
  .describe('Return the full layout including node data. Default false keeps responses compact.'),
1323
1366
  verbose: z.boolean().optional().describe('Alias for full:true.'),
1324
1367
  },
1325
- buildInput: () => ({ includeBlobs: true }),
1368
+ buildInput: (input) => ({ board: input.board, includeBlobs: true }),
1326
1369
  formatResult: async (result, input, host) => {
1327
1370
  const layout = result as CanvasLayout;
1328
1371
  const payload = wantsFullPayload(input)
1329
1372
  ? agentSafeFullLayoutPayload(layout)
1330
- : compactLayoutPayload(layout, await host.getPinnedNodeIds());
1373
+ : compactLayoutPayload(
1374
+ layout,
1375
+ await host.getPinnedNodeIds(typeof input.board === 'string' ? input.board : undefined),
1376
+ );
1331
1377
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
1332
1378
  },
1333
1379
  },
1334
- handler: ({ includeBlobs }) => {
1380
+ handler: ({ includeBlobs, board }) => {
1335
1381
  const full = includeBlobs === true || includeBlobs === 'true';
1382
+ if (typeof board === 'string' && board.trim()) {
1383
+ const target = readTargetBoard(board, full);
1384
+ return (full
1385
+ ? serializeCanvasLayout(target.layout)
1386
+ : serializeCanvasLayoutWithBlobSummaries(target.layout)) as unknown as Record<string, unknown>;
1387
+ }
1336
1388
  return (full
1337
1389
  ? serializeCanvasLayout(canvasState.getLayout())
1338
1390
  : serializeCanvasLayoutWithBlobSummaries(
@@ -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,
@@ -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,14 @@ 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',
299
308
  // Camera and attention controls are observation/navigation, not edits.
300
309
  // They must not create a writer, bump opCount, or refresh an existing one.
301
310
  'viewport.set',
@@ -353,22 +362,105 @@ export async function executeOperation(
353
362
  rawInput: unknown,
354
363
  meta: ExecuteOperationMeta = {},
355
364
  ): Promise<unknown> {
356
- // History entries recorded while this op runs carry who made them (item
357
- // 10: the session panel offers undo on the agent's latest edit).
358
- const historyWriter =
359
- typeof asRecord(rawInput).agentId === 'string' && String(asRecord(rawInput).agentId).trim()
360
- ? String(asRecord(rawInput).agentId).trim()
361
- : (meta.source ?? 'api');
362
- setMutationActor(meta.fromWorkbench ? 'human' : 'agent', historyWriter);
363
- try {
364
- return await executeOperationInner(name, rawInput, meta);
365
- } finally {
366
- setMutationActor(null);
367
- }
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);
368
441
  }
369
442
 
370
443
  async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
371
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
+ }
372
464
  // Scope fence (design item 4): an attached agent's writes must stay inside
373
465
  // the fence the human granted. Reads and the human's own writes pass, and
374
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.