pmx-canvas 0.2.7 → 0.3.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 (189) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/Readme.md +13 -11
  3. package/dist/canvas/index.js +2 -2
  4. package/dist/json-render/index.css +1 -1
  5. package/dist/types/cli/daemon.d.ts +74 -0
  6. package/dist/types/cli/watch.d.ts +2 -2
  7. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  8. package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
  9. package/dist/types/client/canvas/Minimap.d.ts +1 -1
  10. package/dist/types/client/nodes/FileNode.d.ts +1 -1
  11. package/dist/types/client/nodes/ImageNode.d.ts +1 -1
  12. package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
  13. package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
  14. package/dist/types/client/nodes/PromptNode.d.ts +1 -1
  15. package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
  16. package/dist/types/mcp/canvas-access.d.ts +0 -7
  17. package/dist/types/server/ax-state-manager.d.ts +3 -2
  18. package/dist/types/server/bundled-skills.d.ts +3 -3
  19. package/dist/types/server/canvas-schema.d.ts +1 -1
  20. package/dist/types/server/canvas-state.d.ts +2 -0
  21. package/dist/types/server/ext-app-lookup.d.ts +1 -3
  22. package/dist/types/server/html-primitives.d.ts +1 -1
  23. package/dist/types/server/index.d.ts +4 -4
  24. package/dist/types/server/operations/composites.d.ts +29 -23
  25. package/dist/types/server/operations/http.d.ts +2 -1
  26. package/dist/types/server/operations/index.d.ts +1 -1
  27. package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
  28. package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
  29. package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
  30. package/dist/types/shared/ax-intent.d.ts +1 -1
  31. package/docs/RELEASE.md +23 -8
  32. package/docs/ax-host-adapter-contract.md +7 -7
  33. package/docs/ax-state-contract.md +7 -6
  34. package/docs/bun-webview-integration.md +5 -5
  35. package/docs/cli.md +1 -1
  36. package/docs/http-api.md +35 -3
  37. package/docs/mcp.md +121 -100
  38. package/docs/node-types.md +45 -35
  39. package/docs/plans/plan-006-mcp-tool-consolidation.md +3 -1
  40. package/docs/plans/plan-008-registry-finish.md +2 -0
  41. package/docs/plans/plan-009-tech-debt-backlog.md +51 -0
  42. package/docs/screenshot.png +0 -0
  43. package/docs/tech-debt-assessment-2026-06.md +1 -1
  44. package/docs/tech-debt-assessment-2026-07.md +135 -0
  45. package/package.json +5 -2
  46. package/skills/data-analysis/SKILL.md +3 -3
  47. package/skills/frontend-design/SKILL.md +3 -2
  48. package/skills/json-render-mcp/SKILL.md +4 -3
  49. package/skills/playwright-cli/SKILL.md +1 -1
  50. package/skills/pmx-canvas/SKILL.md +12 -7
  51. package/skills/pmx-canvas/evals/evals.json +2 -2
  52. package/skills/pmx-canvas/references/ax-html-control-surface.md +3 -1
  53. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +2 -2
  54. package/skills/pmx-canvas/references/full-reference.md +58 -56
  55. package/skills/pmx-canvas/references/html-primitives.md +3 -3
  56. package/skills/tufte-viz/SKILL.md +5 -4
  57. package/skills/web-artifacts-builder/SKILL.md +4 -4
  58. package/src/cli/agent.ts +1861 -1548
  59. package/src/cli/daemon.ts +460 -0
  60. package/src/cli/index.ts +63 -326
  61. package/src/cli/watch.ts +2 -10
  62. package/src/client/App.tsx +48 -46
  63. package/src/client/canvas/AttentionHistory.tsx +11 -1
  64. package/src/client/canvas/CanvasNode.tsx +41 -29
  65. package/src/client/canvas/CanvasViewport.tsx +101 -66
  66. package/src/client/canvas/CommandPalette.tsx +61 -27
  67. package/src/client/canvas/ContextMenu.tsx +13 -20
  68. package/src/client/canvas/ContextPinBar.tsx +1 -5
  69. package/src/client/canvas/ContextPinHud.tsx +1 -6
  70. package/src/client/canvas/DockedNode.tsx +4 -4
  71. package/src/client/canvas/EdgeLayer.tsx +37 -36
  72. package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
  73. package/src/client/canvas/FocusFieldLayer.tsx +20 -22
  74. package/src/client/canvas/IntentLayer.tsx +31 -14
  75. package/src/client/canvas/Minimap.tsx +11 -16
  76. package/src/client/canvas/SelectionBar.tsx +4 -11
  77. package/src/client/canvas/ShortcutOverlay.tsx +3 -1
  78. package/src/client/canvas/SnapshotPanel.tsx +77 -95
  79. package/src/client/canvas/auto-fit.ts +15 -14
  80. package/src/client/canvas/snap-guides.ts +12 -12
  81. package/src/client/canvas/use-node-resize.ts +1 -5
  82. package/src/client/canvas/use-pan-zoom.ts +25 -26
  83. package/src/client/ext-app/bridge.ts +3 -12
  84. package/src/client/icons.tsx +63 -20
  85. package/src/client/nodes/ContextNode.tsx +14 -25
  86. package/src/client/nodes/ExtAppFrame.tsx +60 -39
  87. package/src/client/nodes/FileNode.tsx +74 -62
  88. package/src/client/nodes/GroupNode.tsx +4 -6
  89. package/src/client/nodes/HtmlNode.tsx +76 -46
  90. package/src/client/nodes/ImageNode.tsx +18 -27
  91. package/src/client/nodes/InlineFormatBar.tsx +4 -21
  92. package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
  93. package/src/client/nodes/LedgerNode.tsx +10 -4
  94. package/src/client/nodes/MarkdownNode.tsx +3 -10
  95. package/src/client/nodes/McpAppNode.tsx +26 -22
  96. package/src/client/nodes/MdFormatBar.tsx +10 -7
  97. package/src/client/nodes/PromptNode.tsx +23 -51
  98. package/src/client/nodes/ResponseNode.tsx +3 -13
  99. package/src/client/nodes/StatusNode.tsx +5 -9
  100. package/src/client/nodes/StatusSummary.tsx +2 -8
  101. package/src/client/nodes/WebpageNode.tsx +20 -14
  102. package/src/client/nodes/iframe-document-url.ts +25 -16
  103. package/src/client/nodes/image-warnings.ts +1 -7
  104. package/src/client/nodes/md-format.ts +20 -5
  105. package/src/client/state/attention-bridge.ts +4 -9
  106. package/src/client/state/attention-store.ts +1 -7
  107. package/src/client/state/canvas-store.ts +52 -36
  108. package/src/client/state/intent-bridge.ts +176 -112
  109. package/src/client/state/intent-store.ts +4 -1
  110. package/src/client/state/sse-bridge.ts +53 -70
  111. package/src/json-render/catalog.ts +12 -16
  112. package/src/json-render/charts/components.tsx +16 -20
  113. package/src/json-render/charts/extra-components.tsx +8 -16
  114. package/src/json-render/charts/extra-definitions.ts +1 -2
  115. package/src/json-render/charts/tufte-components.tsx +37 -20
  116. package/src/json-render/charts/tufte-definitions.ts +8 -2
  117. package/src/json-render/renderer/index.tsx +42 -22
  118. package/src/json-render/schema.ts +6 -3
  119. package/src/json-render/server.ts +33 -39
  120. package/src/mcp/canvas-access.ts +40 -121
  121. package/src/mcp/server.ts +206 -271
  122. package/src/server/agent-context.ts +63 -36
  123. package/src/server/ax-context.ts +7 -5
  124. package/src/server/ax-interaction.ts +176 -43
  125. package/src/server/ax-state-manager.ts +185 -41
  126. package/src/server/ax-state.ts +142 -47
  127. package/src/server/bundled-skills.ts +3 -3
  128. package/src/server/canvas-db.ts +213 -95
  129. package/src/server/canvas-operations.ts +177 -120
  130. package/src/server/canvas-provenance.ts +1 -4
  131. package/src/server/canvas-schema.ts +500 -102
  132. package/src/server/canvas-serialization.ts +27 -35
  133. package/src/server/canvas-state.ts +155 -58
  134. package/src/server/chart-template.ts +4 -5
  135. package/src/server/code-graph.ts +20 -7
  136. package/src/server/diagram-presets.ts +28 -29
  137. package/src/server/ext-app-lookup.ts +4 -20
  138. package/src/server/html-node-summary.ts +19 -10
  139. package/src/server/html-primitives.ts +326 -97
  140. package/src/server/html-surface.ts +6 -9
  141. package/src/server/image-source.ts +6 -4
  142. package/src/server/index.ts +321 -218
  143. package/src/server/intent-registry.ts +3 -6
  144. package/src/server/mcp-app-candidate.ts +5 -10
  145. package/src/server/mcp-app-host.ts +14 -38
  146. package/src/server/mcp-app-runtime.ts +12 -20
  147. package/src/server/mutation-history.ts +15 -5
  148. package/src/server/operations/composites.ts +38 -40
  149. package/src/server/operations/http.ts +5 -5
  150. package/src/server/operations/index.ts +7 -1
  151. package/src/server/operations/invoker.ts +4 -3
  152. package/src/server/operations/mcp.ts +29 -36
  153. package/src/server/operations/ops/annotation.ts +122 -10
  154. package/src/server/operations/ops/app.ts +102 -77
  155. package/src/server/operations/ops/ax-await.ts +17 -10
  156. package/src/server/operations/ops/ax-read.ts +347 -0
  157. package/src/server/operations/ops/ax-shared.ts +2 -7
  158. package/src/server/operations/ops/ax-state.ts +32 -14
  159. package/src/server/operations/ops/ax-timeline.ts +32 -19
  160. package/src/server/operations/ops/ax-work.ts +54 -37
  161. package/src/server/operations/ops/batch.ts +39 -14
  162. package/src/server/operations/ops/canvas-wire.ts +91 -0
  163. package/src/server/operations/ops/edges.ts +37 -25
  164. package/src/server/operations/ops/ext-app.ts +346 -0
  165. package/src/server/operations/ops/groups.ts +49 -20
  166. package/src/server/operations/ops/intent.ts +18 -12
  167. package/src/server/operations/ops/json-render.ts +239 -98
  168. package/src/server/operations/ops/nodes.ts +300 -111
  169. package/src/server/operations/ops/query.ts +62 -33
  170. package/src/server/operations/ops/snapshots.ts +35 -26
  171. package/src/server/operations/ops/validate.ts +2 -1
  172. package/src/server/operations/ops/viewport.ts +60 -16
  173. package/src/server/operations/ops/webview.ts +44 -18
  174. package/src/server/operations/registry.ts +2 -3
  175. package/src/server/operations/types.ts +7 -5
  176. package/src/server/operations/webview-runner.ts +1 -3
  177. package/src/server/placement.ts +8 -18
  178. package/src/server/server.ts +121 -1012
  179. package/src/server/spatial-analysis.ts +39 -25
  180. package/src/server/trace-manager.ts +3 -8
  181. package/src/server/web-artifacts.ts +23 -27
  182. package/src/server/webpage-node.ts +5 -13
  183. package/src/shared/auto-arrange.ts +12 -5
  184. package/src/shared/ax-intent.ts +1 -1
  185. package/src/shared/content-height-reporter.ts +8 -6
  186. package/src/shared/ext-app-tool-result.ts +2 -6
  187. package/src/shared/placement.ts +1 -4
  188. package/src/shared/semantic-attention.ts +39 -37
  189. package/src/shared/surface.ts +8 -4
