pmx-canvas 0.7.3 → 0.8.2

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 (244) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +64 -0
  10. package/Readme.md +21 -7
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +726 -674
  23. package/dist/canvas/index.js +90 -88
  24. package/dist/canvas/mcp-app.html +7879 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +81 -1
  28. package/dist/json-render/index.js +1 -1
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  31. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  32. package/dist/types/client/icons.d.ts +1 -2
  33. package/dist/types/client/mcp-app/index.d.ts +1 -0
  34. package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
  35. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  36. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  37. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  38. package/dist/types/client/state/attention-store.d.ts +0 -5
  39. package/dist/types/client/state/context-status-store.d.ts +53 -0
  40. package/dist/types/client/state/intent-bridge.d.ts +3 -0
  41. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  42. package/dist/types/client/state/presence-store.d.ts +5 -0
  43. package/dist/types/client/state/session-store.d.ts +33 -3
  44. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  45. package/dist/types/client/theme/tokens.d.ts +1 -3
  46. package/dist/types/client/types.d.ts +9 -0
  47. package/dist/types/json-render/server.d.ts +1 -1
  48. package/dist/types/mcp/app-server.d.ts +3 -0
  49. package/dist/types/mcp/canvas-access.d.ts +1 -1
  50. package/dist/types/mcp/canvas-app.d.ts +4 -0
  51. package/dist/types/mcp/workbench-app.d.ts +4 -0
  52. package/dist/types/server/agent-presence.d.ts +1 -1
  53. package/dist/types/server/ax-state.d.ts +1 -1
  54. package/dist/types/server/canvas-db.d.ts +8 -0
  55. package/dist/types/server/canvas-geometry.d.ts +25 -0
  56. package/dist/types/server/canvas-operations.d.ts +1 -11
  57. package/dist/types/server/canvas-state.d.ts +25 -3
  58. package/dist/types/server/context-brief.d.ts +8 -1
  59. package/dist/types/server/context-reads.d.ts +30 -7
  60. package/dist/types/server/index.d.ts +6 -1
  61. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  62. package/dist/types/server/operations/http.d.ts +2 -0
  63. package/dist/types/server/operations/invoker.d.ts +4 -2
  64. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  65. package/dist/types/server/server.d.ts +1 -0
  66. package/dist/types/server/spatial-analysis.d.ts +2 -0
  67. package/dist/types/shared/agent-context.d.ts +22 -0
  68. package/dist/types/shared/agent-presence.d.ts +3 -10
  69. package/dist/types/shared/canvas-app.d.ts +54 -0
  70. package/dist/types/shared/themes.d.ts +1 -1
  71. package/docs/RELEASE.md +7 -3
  72. package/docs/agent-plugin.md +123 -0
  73. package/docs/ax-state-contract.md +40 -0
  74. package/docs/cli.md +7 -2
  75. package/docs/design/AgentContext.dc.html +186 -0
  76. package/docs/design/Before.dc.html +208 -0
  77. package/docs/design/BoardPins.dc.html +151 -0
  78. package/docs/design/Chrome.dc.html +211 -0
  79. package/docs/design/Context.dc.html +239 -0
  80. package/docs/design/Flows.dc.html +181 -0
  81. package/docs/design/Home.dc.html +209 -0
  82. package/docs/design/HomeGraph.dc.html +211 -0
  83. package/docs/design/HomeMap.dc.html +117 -0
  84. package/docs/design/HomeTeam.dc.html +176 -0
  85. package/docs/design/Light.dc.html +28 -0
  86. package/docs/design/Main.dc.html +280 -0
  87. package/docs/design/NearPin.dc.html +260 -0
  88. package/docs/design/Pane600.dc.html +120 -0
  89. package/docs/design/Relations.dc.html +297 -0
  90. package/docs/design/Themes.dc.html +121 -0
  91. package/docs/design/TokenSystem.dc.html +136 -0
  92. package/docs/design/Tokens.dc.html +163 -0
  93. package/docs/design/Types1.dc.html +177 -0
  94. package/docs/design/Types2.dc.html +226 -0
  95. package/docs/design/Types3.dc.html +195 -0
  96. package/docs/design/Uses.dc.html +152 -0
  97. package/docs/design/Vision.dc.html +116 -0
  98. package/docs/design/Zoom.dc.html +82 -0
  99. package/docs/design/canvas.json +274 -0
  100. package/docs/design.md +217 -0
  101. package/docs/environment.md +1 -1
  102. package/docs/http-api.md +86 -16
  103. package/docs/mcp.md +202 -4
  104. package/docs/node-types.md +34 -10
  105. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  106. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  107. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  108. package/docs/product-vision-2026-09.md +47 -7
  109. package/docs/screenshot.png +0 -0
  110. package/docs/sdk.md +1 -0
  111. package/package.json +10 -4
  112. package/plugins/pmx-canvas/LICENSE +21 -0
  113. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  114. package/plugins/pmx-canvas/assets/preview.png +0 -0
  115. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  116. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  117. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  118. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  119. package/plugins/pmx-canvas/mcp.json +12 -0
  120. package/plugins/pmx-canvas/plugin.json +24 -0
  121. package/plugins/pmx-canvas/runtime.json +3 -0
  122. package/plugins/pmx-canvas/runtime.mjs +194 -0
  123. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  124. package/plugins/pmx-canvas/tools.json +2560 -0
  125. package/skills/pmx-canvas/SKILL.md +29 -9
  126. package/skills/pmx-canvas/evals/evals.json +62 -0
  127. package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
  128. package/skills/pmx-canvas/references/full-reference.md +11 -7
  129. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  130. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  131. package/skills/pmx-canvas-testing/SKILL.md +5 -4
  132. package/src/cli/commands/copilot.ts +5 -0
  133. package/src/cli/commands/nodes.ts +3 -2
  134. package/src/cli/commands/view.ts +1 -1
  135. package/src/cli/index.ts +10 -4
  136. package/src/client/App.tsx +6 -1
  137. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  138. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  139. package/src/client/canvas/CanvasNode.tsx +32 -49
  140. package/src/client/canvas/CanvasViewport.tsx +0 -2
  141. package/src/client/canvas/CommandPalette.tsx +3 -2
  142. package/src/client/canvas/ContextMenu.tsx +11 -0
  143. package/src/client/canvas/EdgeLayer.tsx +1 -1
  144. package/src/client/canvas/ExportDialog.tsx +33 -6
  145. package/src/client/canvas/IntentLayer.tsx +2 -2
  146. package/src/client/canvas/Minimap.tsx +12 -7
  147. package/src/client/canvas/NodeContextMark.tsx +156 -0
  148. package/src/client/canvas/SessionPanel.tsx +4 -7
  149. package/src/client/canvas/SessionReceipt.tsx +114 -17
  150. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  151. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  152. package/src/client/canvas/ToolRail.tsx +12 -9
  153. package/src/client/canvas/TopBar.tsx +16 -11
  154. package/src/client/icons.tsx +2 -12
  155. package/src/client/mcp-app/index.tsx +137 -0
  156. package/src/client/mcp-app/style.css +10 -0
  157. package/src/client/nodes/AttachmentNode.tsx +8 -1
  158. package/src/client/nodes/AxStepControls.tsx +1 -1
  159. package/src/client/nodes/ContextNode.tsx +9 -9
  160. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  161. package/src/client/nodes/FileNode.tsx +8 -5
  162. package/src/client/nodes/HtmlNode.tsx +12 -2
  163. package/src/client/nodes/ImageNode.tsx +4 -2
  164. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  165. package/src/client/nodes/LedgerNode.tsx +2 -2
  166. package/src/client/nodes/MarkdownNode.tsx +44 -9
  167. package/src/client/nodes/McpAppNode.tsx +6 -4
  168. package/src/client/nodes/PromptNode.tsx +5 -17
  169. package/src/client/nodes/ResponseNode.tsx +3 -15
  170. package/src/client/nodes/StatusNode.tsx +2 -2
  171. package/src/client/nodes/StatusSummary.tsx +3 -1
  172. package/src/client/nodes/TraceNode.tsx +6 -4
  173. package/src/client/nodes/WebpageNode.tsx +1 -1
  174. package/src/client/nodes/ax-node-actions.ts +2 -2
  175. package/src/client/nodes/iframe-document-url.ts +3 -2
  176. package/src/client/nodes/markdown-sanitize.ts +10 -0
  177. package/src/client/nodes/surface-url.ts +10 -0
  178. package/src/client/nodes/use-surface-frame.ts +3 -1
  179. package/src/client/nodes/use-workbench-asset.ts +26 -0
  180. package/src/client/state/attention-bridge.ts +0 -8
  181. package/src/client/state/attention-store.ts +0 -17
  182. package/src/client/state/canvas-store.ts +4 -2
  183. package/src/client/state/context-status-store.ts +66 -0
  184. package/src/client/state/human-store.ts +2 -0
  185. package/src/client/state/iframe-mode.ts +5 -0
  186. package/src/client/state/intent-bridge.ts +10 -4
  187. package/src/client/state/near-pin-store.ts +53 -0
  188. package/src/client/state/presence-store.ts +5 -10
  189. package/src/client/state/session-store.ts +106 -9
  190. package/src/client/state/sse-bridge.ts +26 -7
  191. package/src/client/state/theme-override.ts +1 -1
  192. package/src/client/state/workbench-transport.ts +36 -0
  193. package/src/client/theme/global.css +726 -674
  194. package/src/client/theme/surface-theme.css +151 -46
  195. package/src/client/theme/tokens.ts +7 -11
  196. package/src/client/types.ts +10 -0
  197. package/src/json-render/renderer/index.css +6 -1
  198. package/src/json-render/renderer/index.tsx +0 -1
  199. package/src/json-render/server.ts +71 -14
  200. package/src/mcp/app-server.ts +64 -0
  201. package/src/mcp/canvas-access.ts +14 -11
  202. package/src/mcp/canvas-app.ts +318 -0
  203. package/src/mcp/server.ts +1 -1
  204. package/src/mcp/workbench-app.ts +211 -0
  205. package/src/server/agent-presence.ts +45 -6
  206. package/src/server/ax-context.ts +1 -1
  207. package/src/server/ax-state.ts +1 -1
  208. package/src/server/board-export.ts +5 -2
  209. package/src/server/canvas-db.ts +46 -12
  210. package/src/server/canvas-geometry.ts +64 -0
  211. package/src/server/canvas-operations.ts +7 -25
  212. package/src/server/canvas-state.ts +105 -15
  213. package/src/server/canvas-validation.ts +19 -4
  214. package/src/server/context-brief.ts +39 -2
  215. package/src/server/context-reads.ts +87 -15
  216. package/src/server/demo-state.json +31 -31
  217. package/src/server/html-surface.ts +4 -5
  218. package/src/server/index.ts +41 -5
  219. package/src/server/mcp-app-runtime.ts +11 -0
  220. package/src/server/operations/composites.ts +4 -3
  221. package/src/server/operations/http.ts +4 -4
  222. package/src/server/operations/invoker.ts +13 -4
  223. package/src/server/operations/mcp.ts +6 -0
  224. package/src/server/operations/ops/ax-read.ts +1 -1
  225. package/src/server/operations/ops/ax-timeline.ts +37 -0
  226. package/src/server/operations/ops/boards.ts +1 -1
  227. package/src/server/operations/ops/canvas-wire.ts +3 -6
  228. package/src/server/operations/ops/intent.ts +31 -2
  229. package/src/server/operations/ops/json-render.ts +6 -2
  230. package/src/server/operations/ops/nodes.ts +8 -76
  231. package/src/server/operations/ops/query.ts +8 -2
  232. package/src/server/operations/ops/snapshots.ts +4 -1
  233. package/src/server/operations/ops/viewport.ts +29 -5
  234. package/src/server/operations/registry.ts +41 -5
  235. package/src/server/scope-fence.ts +55 -10
  236. package/src/server/server.ts +83 -5
  237. package/src/server/spatial-analysis.ts +2 -2
  238. package/src/server/workboard.ts +6 -1
  239. package/src/{server → shared}/agent-context.ts +2 -2
  240. package/src/shared/agent-presence.ts +3 -15
  241. package/src/shared/canvas-app.ts +62 -0
  242. package/src/shared/themes.ts +5 -5
  243. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  244. package/src/client/canvas/kind-colors.ts +0 -23
