pmx-canvas 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Many boards (plan 012): board.list / board.get / board.create /
3
+ * board.update / board.open / board.delete.
4
+ *
5
+ * Agents list, read and create boards. Opening, switching and deleting are the
6
+ * human's (HUMAN_ONLY_OPS in registry.ts): an agent that wants another board
7
+ * asks. Every change broadcasts `boards-changed` with the full list.
8
+ *
9
+ * Route-order note: board.open (POST /api/canvas/boards/open) is registered
10
+ * before any `/api/canvas/boards/:id` POST route so ':id' cannot swallow it.
11
+ *
12
+ * This module must never import server.ts or index.ts.
13
+ */
14
+ import { z } from 'zod';
15
+ import { normalizeBoardCategory } from '../../../shared/boards.js';
16
+ import { canvasState, type CanvasLayout } from '../../canvas-state.js';
17
+ import { openCanvasBoard } from '../../canvas-operations.js';
18
+ import { summarizeNodeForAgentContext } from '../../agent-context.js';
19
+ import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
20
+
21
+ export function boardsPayload(): Record<string, unknown> {
22
+ const boards = canvasState.listBoards();
23
+ const names = new Map(boards.map((board) => [board.id, board.name]));
24
+ const links = new Map<string, Array<{ nodeId: string; boardId: string; title: string | null; missing: boolean }>>();
25
+ for (const board of boards) {
26
+ const nodes = canvasState.readBoard(board.id, false)?.layout.nodes ?? [];
27
+ links.set(
28
+ board.id,
29
+ nodes
30
+ .filter((node) => node.type === 'board')
31
+ .map((node) => {
32
+ const boardId = typeof node.data.boardId === 'string' ? node.data.boardId : '';
33
+ return { nodeId: node.id, boardId, title: names.get(boardId) ?? null, missing: !names.has(boardId) };
34
+ }),
35
+ );
36
+ }
37
+ return {
38
+ activeBoardId: canvasState.activeBoardId,
39
+ boards: boards.map((board) => {
40
+ const read = canvasState.readBoard(board.id, true);
41
+ const readme = read?.layout.nodes.find((node) => node.id === board.readmeNodeId && node.type === 'markdown');
42
+ return {
43
+ ...board,
44
+ summary: readme ? summarizeNodeForAgentContext(readme, { defaultTextLength: 1000 }) : null,
45
+ pinnedTitles: (read?.layout.nodes ?? [])
46
+ .filter((node) => read?.state.contextPins.includes(node.id))
47
+ .map((node) => ({ nodeId: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
48
+ links: links.get(board.id) ?? [],
49
+ backlinks: boards.flatMap((source) =>
50
+ (links.get(source.id) ?? [])
51
+ .filter((link) => link.boardId === board.id)
52
+ .map((link) => ({ boardId: source.id, title: source.name, nodeId: link.nodeId })),
53
+ ),
54
+ };
55
+ }),
56
+ };
57
+ }
58
+
59
+ /** Read a board without opening it. Omitted board preserves the active-board contract. */
60
+ export function readTargetBoard(
61
+ boardId?: unknown,
62
+ includeBlobs = true,
63
+ ): {
64
+ boardId: string | null;
65
+ layout: CanvasLayout;
66
+ pinnedNodeIds: Set<string>;
67
+ } {
68
+ const requested = typeof boardId === 'string' && boardId.trim() ? boardId.trim() : null;
69
+ if (!requested) {
70
+ return {
71
+ boardId: canvasState.activeBoardId,
72
+ layout: canvasState.getLayout(),
73
+ pinnedNodeIds: new Set(canvasState.contextPinnedNodeIds),
74
+ };
75
+ }
76
+ const read = canvasState.readBoard(requested, includeBlobs);
77
+ if (!read) throw new OperationError(`Board "${requested}" not found.`, 404);
78
+ return {
79
+ boardId: requested,
80
+ layout: read.layout,
81
+ pinnedNodeIds: new Set(read.state.contextPins),
82
+ };
83
+ }
84
+
85
+ function emitBoardsChanged(ctx: OperationContext): void {
86
+ ctx.emit('boards-changed', boardsPayload());
87
+ }
88
+
89
+ const jsonResult = (result: unknown) => ({
90
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
91
+ });
92
+
93
+ /** '' or null clears the category; undefined leaves it alone. */
94
+ function boardCategory(value: unknown): string | null | undefined {
95
+ if (value === undefined) return undefined;
96
+ try {
97
+ return normalizeBoardCategory(typeof value === 'string' ? value : null);
98
+ } catch (error) {
99
+ throw new OperationError(error instanceof Error ? error.message : String(error));
100
+ }
101
+ }
102
+
103
+ function boardName(value: unknown): string {
104
+ const name = typeof value === 'string' ? value.trim() : '';
105
+ if (!name) throw new OperationError('A board needs a name.');
106
+ if (name.length > 120) throw new OperationError('Board names are at most 120 characters.');
107
+ return name;
108
+ }
109
+
110
+ // ── board.list ────────────────────────────────────────────────
111
+
112
+ const listShape = {};
113
+ const listSchema = z.looseObject(listShape);
114
+
115
+ const boardListOperation = defineOperation<z.infer<typeof listSchema>, Record<string, unknown>>({
116
+ name: 'board.list',
117
+ mutates: false,
118
+ input: listSchema,
119
+ inputShape: listShape,
120
+ http: { method: 'GET', path: '/api/canvas/boards' },
121
+ mcp: {
122
+ toolName: 'canvas_list_boards',
123
+ description:
124
+ 'List the boards in this workspace, most recently opened first, with node counts and which one is open (activeBoardId; null means the human is on Home).',
125
+ formatResult: jsonResult,
126
+ },
127
+ handler: () => ({ ok: true, ...boardsPayload() }),
128
+ });
129
+
130
+ // ── board.get ─────────────────────────────────────────────────
131
+
132
+ const getShape = {
133
+ id: z.unknown().optional().describe('Board id (default: the open board)'),
134
+ };
135
+ const getSchema = z.looseObject(getShape);
136
+
137
+ const boardGetOperation = defineOperation<z.infer<typeof getSchema>, Record<string, unknown>>({
138
+ name: 'board.get',
139
+ mutates: false,
140
+ input: getSchema,
141
+ inputShape: getShape,
142
+ http: { method: 'GET', path: '/api/canvas/boards/:id' },
143
+ mcp: {
144
+ toolName: 'canvas_get_board',
145
+ description: 'Read one board by id (name, created, last opened, node count). Omit id for the open board.',
146
+ extraShape: { id: z.string().optional().describe('Board id (default: the open board)') },
147
+ formatResult: jsonResult,
148
+ },
149
+ handler: (input) => {
150
+ const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : canvasState.activeBoardId;
151
+ const board = id ? canvasState.listBoards().find((entry) => entry.id === id) : undefined;
152
+ if (!board) throw new OperationError(id ? `Board "${id}" not found.` : 'No board is open.', 404);
153
+ return { ok: true, board, open: board.id === canvasState.activeBoardId };
154
+ },
155
+ });
156
+
157
+ // ── board.create ──────────────────────────────────────────────
158
+
159
+ const createShape = {
160
+ name: z.unknown().optional().describe('Board name'),
161
+ category: z.unknown().optional().describe('Folder path on Home, e.g. Engineering/Canvas'),
162
+ };
163
+ const createSchema = z.looseObject(createShape);
164
+
165
+ const boardCreateOperation = defineOperation<z.infer<typeof createSchema>, Record<string, unknown>>({
166
+ name: 'board.create',
167
+ mutates: false,
168
+ input: createSchema,
169
+ inputShape: createShape,
170
+ http: { method: 'POST', path: '/api/canvas/boards' },
171
+ mcp: {
172
+ toolName: 'canvas_create_board',
173
+ description:
174
+ 'Create a new empty board. It is NOT opened: only the human opens boards, so ask them to open it before writing to it.',
175
+ extraShape: {
176
+ name: z.string().describe('Board name'),
177
+ category: z.string().optional().describe('Folder path on Home, e.g. Engineering/Canvas'),
178
+ },
179
+ formatResult: jsonResult,
180
+ },
181
+ handler: (input, ctx) => {
182
+ const board = canvasState.createBoard(boardName(input.name), boardCategory(input.category) ?? null);
183
+ if (!board) throw new OperationError('Boards need a workspace database.', 409);
184
+ emitBoardsChanged(ctx);
185
+ return { ok: true, board };
186
+ },
187
+ });
188
+
189
+ // ── board.open (human only) ───────────────────────────────────
190
+
191
+ const openShape = {
192
+ id: z.unknown().optional().describe('Board id to open; null or omitted opens Home'),
193
+ };
194
+ const openSchema = z.looseObject(openShape);
195
+
196
+ const boardOpenOperation = defineOperation<z.infer<typeof openSchema>, Record<string, unknown>>({
197
+ name: 'board.open',
198
+ mutates: false,
199
+ input: openSchema,
200
+ inputShape: openShape,
201
+ http: { method: 'POST', path: '/api/canvas/boards/open' },
202
+ handler: (input, ctx) => {
203
+ const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : null;
204
+ if (!openCanvasBoard(id).ok) throw new OperationError(`Board "${id}" not found.`, 404);
205
+ emitBoardsChanged(ctx);
206
+ return { ok: true, ...boardsPayload() };
207
+ },
208
+ });
209
+
210
+ // ── board.update ──────────────────────────────────────────────
211
+
212
+ const updateShape = {
213
+ id: z.unknown().optional().describe('Board id'),
214
+ name: z.unknown().optional().describe('New name'),
215
+ category: z.unknown().optional().describe('Folder path on Home; "" or null unfiles it'),
216
+ readmeNodeId: z.unknown().optional().describe('Markdown node id to use as README; null clears it'),
217
+ };
218
+ const updateSchema = z.looseObject(updateShape);
219
+
220
+ const boardUpdateOperation = defineOperation<z.infer<typeof updateSchema>, Record<string, unknown>>({
221
+ name: 'board.update',
222
+ mutates: false,
223
+ input: updateSchema,
224
+ inputShape: updateShape,
225
+ http: { method: 'PATCH', path: '/api/canvas/boards/:id' },
226
+ mcp: {
227
+ toolName: 'canvas_update_board',
228
+ description:
229
+ 'Rename a board, move it into a nested folder on Home (e.g. Engineering/Canvas; category "" unfiles it), or designate its markdown introduction with readmeNodeId. During board authoring, set a useful README when none exists; preserve an existing designation unless the human requests a change. Moving preserves the board ID and content.',
230
+ extraShape: {
231
+ id: z.string().describe('Board id'),
232
+ name: z.string().optional().describe('New name'),
233
+ category: z.string().optional().describe('Folder path, up to 8 levels of 1–60 characters; "" unfiles it'),
234
+ readmeNodeId: z.string().nullable().optional().describe('Markdown node id used as the board README'),
235
+ },
236
+ formatResult: jsonResult,
237
+ },
238
+ handler: (input, ctx) => {
239
+ const id = typeof input.id === 'string' ? input.id : '';
240
+ const patch = {
241
+ ...(input.name !== undefined ? { name: boardName(input.name) } : {}),
242
+ ...(input.category !== undefined ? { category: boardCategory(input.category) ?? null } : {}),
243
+ };
244
+ if (input.readmeNodeId !== undefined) {
245
+ const board = canvasState.readBoard(id, false);
246
+ if (!board) throw new OperationError(`Board "${id}" not found.`, 404);
247
+ if (
248
+ input.readmeNodeId &&
249
+ !board.layout.nodes.some((node) => node.id === input.readmeNodeId && node.type === 'markdown')
250
+ ) {
251
+ throw new OperationError('README must identify a markdown node on this board.');
252
+ }
253
+ }
254
+ if (!canvasState.updateBoard(id, patch)) {
255
+ throw new OperationError(`Board "${id}" not found.`, 404);
256
+ }
257
+ if (input.readmeNodeId !== undefined) {
258
+ const nodeId = typeof input.readmeNodeId === 'string' && input.readmeNodeId ? input.readmeNodeId : null;
259
+ if (!canvasState.setBoardReadme(id, nodeId)) {
260
+ throw new OperationError('README must identify a markdown node on this board.');
261
+ }
262
+ }
263
+ emitBoardsChanged(ctx);
264
+ return { ok: true, ...boardsPayload() };
265
+ },
266
+ });
267
+
268
+ const fromShape = {
269
+ sourceBoardId: z.string().describe('Source board id'),
270
+ name: z.string().describe('New board name'),
271
+ category: z.string().optional(),
272
+ nodeIds: z.array(z.string()).optional(),
273
+ includeReadme: z.boolean().optional(),
274
+ includeStructure: z.boolean().optional(),
275
+ preview: z.boolean().optional().describe('Return reusable cards and structure without creating'),
276
+ };
277
+ const fromSchema = z.looseObject(fromShape);
278
+ const boardCreateFromOperation = defineOperation<z.infer<typeof fromSchema>, Record<string, unknown>>({
279
+ name: 'board.create-from',
280
+ // Like board.create, this writes only a new inactive board. `mutates` means
281
+ // active-layout mutation: setting it would auto-open a board from Home.
282
+ mutates: false,
283
+ input: fromSchema,
284
+ inputShape: fromShape,
285
+ http: { method: 'POST', path: '/api/canvas/boards/from' },
286
+ mcp: {
287
+ toolName: 'canvas_create_board_from',
288
+ description: 'Preview or create an inactive board from selected reusable cards and structure on another board.',
289
+ extraShape: fromShape,
290
+ formatResult: jsonResult,
291
+ },
292
+ handler: (input, ctx) => {
293
+ const source = canvasState.readBoard(input.sourceBoardId, false);
294
+ if (!source) throw new OperationError(`Board "${input.sourceBoardId}" not found.`, 404);
295
+ if (input.preview) {
296
+ return {
297
+ ok: true,
298
+ sourceBoardId: input.sourceBoardId,
299
+ readmeNodeId: source.board.readmeNodeId,
300
+ cards: source.layout.nodes.map((node) => ({
301
+ id: node.id,
302
+ type: node.type,
303
+ title: typeof node.data.title === 'string' ? node.data.title : null,
304
+ reusable: !['prompt', 'response', 'trace', 'mcp-app'].includes(node.type),
305
+ })),
306
+ edges: source.layout.edges.map((edge) => ({ id: edge.id, from: edge.from, to: edge.to, type: edge.type })),
307
+ };
308
+ }
309
+ const board = canvasState.createBoardFromBoard({
310
+ ...input,
311
+ name: boardName(input.name),
312
+ category: boardCategory(input.category),
313
+ });
314
+ if (!board) throw new OperationError('Could not create board from source.', 409);
315
+ emitBoardsChanged(ctx);
316
+ return { ok: true, board };
317
+ },
318
+ });
319
+
320
+ // ── board.delete (human only) ─────────────────────────────────
321
+
322
+ const deleteShape = {
323
+ id: z.unknown().optional().describe('Board id'),
324
+ };
325
+ const deleteSchema = z.looseObject(deleteShape);
326
+
327
+ const boardDeleteOperation = defineOperation<z.infer<typeof deleteSchema>, Record<string, unknown>>({
328
+ name: 'board.delete',
329
+ mutates: false,
330
+ input: deleteSchema,
331
+ inputShape: deleteShape,
332
+ http: { method: 'DELETE', path: '/api/canvas/boards/:id' },
333
+ handler: (input, ctx) => {
334
+ const id = typeof input.id === 'string' ? input.id : '';
335
+ // Deleting the open board returns to Home through the same path as opening it.
336
+ if (id === canvasState.activeBoardId) openCanvasBoard(null);
337
+ if (!canvasState.deleteBoard(id)) throw new OperationError(`Board "${id}" not found.`, 404);
338
+ emitBoardsChanged(ctx);
339
+ return { ok: true, ...boardsPayload() };
340
+ },
341
+ });
342
+
343
+ export const boardOperations: Operation[] = [
344
+ boardListOperation,
345
+ boardOpenOperation,
346
+ boardGetOperation,
347
+ boardCreateOperation,
348
+ boardCreateFromOperation,
349
+ boardUpdateOperation,
350
+ boardDeleteOperation,
351
+ ];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Static board export (plan 013): export.preview / export.run. HTTP, CLI and
3
+ * SDK — sharing is the human's act, so there is no MCP surface. The written
4
+ * file is served by GET /api/canvas/exports/:name (server.ts).
5
+ *
6
+ * This module must never import server.ts or index.ts.
7
+ */
8
+ import { basename } from 'node:path';
9
+ import { z } from 'zod';
10
+ import { buildBoardExport, previewBoardExport, writeBoardExport } from '../../board-export.js';
11
+ import { canvasState } from '../../canvas-state.js';
12
+ import { defineOperation, OperationError, type Operation } from '../types.js';
13
+
14
+ const shape = {
15
+ board: z.unknown().optional().describe('Board id (default: the open board)'),
16
+ includeFiles: z.unknown().optional().describe('Include the contents of file cards (default: false)'),
17
+ includeDerivedText: z
18
+ .unknown()
19
+ .optional()
20
+ .describe('Include imported document text (default: false; originals remain excluded)'),
21
+ };
22
+ const schema = z.looseObject(shape);
23
+
24
+ function target(input: z.infer<typeof schema>): { boardId: string; includeFiles: boolean } {
25
+ const boardId =
26
+ typeof input.board === 'string' && input.board.trim() ? input.board.trim() : canvasState.activeBoardId;
27
+ if (!boardId) throw new OperationError('No board is open. Open one, or pass board.', 409);
28
+ return { boardId, includeFiles: input.includeFiles === true || input.includeFiles === 'true' };
29
+ }
30
+
31
+ const exportPreviewOperation = defineOperation<z.infer<typeof schema>, Record<string, unknown>>({
32
+ name: 'export.preview',
33
+ mutates: false,
34
+ input: schema,
35
+ inputShape: shape,
36
+ http: { method: 'GET', path: '/api/canvas/export/preview' },
37
+ handler: async (input) => {
38
+ const { boardId, includeFiles } = target(input);
39
+ const manifest = await previewBoardExport(
40
+ boardId,
41
+ includeFiles,
42
+ input.includeDerivedText === true || input.includeDerivedText === 'true',
43
+ );
44
+ if (!manifest) throw new OperationError(`Board "${boardId}" not found.`, 404);
45
+ return { ok: true, manifest };
46
+ },
47
+ });
48
+
49
+ const exportRunOperation = defineOperation<z.infer<typeof schema>, Record<string, unknown>>({
50
+ name: 'export.run',
51
+ mutates: false,
52
+ input: schema,
53
+ inputShape: shape,
54
+ http: { method: 'POST', path: '/api/canvas/export' },
55
+ handler: async (input) => {
56
+ const { boardId, includeFiles } = target(input);
57
+ const built = await buildBoardExport(
58
+ boardId,
59
+ includeFiles,
60
+ input.includeDerivedText === true || input.includeDerivedText === 'true',
61
+ );
62
+ if (!built) throw new OperationError(`Board "${boardId}" not found.`, 404);
63
+ const path = writeBoardExport(built.html, built.manifest.boardName);
64
+ return {
65
+ ok: true,
66
+ path,
67
+ url: `/api/canvas/exports/${encodeURIComponent(basename(path))}`,
68
+ bytes: Buffer.byteLength(built.html),
69
+ manifest: built.manifest,
70
+ };
71
+ },
72
+ });
73
+
74
+ export const exportOperations: Operation[] = [exportPreviewOperation, exportRunOperation];
@@ -204,10 +204,14 @@ const callToolOperation = defineOperation<z.infer<typeof callToolSchema>, Record
204
204
  canReadLocalCheckpoint && toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL
205
205
  ? getLocalExcalidrawCheckpointData(requestedNode, args)
206
206
  : null;
207
+ const boardGeneration = canvasState.boardGeneration;
207
208
  const result =
208
209
  localCheckpointData === null
209
210
  ? await callMcpAppTool(sessionId, toolName, args)
210
211
  : ({ content: [{ type: 'text', text: localCheckpointData }] } satisfies CallToolResult);
212
+ if (canvasState.boardGeneration !== boardGeneration) {
213
+ throw new OperationError('The board changed while the MCP app tool was running.', 409);
214
+ }
211
215
  if (nodeId) {
212
216
  const node = canvasState.getNode(nodeId);
213
217
  if (node?.type === 'mcp-app' && node.data.mode === 'ext-app' && node.data.appSessionId === sessionId) {
@@ -42,7 +42,7 @@ const groupCreateShape = {
42
42
 
43
43
  const groupCreateSchema = z.looseObject(groupCreateShape);
44
44
 
45
- const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>, CanvasNodeState>({
45
+ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>, ReturnType<typeof createCanvasGroup>>({
46
46
  name: 'group.create',
47
47
  mutates: true,
48
48
  input: groupCreateSchema,
@@ -69,7 +69,12 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
69
69
  formatResult: (result, input) => {
70
70
  const body = isRecord(result) ? result : {};
71
71
  const node = body.node as CanvasNodeState | undefined;
72
- const payload = node ? createdNodePayloadFromNode(node, input) : { ok: true };
72
+ const payload = node
73
+ ? {
74
+ ...createdNodePayloadFromNode(node, input),
75
+ ...(body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {}),
76
+ }
77
+ : { ok: true };
73
78
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
74
79
  },
75
80
  },
@@ -93,7 +98,7 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
93
98
  );
94
99
  }
95
100
  }
96
- const { node } = createCanvasGroup({
101
+ return createCanvasGroup({
97
102
  title,
98
103
  childIds,
99
104
  color,
@@ -103,9 +108,11 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
103
108
  height,
104
109
  ...(childLayout ? { childLayout } : {}),
105
110
  });
106
- return node;
107
111
  },
108
- serialize: (node) => buildNodeResponse(node),
112
+ serialize: ({ node, sizeAdjustment }) => ({
113
+ ...buildNodeResponse(node),
114
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
115
+ }),
109
116
  });
110
117
 
111
118
  // ── group.add ─────────────────────────────────────────────────
@@ -157,9 +164,9 @@ const groupAddOperation = defineOperation<z.infer<typeof groupAddSchema>, Record
157
164
  if (!groupId || childIds.length === 0) {
158
165
  throw new OperationError('Missing groupId or childIds.');
159
166
  }
160
- const { ok } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
167
+ const { ok, sizeAdjustment } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
161
168
  if (!ok) throw new OperationError('Group not found or no valid children.');
162
- return { ok: true, groupId };
169
+ return { ok: true, groupId, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
163
170
  },
164
171
  });
165
172