@@ -6,9 +6,9 @@
6
6
  * - pin.set: legacy POST /api/canvas/context-pins emitted ONLY
7
7
  * context-pins-changed (no canvas-layout-update), so mutates: false with a
8
8
  * manual ctx.emit. The injected emitter adds the sessionId/timestamp fields
9
- * the legacy handler set explicitly. (The SDK's setContextPins emits a
10
- * layout update insteadthat local/remote drift is erased here in favor of
11
- * the HTTP wire behavior.)
9
+ * the legacy handler set explicitly. (The SDK's setContextPins emits the
10
+ * same context-pins-changed eventits old layout-update drift was erased
11
+ * in v0.3.0.)
12
12
  * - canvas.undo / canvas.redo: legacy handlers emitted canvas-viewport-update
13
13
  * then canvas-layout-update only after an entry was actually undone/redone,
14
14
  * so mutates: false with conditional manual emits. (The SDK's undo/redo also
@@ -19,10 +19,7 @@
19
19
  */
20
20
  import { z } from 'zod';
21
21
  import { canvasState } from '../../canvas-state.js';
22
- import {
23
- setCanvasContextPins,
24
- syncCanvasRuntimeBackends,
25
- } from '../../canvas-operations.js';
22
+ import { setCanvasContextPins, syncCanvasRuntimeBackends } from '../../canvas-operations.js';
26
23
  import { buildCanvasSummary } from '../../canvas-serialization.js';
27
24
  import { mutationHistory } from '../../mutation-history.js';
28
25
  import { buildSpatialContext, searchNodes } from '../../spatial-analysis.js';
@@ -36,7 +33,10 @@ const MAX_PINS = 20;
36
33
 
37
34
  const pinShape = {
38
35
  nodeIds: z.unknown().optional().describe('Array of node IDs to pin'),
39
- mode: z.unknown().optional().describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
36
+ mode: z
37
+ .unknown()
38
+ .optional()
39
+ .describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
40
40
  };
41
41
 
42
42
  const pinSchema = z.looseObject(pinShape);
@@ -52,20 +52,25 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
52
52
  },