@@ -0,0 +1,318 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
6
+ import { z } from 'zod';
7
+ import { summarizeNodeForAgentContext } from '../shared/agent-context.js';
8
+ import type { CanvasNodeState } from '../server/canvas-state.js';
9
+ import { OperationError } from '../server/operations/index.js';
10
+ import { CANVAS_APP_URI, type CanvasAppSnapshot } from '../shared/canvas-app.js';
11
+ import type { CanvasAccess } from './canvas-access.js';
12
+
13
+ const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
14
+ const additive = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
15
+ const editing = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
16
+ const boardId = z.string().min(1).describe('Active board id from pmx_read_canvas; refuses a stale board.');
17
+ const boardListSchema = z.array(z.object({ id: z.string(), name: z.string() }));
18
+ const summaryShape = {
19
+ boardId: z.string().nullable(),
20
+ boardName: z.string(),
21
+ boards: boardListSchema,
22
+ revision: z.string(),
23
+ nodeCount: z.number(),
24
+ edgeCount: z.number(),
25
+ pinnedNodeIds: z.array(z.string()),
26
+ nodes: z.array(z.object({ id: z.string(), title: z.string(), type: z.string(), contentRevision: z.number() })),
27
+ };
28
+
29
+ function editableContent(node: CanvasNodeState): string | undefined {
30
+ return node.type === 'markdown' &&
31
+ !node.data.path &&
32
+ typeof node.data.content === 'string' &&
33
+ node.data.content.length <= 50_000
34
+ ? node.data.content
35
+ : undefined;
36
+ }
37
+
38
+ async function readSnapshot(canvas: CanvasAccess): Promise<CanvasAppSnapshot> {
39
+ const target = await canvas.prepareContextRead();
40
+ const { boards } = z.object({ boards: boardListSchema }).parse(await canvas.invoker().invoke('board.list', {}));
41
+ if (!target.boardId) {
42
+ return {
43
+ boardId: null,
44
+ boardName: 'No board open',
45
+ boards,
46
+ revision: createHash('sha256').update(JSON.stringify(boards)).digest('hex'),
47
+ nodes: [],
48
+ edges: [],
49
+ pinnedNodeIds: [],
50
+ };
51
+ }
52
+ // Explicit board reads prevent a concurrent workbench switch mixing two boards.
53
+ const layout = await canvas.getLayout(target.boardId);
54
+ const info = z
55
+ .object({ board: z.object({ name: z.string() }) })
56
+ .parse(await canvas.invoker().invoke('board.get', { id: target.boardId }));
57
+ const projection = {
58
+ boardId: target.boardId,
59
+ boardName: info.board.name,
60
+ boards,
61
+ nodes: layout.nodes.map((node) => {
62
+ const src = node.data.src;
63
+ // No network fetches, local paths, SVG or active documents in the app iframe.
64
+ const image =
65
+ node.type === 'image' &&
66
+ typeof src === 'string' &&
67
+ src.length <= 1_400_000 &&
68
+ /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(src)
69
+ ? src
70
+ : undefined;
71
+ return {
72
+ id: node.id,
73
+ type: node.type,
74
+ title: typeof node.data.title === 'string' ? node.data.title : node.type,
75
+ text: summarizeNodeForAgentContext(node, { defaultTextLength: 8000, webpageTextLength: 8000 }),
76
+ contentRevision: node.contentRevision ?? 0,
77
+ editableContent: editableContent(node),
78
+ position: node.position,
79
+ size: node.size,
80
+ ...(image ? { image } : {}),
81
+ };
82
+ }),
83
+ edges: layout.edges.map(({ id, from, to, label }) => ({ id, from, to, label: label ?? '' })),
84
+ pinnedNodeIds: target.pinnedNodeIds,
85
+ };
86
+ return { ...projection, revision: createHash('sha256').update(JSON.stringify(projection)).digest('hex') };
87
+ }
88
+
89
+ function snapshotResult(snapshot: CanvasAppSnapshot): CallToolResult {
90
+ return {
91
+ content: [
92
+ { type: 'text', text: `${snapshot.boardName}: ${snapshot.nodes.length} nodes, ${snapshot.edges.length} edges.` },
93
+ ],
94
+ structuredContent: {
95
+ boardId: snapshot.boardId,
96
+ boardName: snapshot.boardName,
97
+ boards: snapshot.boards,
98
+ revision: snapshot.revision,
99
+ nodeCount: snapshot.nodes.length,
100
+ edgeCount: snapshot.edges.length,
101
+ pinnedNodeIds: snapshot.pinnedNodeIds,
102
+ nodes: snapshot.nodes.map(({ id, title, type, contentRevision }) => ({ id, title, type, contentRevision })),
103
+ },
104
+ // Full preview text and image bytes go to the app, not the model's tool transcript.
105
+ _meta: { canvas: snapshot },
106
+ };
107
+ }
108
+
109
+ async function toolResult(run: () => Promise<CallToolResult>): Promise<CallToolResult> {
110
+ try {
111
+ return await run();
112
+ } catch (error) {
113
+ if (error instanceof OperationError) {
114
+ return { isError: true, content: [{ type: 'text', text: error.message }] };
115
+ }
116
+ console.error('[canvas-app] tool failed', error);
117
+ return {
118
+ isError: true,
119
+ content: [{ type: 'text', text: 'Could not access the canvas. Check the PMX server logs.' }],
120
+ };
121
+ }
122
+ }
123
+
124
+ /** Focused app profile; deliberately does not register the general-purpose composites. */
125
+ export function registerCanvasApp(server: McpServer, ensureCanvas: () => Promise<CanvasAccess>): void {
126
+ const read = () => toolResult(async () => snapshotResult(await readSnapshot(await ensureCanvas())));
127
+ const invoke = (operation: string, input: Record<string, unknown>) =>
128
+ toolResult(async () => {
129
+ const result = await (await ensureCanvas()).invoker().invoke(operation, input);
130
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
131
+ });
132
+ registerAppTool(
133
+ server,
134
+ 'pmx_open_canvas',
135
+ {
136
+ title: 'PMX Canvas',
137
+ description: 'Open a live view of the shared PMX board. Select nodes to share context with the conversation.',
138
+ inputSchema: {},
139
+ outputSchema: summaryShape,
140
+ annotations: readOnly,
141
+ _meta: {
142
+ ui: { resourceUri: CANVAS_APP_URI },
143
+ // ChatGPT-specific extensions are confined to this launcher descriptor.
144
+ 'openai/ui': { entrypoints: [{ type: 'global' }, { type: 'thread' }], preferredModelDisplayMode: 'fullscreen' },
145
+ },
146
+ },
147
+ read,
148
+ );
149
+ registerAppTool(
150
+ server,
151
+ 'pmx_read_canvas',
152
+ {
153
+ title: 'Read PMX Canvas',
154
+ description:
155
+ 'Read the shared active board, node identifiers and context pins. Use pmx_read_node for full note content before editing. Does not change boards.',
156
+ inputSchema: {},
157
+ outputSchema: summaryShape,
158
+ annotations: readOnly,
159
+ _meta: { ui: { visibility: ['model', 'app'] } },
160
+ },
161
+ read,
162
+ );
163
+
164
+ server.registerTool(
165
+ 'pmx_read_node',
166
+ {
167
+ title: 'Read a complete PMX note',
168
+ description:
169
+ 'Read full editable Markdown, content revision, position and incident edges for one note. Always read this before replacing or appending content; selection context is truncated. Other node types, file-linked and oversized notes require the workbench.',
170
+ inputSchema: { boardId, id: z.string().min(1) },
171
+ annotations: readOnly,
172
+ },
173
+ (input) =>
174
+ toolResult(async () => {
175
+ const layout = await (await ensureCanvas()).getLayout(input.boardId);
176
+ const node = layout.nodes.find((entry) => entry.id === input.id);
177
+ const content = node && editableContent(node);
178
+ if (!node || content === undefined)
179
+ throw new OperationError('This node is not an editable standalone note. Use the PMX workbench.');
180
+ const result = {
181
+ boardId: input.boardId,
182
+ id: node.id,
183
+ type: node.type,
184
+ title: typeof node.data.title === 'string' ? node.data.title : node.type,
185
+ content,
186
+ contentRevision: node.contentRevision ?? 0,
187
+ position: node.position,
188
+ size: node.size,
189
+ edges: layout.edges
190
+ .filter((edge) => edge.from === node.id || edge.to === node.id)
191
+ .map(({ id, from, to, type, label }) => ({ id, from, to, type, label: label ?? '' })),
192
+ };
193
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
194
+ }),
195
+ );
196
+
197
+ server.registerTool(
198
+ 'pmx_add_note',
199
+ {
200
+ title: 'Add a PMX note',
201
+ description: 'Add a Markdown note to the shared active board at a canvas position. Existing nodes are preserved.',
202
+ inputSchema: {
203
+ boardId,
204
+ title: z.string().min(1).max(200),
205
+ content: z.string().max(50_000),
206
+ x: z.number().finite(),
207
+ y: z.number().finite(),
208
+ },
209
+ annotations: additive,
210
+ },
211
+ (input) =>
212
+ toolResult(async () => {
213
+ const canvas = await ensureCanvas();
214
+ const result = await canvas.invoker().invoke('node.add', { ...input, type: 'markdown' });
215
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
216
+ }),
217
+ );
218
+ server.registerTool(
219
+ 'pmx_connect_nodes',
220
+ {
221
+ title: 'Connect PMX nodes',
222
+ description: 'Add a labelled relationship between two nodes on the shared active board.',
223
+ inputSchema: {
224
+ boardId,
225
+ from: z.string().min(1),
226
+ to: z.string().min(1),
227
+ type: z.enum(['flow', 'depends-on', 'relation', 'references']),
228
+ label: z.string().max(500).optional(),
229
+ },
230
+ annotations: additive,
231
+ },
232
+ (input) =>
233
+ toolResult(async () => {
234
+ const canvas = await ensureCanvas();
235
+ const result = await canvas.invoker().invoke('edge.add', { ...input, expectedBoardId: input.boardId });
236
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
237
+ }),
238
+ );
239
+
240
+ server.registerTool(
241
+ 'pmx_create_board',
242
+ {
243
+ title: 'Create a PMX board',
244
+ description:
245
+ 'Create an empty board without switching the shared workspace. Open it explicitly with pmx_open_board.',
246
+ inputSchema: { name: z.string().trim().min(1).max(120) },
247
+ annotations: additive,
248
+ },
249
+ (input) => invoke('board.create', input),
250
+ );
251
+ server.registerTool(
252
+ 'pmx_open_board',
253
+ {
254
+ title: 'Switch the shared PMX board',
255
+ description: 'Switch the active board for ALL conversations and the workbench. Null opens Home.',
256
+ inputSchema: { id: z.string().min(1).nullable(), expectedBoardId: boardId.nullable() },
257
+ annotations: editing,
258
+ },
259
+ (input) => invoke('board.open', input),
260
+ );
261
+ server.registerTool(
262
+ 'pmx_pin_nodes',
263
+ {
264
+ title: 'Curate PMX context pins',
265
+ description: 'Add or remove context pins without replacing unrelated pins.',
266
+ inputSchema: { boardId, nodeIds: z.array(z.string().min(1)).min(1).max(20), mode: z.enum(['add', 'remove']) },
267
+ annotations: editing,
268
+ },
269
+ (input) => invoke('pin.set', { ...input, expectedBoardId: input.boardId }),
270
+ );
271
+ server.registerTool(
272
+ 'pmx_move_node',
273
+ {
274
+ title: 'Move a PMX node',
275
+ description: 'Set the position of an existing node. Moving a group also moves its children.',
276
+ inputSchema: { boardId, id: z.string().min(1), x: z.number().finite(), y: z.number().finite() },
277
+ annotations: editing,
278
+ },
279
+ (input) => invoke('node.update', { ...input, expectedBoardId: input.boardId }),
280
+ );
281
+ server.registerTool(
282
+ 'pmx_update_note',
283
+ {
284
+ title: 'Edit a PMX note',
285
+ description:
286
+ 'Replace standalone Markdown using full content and revision from pmx_read_node, never from a truncated selection excerpt. Refuses stale edits and file-linked notes.',
287
+ inputSchema: {
288
+ boardId,
289
+ id: z.string().min(1),
290
+ expectedContentRevision: z.number().int().nonnegative(),
291
+ title: z.string().min(1).max(200),
292
+ content: z.string().max(50_000),
293
+ },
294
+ annotations: editing,
295
+ },
296
+ (input) =>
297
+ toolResult(async () => {
298
+ const canvas = await ensureCanvas();
299
+ const node = await canvas.getNode(input.id, input.boardId);
300
+ if (!node || editableContent(node) === undefined)
301
+ throw new OperationError('Edit this node in the PMX workbench.');
302
+ // Compare again inside node.update, atomically with the actual mutation.
303
+ const result = await canvas.invoker().invoke('node.update', { ...input, expectedBoardId: input.boardId });
304
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
305
+ }),
306
+ );
307
+
308
+ registerAppResource(server, 'PMX Canvas', CANVAS_APP_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
309
+ contents: [
310
+ {
311
+ uri: CANVAS_APP_URI,
312
+ mimeType: RESOURCE_MIME_TYPE,
313
+ text: await readFile(new URL('../../dist/canvas/mcp-app.html', import.meta.url), 'utf8'),
314
+ _meta: { ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [], frameDomains: [] } } },
315
+ },
316
+ ],
317
+ }));
318
+ }
package/src/mcp/server.ts CHANGED
@@ -34,7 +34,7 @@ import { registerOperationTools, registerCompositeTools } from '../server/operat
34
34
  import { agentSourceLabel } from '../server/operations/invoker.js';
