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
@@ -1,15 +1,9 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
- import {
5
- createCanvas,
6
- canvasState,
7
- type CanvasEdge,
8
- type CanvasLayout,
9
- type CanvasNodeState,
10
- type PmxCanvas,
11
- } from '../server/index.js';
4
+ import { createCanvas, canvasState, type CanvasLayout, type CanvasNodeState, type PmxCanvas } from '../server/index.js';
12
5
  import type { PmxAxSource } from '../server/ax-state.js';
6
+ import type { ContextReadInput } from '../server/context-reads.js';
13
7
  import { HttpOperationInvoker, LocalOperationInvoker, type OperationInvoker } from '../server/operations/index.js';
14
8
 
15
9
  // openMcpApp / addDiagram / buildWebArtifact / refreshWebpageNode / addHtmlNode /
@@ -54,8 +48,8 @@ export interface CanvasAccess {
54
48
  readonly remoteBaseUrl: string | null;
55
49
  /** Operation-registry invoker (plan-005): local in-process or HTTP, matching the access mode. */
56
50
  invoker(): OperationInvoker;
57
- getLayout(): Promise<CanvasLayout>;
58
- getNode(id: string): Promise<CanvasNodeState | undefined>;
51
+ getLayout(board?: string): Promise<CanvasLayout>;
52
+ getNode(id: string, board?: string): Promise<CanvasNodeState | undefined>;
59
53
  getAxState(): Promise<AxStateResult>;
60
54
  getAxContext(options?: { consumer?: string }): Promise<AxContextResult>;
61
55
  getAxTimeline(query?: GetAxTimelineQuery): Promise<GetAxTimelineResult>;
@@ -72,7 +66,10 @@ export interface CanvasAccess {
72
66
  ingestActivity(input: IngestActivityInput, options?: { source?: PmxAxSource }): Promise<IngestActivityResult>;
73
67
  getPolicy(): Promise<GetPolicyResult>;
74
68
  getHistory(): Promise<HistoryResult>;
75
- getPinnedNodeIds(): Promise<string[]>;
69
+ getPinnedNodeIds(board?: string): Promise<string[]>;
70
+ prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }>;
71
+ /** Record a context read the agent made through this MCP server (plan-011). */
72
+ recordContextRead(read: ContextReadInput): Promise<void>;
76
73
  runBatch(operations: RunBatchInput): Promise<RunBatchResult>;
77
74
  getCodeGraph(): Promise<CodeGraphResult>;
78
75
  // canvas_screenshot (still hand-written — binary payload) is the only webview
@@ -99,11 +96,15 @@ class LocalCanvasAccess implements CanvasAccess {
99
96
  return this.operationInvoker;
100
97
  }
101
98
 
102
- async getLayout(): Promise<CanvasLayout> {
103
- return this.canvas.getLayout();
99
+ async getLayout(board?: string): Promise<CanvasLayout> {
100
+ return board
101
+ ? ((await this.operationInvoker.invoke('layout.get', { board, includeBlobs: true })) as CanvasLayout)
102
+ : this.canvas.getLayout();
104
103
  }
105
104
 
106
- async getNode(id: string): Promise<CanvasNodeState | undefined> {
105
+ async getNode(id: string, board?: string): Promise<CanvasNodeState | undefined> {
106
+ if (board)
107
+ return (await this.operationInvoker.invoke('node.get', { id, board, includeBlobs: true })) as CanvasNodeState;
107
108
  return this.canvas.getNode(id);
108
109
  }
109
110
 
@@ -162,10 +163,23 @@ class LocalCanvasAccess implements CanvasAccess {
162
163
  return this.canvas.getHistory();
163
164
  }
164
165
 
165
- async getPinnedNodeIds(): Promise<string[]> {
166
+ async getPinnedNodeIds(board?: string): Promise<string[]> {
167
+ if (board) {
168
+ const result = (await this.operationInvoker.invoke('pinned-context.get', { board })) as { nodeIds?: string[] };
169
+ return result.nodeIds ?? [];
170
+ }
166
171
  return Array.from(canvasState.contextPinnedNodeIds);
167
172
  }
168
173
 
174
+ async prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }> {
175
+ const boardId = board ?? canvasState.activeBoardId;
176
+ return { boardId, pinnedNodeIds: boardId ? await this.getPinnedNodeIds(boardId) : [] };
177
+ }
178
+
179
+ async recordContextRead(read: ContextReadInput): Promise<void> {
180
+ canvasState.recordContextRead(read, read.boardId === undefined ? canvasState.activeBoardId : read.boardId);
181
+ }
182
+
169
183
  async runBatch(operations: RunBatchInput): Promise<RunBatchResult> {
170
184
  return await this.canvas.runBatch(operations);
171
185
  }
@@ -202,7 +216,12 @@ class RemoteCanvasAccess implements CanvasAccess {
202
216
  private async requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
203
217
  const response = await fetch(`${this.remoteBaseUrl}${path}`, {
204
218
  method,
205
- headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
219
+ // These fetches serve the agent's MCP reads, which this server records
220
+ // itself — the daemon must not record them a second time (plan-011).
221
+ headers: {
222
+ 'x-pmx-proxied-read': '1',
223
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
224
+ },
206
225
  body: body === undefined ? undefined : JSON.stringify(body),
207
226
  });
208
227
  const text = await response.text();
@@ -227,12 +246,16 @@ class RemoteCanvasAccess implements CanvasAccess {
227
246
  return parsed as T;
228
247
  }
229
248
 
230
- async getLayout(): Promise<CanvasLayout> {
231
- return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state?includeBlobs=true');
249
+ async getLayout(board?: string): Promise<CanvasLayout> {
250
+ const params = new URLSearchParams({ includeBlobs: 'true' });
251
+ if (board) params.set('board', board);
252
+ return await this.requestJson<CanvasLayout>('GET', `/api/canvas/state?${params}`);
232
253
  }
233
254
 
234
- async getNode(id: string): Promise<CanvasNodeState | undefined> {
235
- const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}?includeBlobs=true`);
255
+ async getNode(id: string, board?: string): Promise<CanvasNodeState | undefined> {
256
+ const params = new URLSearchParams({ includeBlobs: 'true' });
257
+ if (board) params.set('board', board);
258
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}?${params}`);
236
259
  if (response.status === 404) return undefined;
237
260
  const text = await response.text();
238
261
  let parsed: unknown;
@@ -346,11 +369,25 @@ class RemoteCanvasAccess implements CanvasAccess {
346
369
  return response.reviewAnnotations ?? [];
347
370
  }
348
371
 
349
- async getPinnedNodeIds(): Promise<string[]> {
350
- const response = await this.requestJson<{ nodeIds?: string[] }>('GET', '/api/canvas/pinned-context');
372
+ async getPinnedNodeIds(board?: string): Promise<string[]> {
373
+ const qs = board ? `?board=${encodeURIComponent(board)}` : '';
374
+ const response = await this.requestJson<{ nodeIds?: string[] }>('GET', `/api/canvas/pinned-context${qs}`);
351
375
  return Array.isArray(response.nodeIds) ? response.nodeIds : [];
352
376
  }
353
377
 
378
+ async prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }> {
379
+ let boardId = board;
380
+ if (!boardId) {
381
+ const boards = await this.requestJson<{ activeBoardId?: string | null }>('GET', '/api/canvas/boards');
382
+ boardId = boards.activeBoardId ?? undefined;
383
+ }
384
+ return { boardId: boardId ?? null, pinnedNodeIds: boardId ? await this.getPinnedNodeIds(boardId) : [] };
385
+ }
386
+
387
+ async recordContextRead(read: ContextReadInput): Promise<void> {
388
+ await this.requestJson('POST', '/api/canvas/ax/context-reads', read);
389
+ }
390
+
354
391
  async runBatch(operations: RunBatchInput): Promise<RunBatchResult> {
355
392
  return await this.requestJson<RunBatchResult>('POST', '/api/canvas/batch', { operations });
356
393
  }
@@ -0,0 +1,28 @@
1
+ import { UriTemplate } from '@modelcontextprotocol/sdk/shared/uriTemplate.js';
2
+
3
+ /** SDK v1 matches {?a,b} as two required, ordered parameters, unlike its expansion. */
4
+ export class ContextUriTemplate extends UriTemplate {
5
+ constructor() {
6
+ super('canvas://context{?budget,consumer,since}');
7
+ }
8
+
9
+ override match(uri: string): Record<string, string> | null {
10
+ if (!URL.canParse(uri)) return null;
11
+ const url = new URL(uri);
12
+ if (
13
+ url.protocol !== 'canvas:' ||
14
+ url.host !== 'context' ||
15
+ url.pathname ||
16
+ url.hash ||
17
+ url.username ||
18
+ url.password
19
+ )
20
+ return null;
21
+ const variables: Record<string, string> = {};
22
+ for (const [key, value] of url.searchParams) {
23
+ if (!['budget', 'consumer', 'since'].includes(key) || key in variables) return null;
24
+ variables[key] = value;
25
+ }
26
+ return variables;
27
+ }
28
+ }
package/src/mcp/server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  import { readFileSync } from 'node:fs';
24
24
  import { dirname, join } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
26
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
27
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
28
  import { z } from 'zod';
29
29
  import { AX_SOURCES } from '../server/operations/ops/ax-shared.js';
@@ -31,6 +31,8 @@ import { canvasState, describeCanvasSchema, stopCanvasServer } from '../server/i
31
31
  import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
32
32
  import { buildPendingAxActivity } from '../server/ax-state.js';
33
33
  import { registerOperationTools, registerCompositeTools } from '../server/operations/index.js';
34
+ import { agentSourceLabel } from '../server/operations/invoker.js';
35
+ import { contextReadFromPayload } from '../server/context-reads.js';
34
36
  import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
35
37
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
36
38
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
@@ -42,6 +44,7 @@ import {
42
44
  } from '../server/canvas-serialization.js';
43
45
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
44
46
  import { registerSkillExtension } from './skills.js';
47
+ import { ContextUriTemplate } from './context-uri-template.js';
45
48
 
46
49
  let canvas: CanvasAccess | null = null;
47
50
  let resourceNotificationServer: McpServer | null = null;
@@ -102,7 +105,9 @@ function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' | 'ax-tim
102
105
  server.server.sendResourceUpdated({ uri: 'canvas://ax-delivery' });
103
106
  }
104
107
  server.server.sendResourceUpdated({ uri: 'canvas://layout' });
108
+ server.server.sendResourceUpdated({ uri: 'canvas://boards' });
105
109
  server.server.sendResourceUpdated({ uri: 'canvas://summary' });
110
+ server.server.sendResourceUpdated({ uri: 'canvas://context' });
106
111
  server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
107
112
  server.server.sendResourceUpdated({ uri: 'canvas://history' });
108
113
  server.server.sendResourceUpdated({ uri: 'canvas://code-graph' });
@@ -223,6 +228,36 @@ export async function startMcpServer(): Promise<void> {
223
228
  resourceNotificationServer = server;
224
229
  registerSkillExtension(server);
225
230
 
231
+ /**
232
+ * Records what the agent actually received from a context resource or
233
+ * prompt (plan-011). Instrumentation must never fail the read itself.
234
+ */
235
+ async function recordContextRead(
236
+ c: CanvasAccess,
237
+ channel: 'mcp-resource' | 'mcp-prompt',
238
+ resource: string,
239
+ text: string,
240
+ target: { boardId: string | null; pinnedNodeIds: string[] },
241
+ ): Promise<void> {
242
+ try {
243
+ const read = contextReadFromPayload(
244
+ {
245
+ channel,
246
+ resource,
247
+ source: agentSourceLabel('mcp'),
248
+ consumer: server.server.getClientVersion()?.name ?? null,
249
+ agentId: null,
250
+ pinnedNodeIds: target.pinnedNodeIds,
251
+ boardId: target.boardId,
252
+ },
253
+ text,
254
+ );
255
+ await c.recordContextRead(read);
256
+ } catch (error) {
257
+ console.error('[pmx-canvas mcp] recording a context read failed:', error);
258
+ }
259
+ }
260
+
226
261
  // ── Operation-registry tools (plan-005) ────────────────────────
227
262
  // Standalone tools are registered from the shared operation registry for
228
263
  // every op not folded into a composite (registerOperationTools skips
@@ -478,8 +513,10 @@ export async function startMcpServer(): Promise<void> {
478
513
  },
479
514
  async () => {
480
515
  const c = await ensureCanvas();
481
- const pinnedIds = new Set(await c.getPinnedNodeIds());
482
- const layout = await c.getLayout();
516
+ const readTarget = await c.prepareContextRead();
517
+ const board = readTarget.boardId ?? undefined;
518
+ const pinnedIds = new Set(readTarget.pinnedNodeIds);
519
+ const layout = await c.getLayout(board);
483
520
 
484
521
  const pinnedNodes = layout.nodes.filter((n) => pinnedIds.has(n.id));
485
522
  const pinnedEdges = layout.edges.filter((e) => pinnedIds.has(e.from) && pinnedIds.has(e.to));
@@ -510,12 +547,41 @@ export async function startMcpServer(): Promise<void> {
510
547
  })),
511
548
  };
512
549
 
550
+ const text = JSON.stringify(context, null, 2);
551
+ await recordContextRead(c, 'mcp-resource', 'canvas://pinned-context', text, readTarget);
513
552
  return {
514
553
  contents: [
515
554
  {
516
555
  uri: 'canvas://pinned-context',
517
556
  mimeType: 'application/json',
518
- text: JSON.stringify(context, null, 2),
557
+ text,
558
+ },
559
+ ],
560
+ };
561
+ },
562
+ );
563
+
564
+ server.resource(
565
+ 'context',
566
+ new ResourceTemplate(new ContextUriTemplate(), { list: undefined }),
567
+ {
568
+ description:
569
+ 'Budgeted cross-board context brief. budget is a maximum number of UTF-16 characters; consumer selects a durable board-scoped delivery cursor, while since requests an explicit revision.',
570
+ mimeType: 'application/json',
571
+ },
572
+ async (uri) => {
573
+ const c = await ensureCanvas();
574
+ const input: Record<string, unknown> = {};
575
+ if (uri.searchParams.has('consumer')) input.consumer = uri.searchParams.get('consumer');
576
+ if (uri.searchParams.has('budget')) input.budget = uri.searchParams.get('budget');
577
+ if (uri.searchParams.has('since')) input.since = uri.searchParams.get('since');
578
+ const document = await c.invoker().invoke('context.get', input);
579
+ return {
580
+ contents: [
581
+ {
582
+ uri: uri.href,
583
+ mimeType: 'application/json',
584
+ text: JSON.stringify(document),
519
585
  },
520
586
  ],
521
587
  };
@@ -555,12 +621,17 @@ export async function startMcpServer(): Promise<void> {
555
621
  async () => {
556
622
  const c = await ensureCanvas();
557
623
  const context = await c.getAxContext({ consumer: 'mcp' });
624
+ const text = JSON.stringify(context, null, 2);
625
+ await recordContextRead(c, 'mcp-resource', 'canvas://ax-context', text, {
626
+ boardId: context.boardId,
627
+ pinnedNodeIds: context.pinned.nodeIds,
628
+ });
558
629
  return {
559
630
  contents: [
560
631
  {
561
632
  uri: 'canvas://ax-context',
562
633
  mimeType: 'application/json',
563
- text: JSON.stringify(context, null, 2),
634
+ text,
564
635
  },
565
636
  ],
566
637
  };
@@ -678,11 +749,16 @@ export async function startMcpServer(): Promise<void> {
678
749
  async () => {
679
750
  const c = await ensureCanvas();
680
751
  const context = await c.getAxContext({ consumer: 'mcp' });
752
+ const text = `Current PMX Canvas context:\n\n${JSON.stringify(context, null, 2)}`;
753
+ await recordContextRead(c, 'mcp-prompt', 'pmx-current-context', text, {
754
+ boardId: context.boardId,
755
+ pinnedNodeIds: context.pinned.nodeIds,
756
+ });
681
757
  return {
682
758
  messages: [
683
759
  {
684
760
  role: 'user',
685
- content: { type: 'text', text: `Current PMX Canvas context:\n\n${JSON.stringify(context, null, 2)}` },
761
+ content: { type: 'text', text },
686
762
  },
687
763
  ],
688
764
  };
@@ -700,19 +776,40 @@ export async function startMcpServer(): Promise<void> {
700
776
  },
701
777
  async () => {
702
778
  const c = await ensureCanvas();
703
- const layout = agentSafeFullLayoutPayload(await c.getLayout());
779
+ const readTarget = await c.prepareContextRead();
780
+ const layout = agentSafeFullLayoutPayload(await c.getLayout(readTarget.boardId ?? undefined));
781
+ const text = JSON.stringify(layout, null, 2);
782
+ await recordContextRead(c, 'mcp-resource', 'canvas://layout', text, readTarget);
704
783
  return {
705
784
  contents: [
706
785
  {
707
786
  uri: 'canvas://layout',
708
787
  mimeType: 'application/json',
709
- text: JSON.stringify(layout, null, 2),
788
+ text,
710
789
  },
711
790
  ],
712
791
  };
713
792
  },
714
793
  );
715
794
 
795
+ server.resource(
796
+ 'canvas-boards',
797
+ 'canvas://boards',
798
+ {
799
+ description:
800
+ 'The boards in this workspace with their categories, most recently opened first, and which one the ' +
801
+ 'human has open (activeBoardId; null = Home). Your writes go to the open board; ask the human to open another.',
802
+ mimeType: 'application/json',
803
+ },
804
+ async () => {
805
+ const c = await ensureCanvas();
806
+ const boards = await c.invoker().invoke('board.list', {});
807
+ return {
808
+ contents: [{ uri: 'canvas://boards', mimeType: 'application/json', text: JSON.stringify(boards, null, 2) }],
809
+ };
810
+ },
811
+ );
812
+
716
813
  server.resource(
717
814
  'canvas-summary',
718
815
  'canvas://summary',
@@ -724,12 +821,19 @@ export async function startMcpServer(): Promise<void> {
724
821
  },
725
822
  async () => {
726
823
  const c = await ensureCanvas();
824
+ const readTarget = await c.prepareContextRead();
825
+ const text = JSON.stringify(
826
+ buildSummaryFromLayout(await c.getLayout(readTarget.boardId ?? undefined), readTarget.pinnedNodeIds),
827
+ null,
828
+ 2,
829
+ );
830
+ await recordContextRead(c, 'mcp-resource', 'canvas://summary', text, readTarget);
727
831
  return {
728
832
  contents: [
729
833
  {
730
834
  uri: 'canvas://summary',
731
835
  mimeType: 'application/json',
732
- text: JSON.stringify(buildSummaryFromLayout(await c.getLayout(), await c.getPinnedNodeIds()), null, 2),
836
+ text,
733
837
  },
734
838
  ],
735
839
  };
@@ -750,19 +854,22 @@ export async function startMcpServer(): Promise<void> {
750
854
  },
751
855
  async () => {
752
856
  const c = await ensureCanvas();
753
- const layout = await c.getLayout();
857
+ const readTarget = await c.prepareContextRead();
858
+ const layout = await c.getLayout(readTarget.boardId ?? undefined);
754
859
  const spatial = buildSpatialContext(
755
860
  layout.nodes,
756
861
  layout.edges,
757
- new Set(await c.getPinnedNodeIds()),
862
+ new Set(readTarget.pinnedNodeIds),
758
863
  layout.annotations ?? [],
759
864
  );
865
+ const text = JSON.stringify(spatial, null, 2);
866
+ await recordContextRead(c, 'mcp-resource', 'canvas://spatial-context', text, readTarget);
760
867
  return {
761
868
  contents: [
762
869
  {
763
870
  uri: 'canvas://spatial-context',
764
871
  mimeType: 'application/json',
765
- text: JSON.stringify(spatial, null, 2),
872
+ text,
766
873
  },
767
874
  ],
768
875
  };
@@ -898,10 +1005,10 @@ export async function startMcpServer(): Promise<void> {
898
1005
  const shutdown = (): void => {
899
1006
  try {
900
1007
  stopCanvasServer();
901
- } catch {
902
- // Exit regardless — a failed cleanup must not resurrect the orphan.
1008
+ process.exit(0);
1009
+ } catch (error) {
1010
+ console.error('Shutdown refused: canvas changes could not be saved. Fix persistence and retry shutdown.', error);
903
1011
  }
904
- process.exit(0);
905
1012
  };
906
1013
  server.server.onclose = shutdown;
907
1014
  process.stdin.on('end', shutdown);
@@ -38,14 +38,62 @@ function truncateContextText(text: string, maxLength: number): string {
38
38
  return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
39
39
  }
40
40
 
41
- function stringifyContextValue(value: unknown, maxLength: number): string {
42
- if (typeof value === 'string') return truncateContextText(value, maxLength);
43
- if (value === null || value === undefined) return '';
44
- try {
45
- return truncateContextText(JSON.stringify(value), maxLength);
46
- } catch {
47
- return '';
48
- }
41
+ function record(value: unknown): Record<string, unknown> {
42
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
43
+ }
44
+
45
+ /** Only for semantic values (table rows, ledger entries), never renderer configuration. */
46
+ function describeValues(value: unknown): string {
47
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
48
+ if (Array.isArray(value)) return value.map(describeValues).filter(Boolean).join('\n');
49
+ return Object.entries(record(value))
50
+ .map(([key, entry]) => {
51
+ const text = describeValues(entry);
52
+ return text ? `${key}: ${text}` : '';
53
+ })
54
+ .filter(Boolean)
55
+ .join('; ');
56
+ }
57
+
58
+ function textFields(data: Record<string, unknown>, keys: string[]): string {
59
+ return keys
60
+ .map((key) => {
61
+ const value = data[key];
62
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : '';
63
+ })
64
+ .filter(Boolean)
65
+ .join('\n');
66
+ }
67
+
68
+ function summarizeGraph(data: Record<string, unknown>): string {
69
+ const kind = typeof data.graphType === 'string' ? data.graphType : 'chart';
70
+ const rows = describeValues(data.data);
71
+ return `Chart: ${kind}.\n${textFields(data, ['title', 'beforeLabel', 'afterLabel'])}\n${rows || 'No chart values available.'}`;
72
+ }
73
+
74
+ function summarizeSpec(value: unknown): string {
75
+ const spec = record(value);
76
+ const elements = record(spec.elements);
77
+ const visited = new Set<string>();
78
+ const parts: string[] = [];
79
+ // Read only reachable elements, in their authored order. Bindings/conditions are
80
+ // not evaluated here, so this is explicitly authored content, not a DOM report.
81
+ const visit = (id: string): void => {
82
+ if (visited.has(id)) return;
83
+ visited.add(id);
84
+ const element = record(elements[id]);
85
+ const props = record(element.props);
86
+ const text = textFields(props, ['title', 'description', 'text', 'content', 'label', 'value', 'caption']);
87
+ if (text) parts.push(text);
88
+ if (Array.isArray(props.data)) parts.push(describeValues(props.data));
89
+ if (Array.isArray(element.children)) {
90
+ for (const child of element.children) if (typeof child === 'string') visit(child);
91
+ }
92
+ };
93
+ if (typeof spec.root === 'string') visit(spec.root);
94
+ return parts.length
95
+ ? `Authored surface content (dynamic visibility not evaluated):\n${parts.join('\n')}`
96
+ : 'Interactive surface. No static text available; inspect the rendered surface.';
49
97
  }
50
98
 
51
99
  function summarizeWebpageData(data: Record<string, unknown>, maxLength: number): string {
@@ -81,10 +129,10 @@ function summarizeExtAppInput(toolInput: unknown): string {
81
129
  if (typeof toolInput !== 'object' || Array.isArray(toolInput)) return '';
82
130
  const elements = (toolInput as Record<string, unknown>).elements;
83
131
  if (Array.isArray(elements)) {
84
- return `Diagram elements: ${elements.length}`;
132
+ const labels = elements.map((element) => textFields(record(element), ['text', 'label'])).filter(Boolean);
133
+ return `Diagram elements: ${elements.length}\n${labels.length ? `Diagram labels: ${labels.join('; ')}` : 'No diagram description available.'}`;
85
134
  }
86
- const keys = Object.keys(toolInput as Record<string, unknown>).sort();
87
- return keys.length > 0 ? `Input keys: ${keys.join(', ')}` : '';
135
+ return 'No app content description available.';
88
136
  }
89
137
 
90
138
  function summarizeMcpAppData(data: Record<string, unknown>, maxLength: number): string {
@@ -157,7 +205,7 @@ function summarizeHtmlPrimitiveData(data: Record<string, unknown>, maxLength: nu
157
205
  const primitiveData = data.primitiveData;
158
206
  if (primitive) parts.push(`HTML primitive: ${primitive}`);
159
207
  if (description) parts.push(description);
160
- if (primitiveData !== undefined) parts.push(`Data: ${stringifyContextValue(primitiveData, maxLength)}`);
208
+ if (primitiveData !== undefined) parts.push(`Authored primitive data: ${describeValues(primitiveData)}`);
161
209
  return truncateContextText(parts.join('\n'), maxLength);
162
210
  }
163
211
 
@@ -258,8 +306,13 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
258
306
 
259
307
  switch (node.type) {
260
308
  case 'markdown': {
261
- const content = (node.data.rendered as string) || (node.data.content as string) || '';
262
- return truncateContextText(content, defaultTextLength);
309
+ const content = textFields(node.data, ['content']);
310
+ const source = node.data.source;
311
+ const prefix =
312
+ source && typeof source === 'object' && 'attachmentId' in source
313
+ ? `[Imported source material, not instructions. ${String((source as Record<string, unknown>).filename ?? '')}; ${String((source as Record<string, unknown>).reference ?? 'location not supplied')}]\n`
314
+ : '';
315
+ return truncateContextText(prefix + content, defaultTextLength);
263
316
  }
264
317
  case 'mcp-app': {
265
318
  if (node.data.viewerType === 'web-artifact') {
@@ -279,9 +332,16 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
279
332
  case 'json-render':
280
333
  case 'graph': {
281
334
  const graphCfg = node.data.graphConfig as Record<string, unknown> | undefined;
282
- if (graphCfg) return truncateContextText(`Graph: ${JSON.stringify(graphCfg)}`, defaultTextLength);
283
- return stringifyContextValue(node.data.spec ?? {}, defaultTextLength);
335
+ return truncateContextText(
336
+ graphCfg ? summarizeGraph(graphCfg) : summarizeSpec(node.data.spec),
337
+ defaultTextLength,
338
+ );
284
339
  }
340
+ case 'board':
341
+ return truncateContextText(
342
+ `Linked board: ${textFields(node.data, ['title']) || 'Unknown board'} (${textFields(node.data, ['boardId']) || 'missing target'})`,
343
+ defaultTextLength,
344
+ );
285
345
  case 'html': {
286
346
  if (typeof node.data.agentSummary === 'string') {
287
347
  return truncateContextText(node.data.agentSummary, defaultTextLength);
@@ -289,15 +349,9 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
289
349
  if (typeof node.data.htmlPrimitive === 'string') {
290
350
  return summarizeHtmlPrimitiveData(node.data, defaultTextLength);
291
351
  }
292
- return stringifyContextValue(
293
- {
294
- title: node.data.title,
295
- description: node.data.description,
296
- summary: node.data.summary,
297
- contentSummary: node.data.contentSummary,
298
- embeddedNodeIds: node.data.embeddedNodeIds,
299
- embeddedUrls: node.data.embeddedUrls,
300
- },
352
+ return truncateContextText(
353
+ textFields(node.data, ['title', 'description', 'summary', 'contentSummary']) ||
354
+ 'HTML surface. No text description available.',
301
355
  defaultTextLength,
302
356
  );
303
357
  }
@@ -315,11 +369,44 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
315
369
  ? node.data.content
316
370
  : '';
317
371
  const prefix = path ? `Path: ${path}\n\n` : '';
318
- const remaining = Math.max(0, defaultTextLength - prefix.length);
319
- return `${prefix}${truncateContextText(fileContent, remaining)}`.trim();
372
+ return truncateContextText(`${prefix}${fileContent}`, defaultTextLength);
320
373
  }
321
- default:
322
- return stringifyContextValue(node.data, defaultTextLength);
374
+ case 'status':
375
+ return truncateContextText(
376
+ `Status: ${textFields(node.data, ['phase']) || textFields(node.data, ['content']) || textFields(node.data, ['status']) || 'unknown'}\n${textFields(node.data, ['detail', 'message', 'activeTool'])}`,
377
+ defaultTextLength,
378
+ );
379
+ case 'context':
380
+ return truncateContextText(
381
+ `Context:\n${textFields(node.data, ['content', 'path'])}\n${Array.isArray(node.data.cards) ? node.data.cards.map((card) => textFields(record(card), ['title', 'label', 'summary', 'path', 'state'])).join('\n') : ''}`,
382
+ defaultTextLength,
383
+ );
384
+ case 'ledger': {
385
+ const { title: _title, __type: _type, strictSize: _size, arrangeLocked: _locked, ...entries } = node.data;
386
+ return truncateContextText(`Ledger:\n${describeValues(entries)}`, defaultTextLength);
387
+ }
388
+ case 'trace':
389
+ return truncateContextText(
390
+ `Tool trace:\n${textFields(node.data, ['toolName', 'status', 'resultSummary', 'content', 'error'])}`,
391
+ defaultTextLength,
392
+ );
393
+ case 'diff':
394
+ case 'mermaid':
395
+ return truncateContextText(
396
+ `${node.type === 'diff' ? 'Diff' : 'Mermaid diagram source'}:\n${textFields(node.data, ['content'])}`,
397
+ defaultTextLength,
398
+ );
399
+ case 'image':
400
+ return truncateContextText(
401
+ textFields(node.data, ['alt', 'caption', 'description']) ||
402
+ 'No image description available; inspect the image.',
403
+ defaultTextLength,
404
+ );
405
+ case 'group':
406
+ return truncateContextText(
407
+ `Group: ${textFields(node.data, ['title']) || 'Untitled'}. ${Array.isArray(node.data.children) ? node.data.children.length : 0} cards.`,
408
+ defaultTextLength,
409
+ );
323
410
  }
324
411
  }
325
412
 
@@ -0,0 +1,29 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ export type AttributionActor = 'human' | 'agent' | 'system' | 'unknown';
4
+
5
+ export interface ActorAttribution {
6
+ actor: AttributionActor;
7
+ source: string;
8
+ agentId?: string;
9
+ }
10
+
11
+ const actors = new AsyncLocalStorage<ActorAttribution | null>();
12
+
13
+ export function setCurrentActor(actor: ActorAttribution | null): void {
14
+ actors.enterWith(actor);
15
+ }
16
+
17
+ export function withCurrentActor<T>(actor: ActorAttribution, action: () => T): T {
18
+ return actors.run(actor, action);
19
+ }
20
+
21
+ /** Direct state/SDK writes are agents unless an operation installed a narrower context. */
22
+ export function currentActor(fallbackSource = 'sdk'): ActorAttribution {
23
+ const current = actors.getStore();
24
+ return current ? { ...current } : { actor: 'agent', source: fallbackSource };
25
+ }
26
+
27
+ export function unknownActor(): ActorAttribution {
28
+ return { actor: 'unknown', source: 'unknown' };
29
+ }