53
53
  mcp: {
54
54
  toolName: 'canvas_pin_nodes',
55
- description: 'Pin nodes to include them in the agent context. Pinned nodes appear in the canvas://pinned-context resource. The human can also pin nodes by clicking in the browser.',
55
+ description:
56
+ 'Pin nodes to include them in the agent context. Pinned nodes appear in the canvas://pinned-context resource. The human can also pin nodes by clicking in the browser.',
56
57
  extraShape: {
57
58
  nodeIds: z.array(z.string()).describe('Array of node IDs to pin'),
58
- mode: z.enum(['set', 'add', 'remove']).optional()
59
+ mode: z
60
+ .enum(['set', 'add', 'remove'])
61
+ .optional()
59
62
  .describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
60
63
  },
61
64
  // The wire body is { ok, count } (legacy HTTP shape); the tool reports the
62
65
  // resulting pin list, so re-read it from the host. (Legacy RemoteCanvasAccess
63
66
  // computed the list client-side; the server state is authoritative now.)
64
67
  formatResult: async (_result, _input, host) => ({
65
- content: [{
66
- type: 'text' as const,
67
- text: JSON.stringify({ ok: true, pinnedNodeIds: await host.getPinnedNodeIds() }),
68
- }],
68
+ content: [
69
+ {
70
+ type: 'text' as const,
71
+ text: JSON.stringify({ ok: true, pinnedNodeIds: await host.getPinnedNodeIds() }),
72
+ },
73
+ ],
69
74
  }),
70
75
  },