35
35
  import { contextReadFromPayload } from '../server/context-reads.js';
36
36
  import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
37
- import { serializeNodeForAgentContext } from '../server/agent-context.js';
37
+ import { serializeNodeForAgentContext } from '../shared/agent-context.js';
38
38
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
39
39
  import {
40
40
  getCanvasNodeTitle,
@@ -0,0 +1,211 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { listOperations, OperationError } from '../server/operations/index.js';
4
+ import { matchOperationPath, readOperationInput } from '../server/operations/http.js';
5
+ import type { CanvasAccess } from './canvas-access.js';
6
+
7
+ // Explicitly bounded to workbench operations. No shell, automation, arbitrary
8
+ // HTTP proxy, external MCP tool calls, or caller-supplied authority headers.
9
+ const writes = new Set([
10
+ 'board.create',
11
+ 'board.open',
12
+ 'board.update',
13
+ 'board.create-from',
14
+ 'board.delete',
15
+ 'node.add',
16
+ 'jsonrender.add',
17
+ 'graph.add',
18
+ 'node.update',
19
+ 'node.remove',
20
+ 'node.refresh',
21
+ 'edge.add',
22
+ 'edge.update',
23
+ 'edge.remove',
24
+ 'group.create',
25
+ 'group.add',
26
+ 'group.remove',
27
+ 'pin.set',
28
+ 'annotation.add',
29
+ 'annotation.remove',
30
+ 'theme.set',
31
+ 'viewport.set',
32
+ 'canvas.apply-updates',
33
+ 'canvas.clear',
34
+ 'arrange',
35
+ 'node.focus',
36
+ 'view.fit',
37
+ 'snapshot.save',
38
+ 'snapshot.restore',
39
+ 'snapshot.delete',
40
+ 'tour.set',
41
+ 'ax.focus.set',
42
+ 'ax.policy.set',
43
+ 'ax.work.create',
44
+ 'ax.work.update',
45
+ 'ax.review.add',
46
+ 'ax.review.update',
47
+ 'ax.elicitation.respond',
48
+ 'ax.mode.resolve',
49
+ 'ax.steer',
50
+ 'ax.event.record',
51
+ 'ax.presence.set',
52
+ 'ax.interaction.submit',
53
+ 'intent.clear',
54
+ 'import.request',
55
+ 'import.cancel',
56
+ 'import.commit',
57
+ 'export.run',
58
+ ]);
59
+
60
+ function readableAsset(path: string): boolean {
61
+ return (
62
+ [
63
+ '/health',
64
+ '/api/workbench/poll',
65
+ '/api/workbench/state',
66
+ '/api/file',
67
+ '/api/canvas/json-render/view',
68
+ '/api/canvas/file-bytes',
69
+ ].includes(path) ||
70
+ /^\/api\/canvas\/(surface|image|frame-documents|exports)\/[^/]+$/.test(path) ||
71
+ /^\/api\/canvas\/attachments\/[^/]+\/bytes$/.test(path)
72
+ );
73
+ }
74
+
75
+ /** HTTP-shaped replies let the real workbench retain its existing API contracts. */
76
+ export function registerWorkbenchApp(server: McpServer, ensureCanvas: () => Promise<CanvasAccess>): void {
77
+ server.registerTool(
78
+ 'pmx_workbench_request',
79
+ {
80
+ title: 'PMX workbench transport',
81
+ description: 'App-only transport for the PMX workbench. Not an assistant tool.',
82
+ inputSchema: {
83
+ path: z.string().max(8192),
84
+ method: z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD']),
85
+ body: z.string().max(4_000_000).optional(),
86
+ expectedBoardId: z.string().nullable(),
87
+ },
88
+ // Workbench webpage/image actions can fetch user-chosen external URLs.
89
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
90
+ _meta: { ui: { visibility: ['app'] } },
91
+ },
92
+ async (input) => {
93
+ const reply = (status: number, body: string, contentType = 'application/json', encoding = 'text') => ({
94
+ content: [
95
+ { type: 'text' as const, text: status < 400 ? 'Workbench request completed.' : 'Workbench request refused.' },
96
+ ],
97
+ _meta: { response: { status, body, contentType, encoding } },
98
+ });
99
+ try {
100
+ const canvas = await ensureCanvas();
101
+ const base = canvas.remoteBaseUrl ?? `http://127.0.0.1:${canvas.port}`;
102
+ const url = new URL(input.path, base);
103
+ if (
104
+ !input.path.startsWith('/') ||
105
+ input.path.startsWith('//') ||
106
+ input.path.includes('\\') ||
107
+ url.origin !== new URL(base).origin ||
108
+ url.username ||
109
+ url.password ||
110
+ url.hash
111
+ ) {
112
+ throw new OperationError('Only local PMX workbench paths are allowed.', 403);
113
+ }
114
+ const req = new Request(url, {
115
+ method: input.method,
116
+ headers: { 'Content-Type': 'application/json' },
117
+ ...(input.body === undefined ? {} : { body: input.body }),
118
+ });
119
+ for (const op of listOperations()) {
120
+ const route = op.http;
121
+ if (!route || route.method !== input.method) continue;
122
+ const params = matchOperationPath(route.path, url.pathname);
123
+ if (!params) continue;
124
+ if (!url.pathname.startsWith('/api/canvas/') || (input.method !== 'GET' && !writes.has(op.name)))
125
+ throw new OperationError('This action requires the local PMX workbench.', 403);
126
+ const args = route.readInput
127
+ ? await route.readInput(req, params, url)
128
+ : await readOperationInput(req, params, url);
129
+ if (input.method !== 'GET') args.expectedBoardId = input.expectedBoardId;
130
+ // This profile is one agent transport, never browser/system authority
131
+ // or a caller-selected writer whose wider territory could bypass a fence.
132
+ args.source = 'mcp';
133
+ delete args.agentId;
134
+ if (
135
+ op.name === 'ax.interaction.submit' &&
136
+ (args.type === 'ax.approval.resolve' || args.type === 'ax.flow.materialize')
137
+ )
138
+ throw new OperationError('Resolve approvals and materialize flows in the local PMX workbench.', 403);
139
+ if (op.name === 'node.add' || op.name === 'node.update') {
140
+ const data = args.data;
141
+ const nodeType = op.name === 'node.add' ? args.type : (await canvas.getNode(String(args.id)))?.type;
142
+ // Trace tool names describe past activity; they never launch a backend.
143
+ const launchFields = ['transportConfig', 'toolInput', 'serverName', 'appSessionId'];
144
+ if (nodeType !== 'trace') launchFields.push('toolName');
145
+ if (
146
+ (op.name === 'node.add' && args.type === 'mcp-app') ||
147
+ (data && typeof data === 'object' && launchFields.some((field) => field in data))
148
+ )
149
+ throw new OperationError('Configure external MCP apps in the local PMX workbench.', 403);
150
+ }
151
+ if (
152
+ op.name === 'node.update' &&
153
+ ('content' in args || (args.data && typeof args.data === 'object' && 'content' in args.data))
154
+ ) {
155
+ if (typeof args.expectedContentRevision !== 'number')
156
+ throw new OperationError('Read the note before editing: a content revision is required.', 409);
157
+ const node = await canvas.getNode(String(args.id));
158
+ if (node?.data.path) throw new OperationError('Edit file-linked content in the local PMX workbench.', 403);
159
+ }
160
+ const result = await canvas.invoker().invoke(op.name, args);
161
+ return reply(route.status ? route.status(result) : 200, JSON.stringify(result));
162
+ }
163
+ if (
164
+ !((input.method === 'GET' || input.method === 'HEAD') && readableAsset(url.pathname)) &&
165
+ !(input.method === 'POST' && url.pathname === '/api/render')
166
+ ) {
167
+ throw new OperationError('This action requires the local PMX workbench.', 403);
168
+ }
169
+ // Resolve only PMX's internal surface redirects. Never follow to arbitrary
170
+ // hosts or return the workbench HTML (which carries its human boot token).
171
+ let target = url;
172
+ let response: Response | undefined;
173
+ for (let redirects = 0; redirects < 3; redirects++) {
174
+ response = await fetch(target, {
175
+ method: input.method,
176
+ body: input.body,
177
+ headers: { 'Content-Type': 'application/json' },
178
+ redirect: 'manual',
179
+ signal: AbortSignal.timeout(15_000),
180
+ });
181
+ if (response.status < 300 || response.status >= 400) break;
182
+ const next = new URL(response.headers.get('location') ?? '', target);
183
+ if (next.origin !== url.origin || !readableAsset(next.pathname))
184
+ throw new OperationError('External surfaces must be opened outside the embedded workbench.', 403);
185
+ target = next;
186
+ }
187
+ if (!response || (response.status >= 300 && response.status < 400))
188
+ throw new OperationError('Could not resolve the PMX surface.');
189
+ const bytes = await response.arrayBuffer();
190
+ if (bytes.byteLength > 24 * 1024 * 1024)
191
+ throw new OperationError('This asset is too large for the MCP connection.');
192
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
193
+ const text = /^(text\/|application\/(json|javascript))/.test(contentType);
194
+ return reply(
195
+ response.status,
196
+ text ? new TextDecoder().decode(bytes) : Buffer.from(bytes).toString('base64'),
197
+ contentType,
198
+ text ? 'text' : 'base64',
199
+ );
200
+ } catch (error) {
201
+ if (error instanceof OperationError)
202
+ return reply(error.status, JSON.stringify({ ok: false, error: error.message }));
203
+ console.error('[workbench-app] request failed', error);
204
+ return reply(
205
+ 502,
206
+ JSON.stringify({ ok: false, error: 'PMX workbench request failed. Check the daemon and retry.' }),
207
+ );
208
+ }
209
+ },
210
+ );
211
+ }
@@ -32,7 +32,7 @@ import {
32
32
  PRESENCE_TOOLING_SETTLE_MS,
33
33
  TRANSPORT_SOURCES,
34
34
  } from '../shared/agent-presence.js';
35
- import { serializeNodeForAgentContext } from './agent-context.js';
35
+ import { serializeNodeForAgentContext } from '../shared/agent-context.js';
36
36
  import type { PmxAxActivityKind } from './ax-state.js';
37
37
  import { type CanvasNodeState, canvasState } from './canvas-state.js';
38
38
  import { OperationError } from './operations/types.js';
@@ -205,6 +205,8 @@ interface StoredPresence extends AgentPresence {
205
205
  lastActivityNodeId: string | null;
206
206
  /** Snapshot of the board taken when this session attached (the receipt diffs against it). */
207
207
  startSnapshotId: string | null;
208
+ /** When this session attached (ISO); the receipt counts reads and pins from here. */
209
+ startedAt: string | null;
208
210
  }
209
211
 
210
212
  function presenceKey(source: string, agentId: string | null | undefined): string {
@@ -230,7 +232,12 @@ export function estimateContextBudget(): ContextBudget {
230
232
  /** Returns the id of the pre-session snapshot the server took, if any. */
231
233
  type SessionStartListener = (presence: AgentPresence) => string | null;
232
234
  export type SessionEndReason = 'human' | 'agent' | 'idle-timeout';
233
- type SessionEndListener = (presence: AgentPresence, startSnapshotId: string | null, endedBy: SessionEndReason) => void;
235
+ type SessionEndListener = (
236
+ presence: AgentPresence,
237
+ startSnapshotId: string | null,
238
+ endedBy: SessionEndReason,
239
+ startedAt: string | null,
240
+ ) => void;
234
241
 
235
242
  export class AgentPresenceRegistry {
236
243
  private readonly presences = new Map<string, StoredPresence>();
@@ -392,6 +399,7 @@ export class AgentPresenceRegistry {
392
399
  phaseSetMs: now,
393
400
  toolingUntilMs: null,
394
401
  startSnapshotId: null,
402
+ startedAt: null,
395
403
  };
396
404
  const wasAttached = stored.attached;
397
405
  stored.lastSeenMs = now;
@@ -448,6 +456,7 @@ export class AgentPresenceRegistry {
448
456
  // moment stays the receipt baseline via the inherited snapshot. A
449
457
  // placeholder that already adopted work is a real session and stays.
450
458
  let inheritedSnapshotId: string | null = null;
459
+ let inheritedStartedAt: string | null = null;
451
460
  if (stored.source !== 'browser') {
452
461
  const placeholder = [...this.presences.values()].find(
453
462
  (presence) =>
@@ -463,13 +472,20 @@ export class AgentPresenceRegistry {
463
472
  if (target === placeholder.sessionId) this.aliases.set(channel, key);
464
473
  }
465
474
  inheritedSnapshotId = placeholder.startSnapshotId;
475
+ inheritedStartedAt = placeholder.startedAt;
466
476
  }
467
477
  }
468
478
  stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
479
+ stored.startedAt = inheritedStartedAt ?? new Date(now).toISOString();
469
480
  }
470
481
  if (input.attached === false) {
471
482
  if (wasAttached)
472
- this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
483
+ this.onSessionEnd(
484
+ this.publicView(stored, now),
485
+ stored.startSnapshotId,
486
+ input.endedBy ?? 'agent',
487
+ stored.startedAt,
488
+ );
473
489
  // An explicit detach always removes the presence. In particular, a
474
490
  // worker that reports attached:false must not be refreshed into another
475
491
  // full activity TTL as an external writer.
@@ -481,7 +497,24 @@ export class AgentPresenceRegistry {
481
497
  }
482
498
  if (input.focusNodeId !== undefined) stored.focusNodeId = input.focusNodeId;
483
499
  if (input.parentAgentId !== undefined) stored.parentAgentId = input.parentAgentId;
484
- if (input.cursor !== undefined) stored.cursor = input.cursor;
500
+ if (input.cursor !== undefined) {
501
+ // A MOVING cursor is the agent visibly working: it keeps a live explicit
502
+ // `tooling` from settling (a host streaming cursor moves after one
503
+ // `tooling` report read as Idle within seconds). A stationary heartbeat
504
+ // is not activity, and `thinking` keeps its own longer clock (below).
505
+ const moved =
506
+ input.cursor !== null && (stored.cursor?.x !== input.cursor.x || stored.cursor?.y !== input.cursor.y);
507
+ if (
508
+ moved &&
509
+ input.phase === undefined &&
510
+ stored.phase === 'tooling' &&
511
+ stored.toolingUntilMs !== null &&
512
+ stored.toolingUntilMs > now
513
+ ) {
514
+ stored.toolingUntilMs = now + PRESENCE_TOOLING_SETTLE_MS;
515
+ }
516
+ stored.cursor = input.cursor;
517
+ }
485
518
  if (input.contextUsage !== undefined) stored.contextUsage = input.contextUsage;
486
519
  if (input.phase === 'tooling' && input.derivedTooling) {
487
520
  // Copilot's finding: an op-derived tooling touch used to CLOBBER the
@@ -585,7 +618,8 @@ export class AgentPresenceRegistry {
585
618
  const removed = this.presences.delete(sessionId);
586
619
  if (removed) {
587
620
  this.dropAliasesTo(sessionId);
588
- if (stored?.attached) this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent');
621
+ if (stored?.attached)
622
+ this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent', stored.startedAt);
589
623
  this.scheduleEmit();
590
624
  }
591
625
  this.maybeStopSweeper();
@@ -700,7 +734,12 @@ export class AgentPresenceRegistry {
700
734
  this.presences.delete(key);
701
735
  this.dropAliasesTo(key);
702
736
  if (presence.attached)
703
- this.onSessionEnd(this.publicView(presence, now), presence.startSnapshotId, 'idle-timeout');
737
+ this.onSessionEnd(
738
+ this.publicView(presence, now),
739
+ presence.startSnapshotId,
740
+ 'idle-timeout',
741
+ presence.startedAt,
742
+ );
704
743
  changed = true;
705
744
  continue;
706
745
  }
@@ -1,4 +1,4 @@
1
- import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
1
+ import { buildAgentContextPreamble, serializeNodeForAgentContext } from '../shared/agent-context.js';
2
2
  import {
3
3
  type PmxAxHumanAnnotation,
4
4
  buildAxContext,
@@ -1,7 +1,7 @@
1
1
  import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
3
3
  import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
4
- import type { AgentContextNode } from './agent-context.js';
4
+ import type { AgentContextNode } from '../shared/agent-context.js';
5
5
  import type { ActorAttribution } from './attribution.js';
6
6
 
7
7
  export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';