71
76
  handler: (input, ctx) => {
@@ -87,7 +92,7 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
87
92
 
88
93
  const searchShape = {
89
94
  q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
90
- limit: z.unknown().optional().describe('Max results to return (default: 10). Applied by the MCP tool only; the HTTP route returns all matches.'),
95
+ limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
91
96
  };
92
97
 
93
98
  const searchSchema = z.looseObject(searchShape);
@@ -103,27 +108,37 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
103
108
  },
104
109
  mcp: {
105
110
  toolName: 'canvas_search',
106
- description: 'Search for nodes by title or content keywords. Returns matching nodes ranked by relevance with snippets. Much faster than reading the full layout when you need to find specific nodes.',
111
+ description:
112
+ 'Search for nodes by title or content keywords. Returns matching nodes ranked by relevance with snippets. Much faster than reading the full layout when you need to find specific nodes.',
107
113
  extraShape: {
108
114
  query: z.string().describe('Search query — matches against node titles, content, and file paths'),
109
115
  limit: z.number().optional().describe('Max results to return (default: 10)'),
110
116
  },
111
- // The HTTP route only reads ?q= (legacy behavior); map the MCP-facing
112
- // `query` arg onto it. The limit cap stays MCP-side, like the legacy tool.
113
- buildInput: (input) => ({ q: typeof input.query === 'string' ? input.query : '' }),
117
+ // Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
118
+ // `limit` on every transport; the MCP tool additionally defaults it to 10.
119
+ buildInput: (input) => ({
120
+ q: typeof input.query === 'string' ? input.query : '',
121
+ ...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
122
+ }),
114
123
  formatResult: (result, input) => {
115
124
  const body = isRecord(result) ? result : {};
116
125
  const results = Array.isArray(body.results) ? body.results : [];
117
126
  const limit = typeof input.limit === 'number' ? input.limit : 10;
118
127
  return {
119
- content: [{
120
- type: 'text' as const,
121
- text: JSON.stringify({
122
- query: input.query,
123
- resultCount: results.length,
124
- results: results.slice(0, limit),
125
- }, null, 2),
126
- }],
128
+ content: [
129
+ {
130
+ type: 'text' as const,
131
+ text: JSON.stringify(
132
+ {
133
+ query: input.query,
134
+ resultCount: results.length,
135
+ results: results.slice(0, limit),
136
+ },
137
+ null,
138
+ 2,
139
+ ),
140
+ },
141
+ ],
127
142
  };
128
143
  },
129
144
  },
@@ -132,7 +147,18 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
132
147
  if (!q.trim()) {
133
148
  return { results: [], query: q };
134
149
  }
135
- return { results: searchNodes(canvasState.getLayout().nodes, q), query: q };
150
+ const rawLimit = input.limit;
151
+ const limit =
152
+ typeof rawLimit === 'number'
153
+ ? rawLimit
154
+ : typeof rawLimit === 'string' && rawLimit.trim() !== ''
155
+ ? Number(rawLimit)
156
+ : Number.NaN;
157
+ const results = searchNodes(canvasState.getLayout().nodes, q);
158
+ return {
159
+ results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
160
+ query: q,
161
+ };
136
162
  },
137
163
  });
138
164
 
@@ -167,10 +193,12 @@ async function formatUndoRedoResult(result: unknown, host: OperationMcpToolHost)
167
193
  const historyBody = isRecord(history) ? history : {};
168
194
  const body = isRecord(result) ? result : {};
169
195
  return {
170
- content: [{
171
- type: 'text' as const,
172
- text: JSON.stringify({ ...body, canUndo: historyBody.canUndo, canRedo: historyBody.canRedo }),
173
- }],
196
+ content: [
197
+ {
198
+ type: 'text' as const,
199
+ text: JSON.stringify({ ...body, canUndo: historyBody.canUndo, canRedo: historyBody.canRedo }),
200
+ },
201
+ ],
174
202
  };
175
203
  }
176
204
 
@@ -189,7 +217,8 @@ const undoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<str
189
217
  },
190
218
  mcp: {
191
219
  toolName: 'canvas_undo',
192
- description: 'Undo the last canvas mutation. Returns a description of what was undone. Use this to backtrack when an approach is wrong — explore without fear.',
220
+ description:
221
+ 'Undo the last canvas mutation. Returns a description of what was undone. Use this to backtrack when an approach is wrong — explore without fear.',
193
222
  formatResult: (result, _input, host) => formatUndoRedoResult(result, host),
194
223
  },
195
224
  handler: async (_input, ctx) => {
@@ -69,7 +69,8 @@ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>
69
69
  },
70
70
  mcp: {
71
71
  toolName: 'canvas_list_snapshots',
72
- description: 'List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
72
+ description:
73
+ 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
73
74
  extraShape: {
74
75
  limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
75
76
  query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
@@ -84,9 +85,7 @@ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>
84
85
  handler: (input) => {
85
86
  const body: Record<string, unknown> = input;
86
87
  // Legacy precedence: ?q= wins over ?query= even when empty.
87
- const query = typeof body.q === 'string'
88
- ? body.q
89
- : typeof body.query === 'string' ? body.query : undefined;
88
+ const query = typeof body.q === 'string' ? body.q : typeof body.query === 'string' ? body.query : undefined;
90
89
  const limit = parsePositiveIntegerParam(body.limit);
91
90
  return listCanvasSnapshots({
92
91
  ...(limit !== undefined ? { limit } : {}),
@@ -117,7 +116,8 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
117
116
  },
118
117
  mcp: {
119
118
  toolName: 'canvas_snapshot',
120
- description: 'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
119
+ description:
120
+ 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
121
121
  extraShape: {
122
122
  name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
123
123
  },
@@ -154,7 +154,8 @@ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Re
154
154
  },
155
155
  mcp: {
156
156
  toolName: 'canvas_gc_snapshots',
157
- description: 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
157
+ description:
158
+ 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
158
159
  extraShape: {
159
160
  keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
160
161
  dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
@@ -168,11 +169,8 @@ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Re
168
169
  const keepValue = body.keep;
169
170
  // Legacy coercion replicated as-is, including the Number('junk') → NaN
170
171
  // pass-through (gcCanvasSnapshots falls back to its own default).
171
- const keep = typeof keepValue === 'number'
172
- ? keepValue
173
- : typeof keepValue === 'string'
174
- ? Number(keepValue)
175
- : undefined;
172
+ const keep =
173
+ typeof keepValue === 'number' ? keepValue : typeof keepValue === 'string' ? Number(keepValue) : undefined;
176
174
  const dryRun = body.dryRun === true || body['dry-run'] === true;
177
175
  return gcCanvasSnapshots({
178
176
  ...(keep !== undefined ? { keep } : {}),
@@ -207,7 +205,8 @@ const snapshotDiffOperation = defineOperation<z.infer<typeof snapshotDiffSchema>
207
205
  },
208
206
  mcp: {
209
207
  toolName: 'canvas_diff',
210
- description: 'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
208
+ description:
209
+ 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
211
210
  extraShape: {
212
211
  snapshot: z.string().describe('Snapshot name or ID to compare against'),
213
212
  },
@@ -242,9 +241,7 @@ const snapshotDiffQueryOperation = defineOperation<z.infer<typeof snapshotDiffQu
242
241
  },
243
242
  handler: (input) => {
244
243
  // Legacy precedence: ?name= wins over ?id= even when empty.
245
- const name = typeof input.name === 'string'
246
- ? input.name
247
- : typeof input.id === 'string' ? input.id : '';
244
+ const name = typeof input.name === 'string' ? input.name : typeof input.id === 'string' ? input.id : '';
248
245
  if (!name.trim()) throw new OperationError('Missing snapshot name or id.');
249
246
  return diffSnapshotCore(name);
250
247
  },
@@ -273,7 +270,11 @@ function buildSnapshotRestoreSummary(layout: unknown): Record<string, unknown> {
273
270
  }
274
271
 
275
272
  const snapshotRestoreShape = {
276
- id: z.string().optional().catch(undefined).describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
273
+ id: z
274
+ .string()
275
+ .optional()
276
+ .catch(undefined)
277
+ .describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
277
278
  };
278
279
 
279
280
  const snapshotRestoreSchema = z.looseObject(snapshotRestoreShape);
@@ -289,21 +290,28 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
289
290
  },
290
291
  mcp: {
291
292
  toolName: 'canvas_restore',
292
- description: 'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
293
+ description:
294
+ 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
293
295
  extraShape: {
294
296
  id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
295
297
  },
296
298
  formatResult: async (_result, input, host) => {
297
299
  const layout = await host.invoker().invoke('layout.get', { includeBlobs: true });
298
300
  return {
299
- content: [{
300
- type: 'text' as const,
301
- text: JSON.stringify({
302
- ok: true,
303
- restored: input.id,
304
- summary: buildSnapshotRestoreSummary(layout),
305
- }, null, 2),
306
- }],
301
+ content: [
302
+ {
303
+ type: 'text' as const,
304
+ text: JSON.stringify(
305
+ {
306
+ ok: true,
307
+ restored: input.id,
308
+ summary: buildSnapshotRestoreSummary(layout),
309
+ },
310
+ null,
311
+ 2,
312
+ ),
313
+ },
314
+ ],
307
315
  };
308
316
  },
309
317
  },
@@ -338,7 +346,8 @@ const snapshotDeleteOperation = defineOperation<z.infer<typeof snapshotDeleteSch
338
346
  },
339
347
  mcp: {
340
348
  toolName: 'canvas_delete_snapshot',
341
- description: 'Delete a saved snapshot by ID.',
349
+ description:
350
+ 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete a saved snapshot by ID.',
342
351
  extraShape: {
343
352
  id: z.string().describe('Snapshot ID to delete'),
344
353
  },
@@ -26,7 +26,8 @@ const validateGetOperation = defineOperation<z.infer<typeof validateGetSchema>,
26
26
  },
27
27
  mcp: {
28
28
  toolName: 'canvas_validate',
29
- description: 'Validate the current canvas layout. Distinguishes true node collisions from expected group-child containment and reports missing edge endpoints.',
29
+ description:
30
+ 'Validate the current canvas layout. Distinguishes true node collisions from expected group-child containment and reports missing edge endpoints.',
30
31
  formatResult: (result) => ({
31
32
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
32
33
  }),
@@ -14,11 +14,7 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  import { canvasState } from '../../canvas-state.js';
17
- import {
18
- arrangeCanvasNodes,
19
- clearCanvas,
20
- fitCanvasView,
21
- } from '../../canvas-operations.js';
17
+ import { arrangeCanvasNodes, clearCanvas, fitCanvasView } from '../../canvas-operations.js';
22
18
  import { validateCanvasLayout } from '../../canvas-validation.js';
23
19
  import { defineOperation, OperationError, type Operation } from '../types.js';
24
20
  import { closeNodeAppSession, isRecord } from './nodes.js';
@@ -42,17 +38,20 @@ const arrangeOperation = defineOperation<z.infer<typeof arrangeSchema>, Record<s
42
38
  },
43
39
  mcp: {
44
40
  toolName: 'canvas_arrange',
45
- description: 'Auto-arrange all nodes on the canvas. Layouts: grid (default), column (vertical stack), flow (horizontal row).',
41
+ description:
42
+ 'Auto-arrange all nodes on the canvas. Layouts: grid (default), column (vertical stack), flow (horizontal row).',
46
43
  extraShape: {
47
44
  layout: z.enum(['grid', 'column', 'flow']).optional().describe('Arrangement layout (default: grid)'),
48
45
  },
49
46
  // Legacy tool reported { ok: true, layout } regardless of the arrange
50
47
  // result (it ignored the arranged count and validation outcome).
51
48
  formatResult: (result, input) => ({
52
- content: [{
53
- type: 'text' as const,
54
- text: JSON.stringify({ ok: true, layout: typeof input.layout === 'string' ? input.layout : 'grid' }),
55
- }],
49
+ content: [
50
+ {
51
+ type: 'text' as const,
52
+ text: JSON.stringify({ ok: true, layout: typeof input.layout === 'string' ? input.layout : 'grid' }),
53
+ },
54
+ ],
56
55
  }),
57
56
  },
58
57
  handler: (input) => {
@@ -93,7 +92,8 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
93
92
  },
94
93
  mcp: {
95
94
  toolName: 'canvas_focus_node',
96
- description: 'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
95
+ description:
96
+ "Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human's camera (useful when reacting to background events without disrupting the human's current view).",
97
97
  extraShape: {
98
98
  id: z.string().describe('Node ID to focus on'),
99
99
  noPan: z
@@ -104,10 +104,12 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
104
104
  formatResult: (result) => {
105
105
  const body = isRecord(result) ? result : {};
106
106
  return {
107
- content: [{
108
- type: 'text' as const,
109
- text: JSON.stringify({ ok: true, focused: body.focused, panned: body.panned }),
110
- }],
107
+ content: [
108
+ {
109
+ type: 'text' as const,
110
+ text: JSON.stringify({ ok: true, focused: body.focused, panned: body.panned }),
111
+ },
112
+ ],
111
113
  };
112
114
  },
113
115
  },
@@ -155,7 +157,8 @@ const fitOperation = defineOperation<z.infer<typeof fitSchema>, Record<string, u
155
157
  },
156
158
  mcp: {
157
159
  toolName: 'canvas_fit_view',
158
- description: 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
160
+ description:
161
+ 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
159
162
  extraShape: {
160
163
  nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
161
164
  },
@@ -211,9 +214,50 @@ const clearOperation = defineOperation<z.infer<typeof clearSchema>, Record<strin
211
214
  },
212
215
  });
213
216
 
217
+ // ── viewport.set (plan-009 C1 slice 3) ────────────────────────
218
+
219
+ const viewportSetShape = {
220
+ x: z.unknown().optional().describe('Viewport x offset'),
221
+ y: z.unknown().optional().describe('Viewport y offset'),
222
+ scale: z.unknown().optional().describe('Viewport zoom scale'),
223
+ recordHistory: z.unknown().optional().describe('Pass false to skip the undo-history entry'),
224
+ };
225
+
226
+ const viewportSetSchema = z.looseObject(viewportSetShape);
227
+
228
+ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>, Record<string, unknown>>({
229
+ name: 'viewport.set',
230
+ mutates: false,
231
+ input: viewportSetSchema,
232
+ inputShape: viewportSetShape,
233
+ http: {
234
+ method: 'POST',
235
+ path: '/api/canvas/viewport',
236
+ },
237
+ // Legacy wire: emits canvas-viewport-update (never a layout update).
238
+ handler: (input, ctx) => {
239
+ const body: Record<string, unknown> = input;
240
+ const next = {
241
+ x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
242
+ y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
243
+ scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
244
+ };
245
+ if (body.recordHistory === false) {
246
+ canvasState.withSuppressedRecording(() => {
247
+ canvasState.setViewport(next);
248
+ });
249
+ } else {
250
+ canvasState.setViewport(next);
251
+ }
252
+ ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
253
+ return { ok: true };
254
+ },
255
+ });
256
+
214
257
  export const viewportOperations: Operation[] = [
215
258
  arrangeOperation,
216
259
  focusOperation,
217
260
  fitOperation,
218
261
  clearOperation,
262
+ viewportSetOperation,
219
263
  ];
@@ -72,7 +72,11 @@ async function runWebviewTask<T>(task: () => Promise<T> | T): Promise<T> {
72
72
  } catch (error) {
73
73
  const message = error instanceof Error ? error.message : String(error);
74
74
  let webview: WebviewStatus | undefined;
75
- try { webview = getWebviewRunner().status(); } catch { /* runner not wired */ }
75
+ try {
76
+ webview = getWebviewRunner().status();
77
+ } catch {
78
+ /* runner not wired */
79
+ }
76
80
  throw new OperationError(message, 400, webview ? { webview } : undefined);
77
81
  }
78
82
  }
@@ -164,7 +168,9 @@ const startOperation = defineOperation<z.infer<typeof startSchema>, WebviewStart
164
168
  description:
165
169
  'Start or replace the headless Bun.WebView automation session for the current PMX Canvas workbench. Use this before screenshot, evaluate, or resize when no automation session is active.',
166
170
  extraShape: {
167
- backend: z.enum(['chrome', 'webkit']).optional()
171
+ backend: z
172
+ .enum(['chrome', 'webkit'])
173
+ .optional()
168
174
  .describe('Automation backend. Default: webkit on macOS, chrome elsewhere.'),
169
175
  width: z.number().optional().describe('Viewport width in pixels (default: 1280)'),
170
176
  height: z.number().optional().describe('Viewport height in pixels (default: 800)'),
@@ -184,14 +190,20 @@ const startOperation = defineOperation<z.infer<typeof startSchema>, WebviewStart
184
190
  const body = result as { ok?: boolean; webview?: WebviewStatus; error?: string };
185
191
  if (body.ok && body.webview) return statusText(body.webview);
186
192
  return {
187
- content: [{
188
- type: 'text' as const,
189
- text: JSON.stringify({
190
- ok: false,
191
- error: body.error ?? 'WebView start failed.',
192
- ...(body.webview ? { webview: body.webview } : {}),
193
- }, null, 2),
194
- }],
193
+ content: [
194
+ {
195
+ type: 'text' as const,
196
+ text: JSON.stringify(
197
+ {
198
+ ok: false,
199
+ error: body.error ?? 'WebView start failed.',
200
+ ...(body.webview ? { webview: body.webview } : {}),
201
+ },
202
+ null,
203
+ 2,
204
+ ),
205
+ },
206
+ ],
195
207
  isError: true,
196
208
  };
197
209
  },
@@ -228,10 +240,12 @@ const stopOperation = defineOperation<z.infer<typeof stopSchema>, { stopped: boo
228
240
  formatResult: (result) => {
229
241
  const body = result as { ok: boolean; stopped: boolean; webview: WebviewStatus };
230
242
  return {
231
- content: [{
232
- type: 'text' as const,
233
- text: JSON.stringify({ ok: true, stopped: body.stopped, webview: body.webview }, null, 2),
234
- }],
243
+ content: [
244
+ {
245
+ type: 'text' as const,
246
+ text: JSON.stringify({ ok: true, stopped: body.stopped, webview: body.webview }, null, 2),
247
+ },
248
+ ],
235
249
  };
236
250
  },
237
251
  },
@@ -262,7 +276,8 @@ const resizeOperation = defineOperation<z.infer<typeof resizeSchema>, WebviewSta
262
276
  },
263
277
  mcp: {
264
278
  toolName: 'canvas_resize',
265
- description: 'Resize the active Bun.WebView automation viewport. Requires an active automation session started via canvas_webview_start.',
279
+ description:
280
+ 'Resize the active Bun.WebView automation viewport. Requires an active automation session started via canvas_webview_start.',
266
281
  extraShape: {
267
282
  width: z.number().describe('Viewport width in pixels'),
268
283
  height: z.number().describe('Viewport height in pixels'),
@@ -287,7 +302,12 @@ const resizeOperation = defineOperation<z.infer<typeof resizeSchema>, WebviewSta
287
302
 
288
303
  const evaluateShape = {
289
304
  expression: z.unknown().optional().describe('JavaScript expression to evaluate in the page context'),
290
- script: z.unknown().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
305
+ script: z
306
+ .unknown()
307
+ .optional()
308
+ .describe(
309
+ 'Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.',
310
+ ),
291
311
  };
292
312
  const evaluateSchema = z.looseObject(evaluateShape);
293
313
 
@@ -302,10 +322,16 @@ const evaluateOperation = defineOperation<z.infer<typeof evaluateSchema>, { valu
302
322
  },
303
323
  mcp: {
304
324
  toolName: 'canvas_evaluate',
305
- description: 'Evaluate JavaScript in the active Bun.WebView automation session for the workbench page. Use this to inspect rendered browser state. Requires an active automation session started via canvas_webview_start.',
325
+ description:
326
+ 'Evaluate JavaScript in the active Bun.WebView automation session for the workbench page. Use this to inspect rendered browser state. Requires an active automation session started via canvas_webview_start.',
306
327
  extraShape: {
307
328
  expression: z.string().optional().describe('JavaScript expression to evaluate in the page context'),
308
- script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
329
+ script: z
330
+ .string()
331
+ .optional()
332
+ .describe(
333
+ 'Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.',
334
+ ),
309
335
  },
310
336
  // Legacy canvas_evaluate validation: exactly one of expression/script (its
311
337
  // own message). Validate here so the MCP tool throws the legacy message
@@ -95,9 +95,8 @@ function linkedIntentId(rawInput: unknown): string | undefined {
95
95
 
96
96
  function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxIntentKind[] | undefined {
97
97
  if (name === 'jsonrender.stream') {
98
- const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
99
- ? rawInput as Record<string, unknown>
100
- : {};
98
+ const input =
99
+ rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
101
100
  return typeof input.nodeId === 'string' && input.nodeId.length > 0 ? ['edit'] : ['create'];
102
101
  }
103
102
  return INTENT_KINDS_BY_OPERATION[name];
@@ -107,7 +107,9 @@ export interface Operation {
107
107
  * Any `z.looseObject(...)` satisfies this.
108
108
  */
109
109
  export interface OperationInputSchema<I> {
110
- safeParse(value: unknown):
110
+ safeParse(
111
+ value: unknown,
112
+ ):
111
113
  | { success: true; data: I }
112
114
  | { success: false; error: { issues: Array<{ path: PropertyKey[]; message: string }> } };
113
115
  }
@@ -126,9 +128,7 @@ export interface OperationDefinition<I extends Record<string, unknown>, O> {
126
128
  serialize?: (output: O) => unknown;
127
129
  }
128
130
 
129
- export function defineOperation<I extends Record<string, unknown>, O>(
130
- def: OperationDefinition<I, O>,
131
- ): Operation {
131
+ export function defineOperation<I extends Record<string, unknown>, O>(def: OperationDefinition<I, O>): Operation {
132
132
  return {
133
133
  name: def.name,
134
134
  mutates: def.mutates,
@@ -139,7 +139,9 @@ export function defineOperation<I extends Record<string, unknown>, O>(
139
139
  const parsed = def.input.safeParse(rawInput ?? {});
140
140
  if (!parsed.success) {
141
141
  const detail = parsed.error.issues
142
- .map((issue) => (issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${issue.message}` : issue.message))
142
+ .map((issue) =>
143
+ issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${issue.message}` : issue.message,
144
+ )
143
145
  .join('; ');
144
146
  throw new OperationError(`Invalid input for ${def.name}: ${detail}`, 400);
145
147
  }
@@ -69,9 +69,7 @@ export function setWebviewRunner(runner: WebviewRunner | null): void {
69
69
 
70
70
  export function getWebviewRunner(): WebviewRunner {
71
71
  if (!injectedRunner) {
72
- throw new Error(
73
- 'Webview runner is not injected. server.ts must call setWebviewRunner(...) at module load.',
74
- );
72
+ throw new Error('Webview runner is not injected. server.ts must call setWebviewRunner(...) at module load.');
75
73
  }
76
74
  return injectedRunner;
77
75
  }