pmx-canvas 0.1.13 → 0.1.15

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 (58) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +137 -87
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  9. package/dist/types/client/state/canvas-store.d.ts +5 -1
  10. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +12 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/diagram-presets.d.ts +4 -0
  21. package/dist/types/server/index.d.ts +21 -3
  22. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +18 -0
  24. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +43 -0
  27. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  28. package/src/cli/agent.ts +66 -5
  29. package/src/cli/index.ts +2 -23
  30. package/src/client/canvas/AttentionHistory.tsx +14 -1
  31. package/src/client/canvas/CanvasNode.tsx +1 -1
  32. package/src/client/canvas/CanvasViewport.tsx +3 -0
  33. package/src/client/canvas/DockedNode.tsx +110 -12
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +8 -3
  35. package/src/client/canvas/Minimap.tsx +1 -0
  36. package/src/client/icons.tsx +1 -0
  37. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  38. package/src/client/nodes/HtmlNode.tsx +151 -0
  39. package/src/client/nodes/McpAppNode.tsx +2 -2
  40. package/src/client/state/canvas-store.ts +24 -2
  41. package/src/client/state/intent-bridge.ts +4 -3
  42. package/src/client/state/sse-bridge.ts +2 -0
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +199 -26
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +55 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/diagram-presets.ts +45 -25
  54. package/src/server/index.ts +64 -7
  55. package/src/server/mcp-app-runtime.ts +15 -5
  56. package/src/server/server.ts +169 -63
  57. package/src/server/web-artifacts.ts +116 -3
  58. package/src/shared/canvas-node-kind.ts +14 -0
package/src/mcp/server.ts CHANGED
@@ -165,9 +165,73 @@ function encodeBase64(bytes: Uint8Array): string {
165
165
  return Buffer.from(bytes).toString('base64');
166
166
  }
167
167
 
168
- async function createdNodePayload(c: CanvasAccess, id: string): Promise<Record<string, unknown>> {
168
+ function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): boolean {
169
+ return input.full === true || input.verbose === true || input.includeData === true;
170
+ }
171
+
172
+ function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
173
+ if (!node) return null;
174
+ const serialized = serializeCanvasNode(node);
175
+ return {
176
+ id: serialized.id,
177
+ type: serialized.type,
178
+ kind: serialized.kind,
179
+ title: serialized.title,
180
+ content: serialized.content,
181
+ position: serialized.position,
182
+ size: serialized.size,
183
+ pinned: serialized.pinned,
184
+ collapsed: serialized.collapsed,
185
+ dockPosition: serialized.dockPosition,
186
+ provenance: serialized.provenance,
187
+ };
188
+ }
189
+
190
+ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>, pinnedIds: string[]): Record<string, unknown> {
191
+ return {
192
+ summary: buildSummaryFromLayout(layout, pinnedIds),
193
+ viewport: layout.viewport,
194
+ nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
195
+ edges: layout.edges.map((edge) => ({
196
+ id: edge.id,
197
+ from: edge.from,
198
+ to: edge.to,
199
+ type: edge.type,
200
+ ...(edge.label ? { label: edge.label } : {}),
201
+ ...(edge.style ? { style: edge.style } : {}),
202
+ ...(edge.animated !== undefined ? { animated: edge.animated } : {}),
203
+ })),
204
+ };
205
+ }
206
+
207
+ function compactBatchValue(value: unknown): unknown {
208
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
209
+ const record = value as Record<string, unknown>;
210
+ const nodeLike = typeof record.id === 'string' && typeof record.type === 'string';
211
+ const compact: Record<string, unknown> = {};
212
+ for (const key of ['ok', 'id', 'type', 'kind', 'title', 'content', 'position', 'size', 'fetch', 'error', 'from', 'to', 'groupId', 'nodeIds', 'snapshot', 'arranged', 'layout']) {
213
+ if (record[key] !== undefined) compact[key] = record[key];
214
+ }
215
+ if (nodeLike) return compact;
216
+ return record;
217
+ }
218
+
219
+ function compactBatchResult(result: { ok: boolean; results: Array<Record<string, unknown>>; refs: Record<string, unknown>; failedIndex?: number; error?: string }): Record<string, unknown> {
220
+ return {
221
+ ok: result.ok,
222
+ ...(result.failedIndex !== undefined ? { failedIndex: result.failedIndex } : {}),
223
+ ...(result.error ? { error: result.error } : {}),
224
+ results: result.results.map((entry) => compactBatchValue(entry)),
225
+ refs: Object.fromEntries(Object.entries(result.refs).map(([key, value]) => [key, compactBatchValue(value)])),
226
+ };
227
+ }
228
+
229
+ async function createdNodePayload(c: CanvasAccess, id: string, options: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): Promise<Record<string, unknown>> {
169
230
  const node = await c.getNode(id);
170
231
  if (!node) return { ok: true, id };
232
+ if (!wantsFullPayload(options)) {
233
+ return { ok: true, node: compactNodePayload(node), id };
234
+ }
171
235
  const serialized = serializeCanvasNode(node);
172
236
  return { ok: true, node: serialized, ...serialized };
173
237
  }
@@ -214,13 +278,19 @@ export async function startMcpServer(): Promise<void> {
214
278
  // ── canvas_get_layout ──────────────────────────────────────────
215
279
  server.tool(
216
280
  'canvas_get_layout',
217
- 'Get the full canvas state: all nodes, edges, and viewport. Call this first to understand what is on the canvas.',
218
- {},
219
- async () => {
281
+ 'Get the canvas layout. Defaults to a compact agent-safe projection; pass full:true for full node data.',
282
+ {
283
+ full: z.boolean().optional().describe('Return the full layout including node data. Default false keeps responses compact.'),
284
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
285
+ },
286
+ async (input) => {
220
287
  const c = await ensureCanvas();
221
- const layout = serializeCanvasLayout(await c.getLayout());
288
+ const layout = await c.getLayout();
289
+ const payload = wantsFullPayload(input)
290
+ ? serializeCanvasLayout(layout)
291
+ : compactLayoutPayload(layout, await c.getPinnedNodeIds());
222
292
  return {
223
- content: [{ type: 'text', text: JSON.stringify(layout, null, 2) }],
293
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
224
294
  };
225
295
  },
226
296
  );
@@ -228,19 +298,24 @@ export async function startMcpServer(): Promise<void> {
228
298
  // ── canvas_get_node ────────────────────────────────────────────
229
299
  server.tool(
230
300
  'canvas_get_node',
231
- 'Get a single node by ID, including its full data.',
232
- { id: z.string().describe('The node ID to retrieve') },
233
- async ({ id }) => {
301
+ 'Get a single node by ID. Defaults to compact metadata; pass full:true to include full data/tool results.',
302
+ {
303
+ id: z.string().describe('The node ID to retrieve'),
304
+ full: z.boolean().optional().describe('Include full node data, including mcp-app tool results. Default false.'),
305
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
306
+ },
307
+ async (input) => {
234
308
  const c = await ensureCanvas();
235
- const node = await c.getNode(id);
309
+ const node = await c.getNode(input.id);
236
310
  if (!node) {
237
311
  return {
238
- content: [{ type: 'text', text: `Node "${id}" not found.` }],
312
+ content: [{ type: 'text', text: `Node "${input.id}" not found.` }],
239
313
  isError: true,
240
314
  };
241
315
  }
316
+ const payload = wantsFullPayload(input) ? serializeCanvasNode(node) : compactNodePayload(node);
242
317
  return {
243
- content: [{ type: 'text', text: JSON.stringify(serializeCanvasNode(node), null, 2) }],
318
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
244
319
  };
245
320
  },
246
321
  );
@@ -248,9 +323,9 @@ export async function startMcpServer(): Promise<void> {
248
323
  // ── canvas_add_node ────────────────────────────────────────────
249
324
  server.tool(
250
325
  'canvas_add_node',
251
- 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, group. Dedicated node tools: json-render -> canvas_add_json_render_node, graph -> canvas_add_graph_node, web-artifact -> canvas_build_web_artifact, external apps -> canvas_open_mcp_app, groups -> canvas_create_group. Call canvas_describe_schema for the full nodeTypeRouting table.',
326
+ 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated node tools: json-render -> canvas_add_json_render_node, graph -> canvas_add_graph_node, web-artifact -> canvas_build_web_artifact, external apps -> canvas_open_mcp_app, html (preferred) -> canvas_add_html_node, groups -> canvas_create_group. Call canvas_describe_schema for the full nodeTypeRouting table.',
252
327
  {
253
- type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'group'])
328
+ type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'html', 'group'])
254
329
  .describe('Node type (prefer canvas_create_group for groups)'),
255
330
  title: z.string().optional().describe('Node title'),
256
331
  content: z.string().optional().describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
@@ -261,6 +336,14 @@ export async function startMcpServer(): Promise<void> {
261
336
  width: z.number().optional().describe('Width in pixels (default: 720)'),
262
337
  height: z.number().optional().describe('Height in pixels (default: 600)'),
263
338
  strictSize: z.boolean().optional().describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
339
+ toolName: z.string().optional().describe('Trace node tool or operation label'),
340
+ category: z.string().optional().describe('Trace node category: mcp, file, subagent, or other'),
341
+ status: z.string().optional().describe('Trace node status: running, success, or failed'),
342
+ duration: z.string().optional().describe('Trace node duration badge text'),
343
+ resultSummary: z.string().optional().describe('Trace node result summary'),
344
+ error: z.string().optional().describe('Trace node error message'),
345
+ full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
346
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
264
347
  },
265
348
  async (input) => {
266
349
  const c = await ensureCanvas();
@@ -291,7 +374,39 @@ export async function startMcpServer(): Promise<void> {
291
374
  : input;
292
375
  const id = await c.addNode(nodeInput);
293
376
  return {
294
- content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id), null, 2) }],
377
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
378
+ };
379
+ },
380
+ );
381
+
382
+ // ── canvas_add_html_node ────────────────────────────────────────
383
+ server.tool(
384
+ 'canvas_add_html_node',
385
+ 'Add an html node: a self-contained HTML document (with optional inline <script> and CDN <script src="...">) rendered inside a sandboxed iframe (sandbox="allow-scripts"). Use this for moderate-complexity visualizations or interactive widgets that need real JS but do not warrant a full React/shadcn build. The iframe inherits canvas theme tokens via injected CSS custom properties (both --c-* and common --color-* aliases) so authored HTML using var(--color-text-secondary), var(--color-bg), etc. renders cohesively. No same-origin access; no top-navigation; no forms. For declarative-only views with zero JS, prefer canvas_add_json_render_node. For React + shadcn + routing or multi-component apps, use canvas_build_web_artifact.',
386
+ {
387
+ html: z.string().describe('HTML document or fragment. Full <html>...</html> documents are passed through with theme styles injected into <head>; bare fragments are wrapped in a minimal document. Inline <script> and remote CDN <script src="..."> are allowed.'),
388
+ title: z.string().optional().describe('Node title shown in the canvas titlebar.'),
389
+ x: z.number().optional().describe('X position (auto-placed if omitted).'),
390
+ y: z.number().optional().describe('Y position (auto-placed if omitted).'),
391
+ width: z.number().optional().describe('Width in pixels (default: 720).'),
392
+ height: z.number().optional().describe('Height in pixels (default: 640).'),
393
+ strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
394
+ full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
395
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
396
+ },
397
+ async (input) => {
398
+ const c = await ensureCanvas();
399
+ const id = await c.addHtmlNode({
400
+ html: input.html,
401
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
402
+ ...(typeof input.x === 'number' ? { x: input.x } : {}),
403
+ ...(typeof input.y === 'number' ? { y: input.y } : {}),
404
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
405
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
406
+ ...(input.strictSize === true ? { strictSize: true } : {}),
407
+ });
408
+ return {
409
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
295
410
  };
296
411
  },
297
412
  );
@@ -303,11 +418,13 @@ export async function startMcpServer(): Promise<void> {
303
418
  toolName: z.string().describe('Tool name on the external MCP server'),
304
419
  serverName: z.string().optional().describe('Optional display name for the external MCP server'),
305
420
  toolArguments: z.record(z.string(), z.unknown()).optional().describe('Arguments passed to the external tool call'),
421
+ nodeId: z.string().optional().describe('Existing mcp-app node ID to update in place instead of creating a new node.'),
306
422
  title: z.string().optional().describe('Optional canvas node title override'),
307
423
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
308
424
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
309
425
  width: z.number().optional().describe('Width in pixels (default: 720)'),
310
426
  height: z.number().optional().describe('Height in pixels (default: 500)'),
427
+ timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for cold external app servers'),
311
428
  transport: z.union([
312
429
  z.object({
313
430
  type: z.literal('stdio'),
@@ -331,11 +448,13 @@ export async function startMcpServer(): Promise<void> {
331
448
  toolName: input.toolName,
332
449
  ...(typeof input.serverName === 'string' ? { serverName: input.serverName } : {}),
333
450
  ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
451
+ ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
334
452
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
335
453
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
336
454
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
337
455
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
338
456
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
457
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
339
458
  });
340
459
  return {
341
460
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -357,27 +476,37 @@ export async function startMcpServer(): Promise<void> {
357
476
  z.string().describe('JSON array string of Excalidraw elements'),
358
477
  z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
359
478
  ]).describe('Excalidraw elements to render. See https://github.com/excalidraw/excalidraw-mcp for the element format.'),
479
+ nodeId: z.string().optional().describe('Existing Excalidraw mcp-app node ID to update in place instead of creating a new node.'),
360
480
  title: z.string().optional().describe('Optional canvas node title override'),
361
481
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
362
482
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
363
483
  width: z.number().optional().describe('Width in pixels (default: 720)'),
364
484
  height: z.number().optional().describe('Height in pixels (default: 500)'),
485
+ timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for Excalidraw cold starts. Client-side MCP hosts may still enforce their own total request timeout.'),
365
486
  },
366
- async (input) => {
487
+ async (input, extra) => {
367
488
  const c = await ensureCanvas();
368
489
  try {
369
490
  const result = await c.addDiagram({
370
491
  elements: input.elements,
492
+ ...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
371
493
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
372
494
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
373
495
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
374
496
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
375
497
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
498
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
376
499
  });
377
500
  return {
378
501
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
379
502
  };
380
503
  } catch (error) {
504
+ if (extra.signal.aborted) {
505
+ return {
506
+ content: [{ type: 'text', text: 'canvas_add_diagram was cancelled by the MCP client before Excalidraw finished. Retry with a higher client request timeout and pass timeoutMs to PMX Canvas for the downstream Excalidraw call.' }],
507
+ isError: true,
508
+ };
509
+ }
381
510
  return {
382
511
  content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
383
512
  isError: true,
@@ -534,7 +663,10 @@ export async function startMcpServer(): Promise<void> {
534
663
  bytes: result.fileSize,
535
664
  projectPath: result.projectPath,
536
665
  openedInCanvas: result.openedInCanvas,
666
+ startedAt: result.startedAt,
537
667
  completedAt: result.completedAt,
668
+ durationMs: result.durationMs,
669
+ timeoutMs: result.timeoutMs,
538
670
  // `id` only present when a canvas node was actually created.
539
671
  // See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
540
672
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
@@ -700,10 +832,19 @@ export async function startMcpServer(): Promise<void> {
700
832
  xKey: z.string().optional().describe('Graph x/category key'),
701
833
  yKey: z.string().optional().describe('Graph y/value key'),
702
834
  chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
835
+ toolName: z.string().optional().describe('Trace node tool or operation label'),
836
+ category: z.string().optional().describe('Trace node category: mcp, file, subagent, or other'),
837
+ status: z.string().optional().describe('Trace node status: running, success, or failed'),
838
+ duration: z.string().optional().describe('Trace node duration badge text'),
839
+ resultSummary: z.string().optional().describe('Trace node result summary'),
840
+ error: z.string().optional().describe('Trace node error message'),
703
841
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
704
842
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
843
+ full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
844
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
705
845
  },
706
- async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
846
+ async (input) => {
847
+ const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
707
848
  const c = await ensureCanvas();
708
849
  const node = await c.getNode(id);
709
850
  if (!node) {
@@ -730,13 +871,19 @@ export async function startMcpServer(): Promise<void> {
730
871
  if (xKey !== undefined) patch.xKey = xKey;
731
872
  if (yKey !== undefined) patch.yKey = yKey;
732
873
  if (chartHeight !== undefined) patch.chartHeight = chartHeight;
874
+ if (toolName !== undefined) patch.toolName = toolName;
875
+ if (category !== undefined) patch.category = category;
876
+ if (status !== undefined) patch.status = status;
877
+ if (duration !== undefined) patch.duration = duration;
878
+ if (resultSummary !== undefined) patch.resultSummary = resultSummary;
879
+ if (error !== undefined) patch.error = error;
733
880
  if (arrangeLocked !== undefined) {
734
881
  patch.arrangeLocked = arrangeLocked;
735
882
  }
736
883
  await c.updateNode(id, patch);
737
884
  const updated = await c.getNode(id);
738
885
  return {
739
- content: [{ type: 'text', text: JSON.stringify(updated ? await createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
886
+ content: [{ type: 'text', text: JSON.stringify(updated ? await createdNodePayload(c, id, input) : { ok: true, id }, null, 2) }],
740
887
  };
741
888
  },
742
889
  );
@@ -1443,12 +1590,14 @@ export async function startMcpServer(): Promise<void> {
1443
1590
  width: z.number().optional().describe('Width (auto-computed from children if omitted)'),
1444
1591
  height: z.number().optional().describe('Height (auto-computed from children if omitted)'),
1445
1592
  childLayout: z.enum(['grid', 'column', 'flow']).optional().describe('Optional child auto-layout. Omit to preserve current child positions.'),
1593
+ full: z.boolean().optional().describe('Return the full created group payload. Default false returns compact metadata.'),
1594
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
1446
1595
  },
1447
1596
  async (input) => {
1448
1597
  const c = await ensureCanvas();
1449
1598
  const id = await c.createGroup(input);
1450
1599
  return {
1451
- content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id), null, 2) }],
1600
+ content: [{ type: 'text', text: JSON.stringify(await createdNodePayload(c, id, input), null, 2) }],
1452
1601
  };
1453
1602
  },
1454
1603
  );
@@ -1481,12 +1630,15 @@ export async function startMcpServer(): Promise<void> {
1481
1630
  assign: z.string().optional().describe('Optional reference name for later operations'),
1482
1631
  args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
1483
1632
  })).describe('Ordered array of batch operations'),
1633
+ full: z.boolean().optional().describe('Return full batch operation results. Default false compacts node-like payloads.'),
1634
+ verbose: z.boolean().optional().describe('Alias for full:true.'),
1484
1635
  },
1485
- async ({ operations }) => {
1636
+ async (input) => {
1486
1637
  const c = await ensureCanvas();
1487
- const result = await c.runBatch(operations);
1638
+ const result = await c.runBatch(input.operations);
1639
+ const payload = wantsFullPayload(input) ? result : compactBatchResult(result);
1488
1640
  return {
1489
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1641
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1490
1642
  ...(result.ok ? {} : { isError: true }),
1491
1643
  };
1492
1644
  },
@@ -1566,12 +1718,33 @@ export async function startMcpServer(): Promise<void> {
1566
1718
  // ── canvas_list_snapshots ───────────────────────────────────
1567
1719
  server.tool(
1568
1720
  'canvas_list_snapshots',
1569
- 'List all saved canvas snapshots with IDs, names, timestamps, and node/edge counts.',
1570
- {},
1571
- async () => {
1721
+ '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.',
1722
+ {
1723
+ limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
1724
+ query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
1725
+ all: z.boolean().optional().describe('Return all snapshots instead of the default limit'),
1726
+ },
1727
+ async (input) => {
1728
+ const c = await ensureCanvas();
1729
+ return {
1730
+ content: [{ type: 'text', text: JSON.stringify({ snapshots: await c.listSnapshots(input) }, null, 2) }],
1731
+ };
1732
+ },
1733
+ );
1734
+
1735
+ // ── canvas_gc_snapshots ─────────────────────────────────────
1736
+ server.tool(
1737
+ 'canvas_gc_snapshots',
1738
+ 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
1739
+ {
1740
+ keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
1741
+ dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
1742
+ },
1743
+ async (input) => {
1572
1744
  const c = await ensureCanvas();
1745
+ const result = await c.gcSnapshots(input);
1573
1746
  return {
1574
- content: [{ type: 'text', text: JSON.stringify({ snapshots: await c.listSnapshots() }, null, 2) }],
1747
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1575
1748
  };
1576
1749
  },
1577
1750
  );
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from './canvas-state.js';
2
+ import { getCanvasNodeKind } from '../shared/canvas-node-kind.js';
2
3
 
3
4
  const DEFAULT_CONTEXT_TEXT_LENGTH = 700;
4
5
  const DEFAULT_WEBPAGE_CONTEXT_TEXT_LENGTH = 1600;
@@ -6,6 +7,7 @@ const DEFAULT_WEBPAGE_CONTEXT_TEXT_LENGTH = 1600;
6
7
  export interface AgentContextNode {
7
8
  id: string;
8
9
  type: CanvasNodeState['type'];
10
+ kind: string;
9
11
  title: string | null;
10
12
  content: string | null;
11
13
  metadata?: Record<string, unknown>;
@@ -118,6 +120,41 @@ function summarizeMcpAppData(data: Record<string, unknown>, maxLength: number):
118
120
  return truncateContextText(parts.join('\n'), maxLength);
119
121
  }
120
122
 
123
+ function summarizeWebArtifactData(data: Record<string, unknown>, maxLength: number): string {
124
+ const parts: string[] = [];
125
+ const content = typeof data.content === 'string' ? data.content : '';
126
+ const title = typeof data.title === 'string' ? data.title : '';
127
+ const path = typeof data.path === 'string' ? data.path : '';
128
+ const url = typeof data.url === 'string' ? data.url : '';
129
+ const projectPath = typeof data.projectPath === 'string' ? data.projectPath : '';
130
+ const artifactBytes = typeof data.artifactBytes === 'number' ? data.artifactBytes : null;
131
+ const sourceFileCount = typeof data.sourceFileCount === 'number' ? data.sourceFileCount : null;
132
+ const sourceFiles = Array.isArray(data.sourceFiles)
133
+ ? data.sourceFiles.filter((file): file is string => typeof file === 'string')
134
+ : [];
135
+ const deps = Array.isArray(data.deps)
136
+ ? data.deps.filter((dep): dep is string => typeof dep === 'string')
137
+ : [];
138
+
139
+ if (content) parts.push(content);
140
+ if (!content && title) parts.push(`Web artifact: ${title}`);
141
+ if (sourceFiles.length > 0 && !content.includes('Source files:')) {
142
+ const remaining = sourceFileCount !== null ? Math.max(0, sourceFileCount - sourceFiles.length) : 0;
143
+ parts.push(`Source files: ${sourceFiles.join(', ')}${remaining > 0 ? `, +${remaining} more` : ''}`);
144
+ }
145
+ if (artifactBytes !== null && !content.includes('Artifact bytes:')) {
146
+ parts.push(`Artifact bytes: ${artifactBytes}`);
147
+ }
148
+ if (deps.length > 0 && !content.includes('Dependencies:')) {
149
+ parts.push(`Dependencies: ${deps.join(', ')}`);
150
+ }
151
+ if (path) parts.push(`Path: ${path}`);
152
+ if (projectPath) parts.push(`Project: ${projectPath}`);
153
+ if (url) parts.push(`URL: ${url}`);
154
+
155
+ return parts.length > 0 ? truncateContextText(parts.join('\n'), maxLength) : 'Web artifact node';
156
+ }
157
+
121
158
  function metadataForNode(node: CanvasNodeState): Record<string, unknown> | undefined {
122
159
  switch (node.type) {
123
160
  case 'webpage': {
@@ -146,9 +183,13 @@ function metadataForNode(node: CanvasNodeState): Record<string, unknown> | undef
146
183
  }
147
184
  case 'mcp-app': {
148
185
  const metadata: Record<string, unknown> = {};
149
- for (const key of ['url', 'path', 'mode', 'hostMode', 'serverName', 'toolName', 'resourceUri', 'sessionStatus']) {
186
+ for (const key of ['url', 'path', 'mode', 'hostMode', 'viewerType', 'serverName', 'toolName', 'resourceUri', 'sessionStatus', 'projectPath', 'artifactBytes', 'sourceFiles', 'sourceFileCount', 'deps']) {
150
187
  const value = node.data[key];
151
- if (value !== undefined && value !== null && value !== '') metadata[key] = value;
188
+ if (Array.isArray(value)) {
189
+ if (value.length > 0) metadata[key] = value;
190
+ } else if (value !== undefined && value !== null && value !== '') {
191
+ metadata[key] = value;
192
+ }
152
193
  }
153
194
  return Object.keys(metadata).length > 0 ? metadata : undefined;
154
195
  }
@@ -170,6 +211,9 @@ export function summarizeNodeForAgentContext(
170
211
  return truncateContextText(content, defaultTextLength);
171
212
  }
172
213
  case 'mcp-app': {
214
+ if (node.data.viewerType === 'web-artifact') {
215
+ return summarizeWebArtifactData(node.data, defaultTextLength);
216
+ }
173
217
  const chartCfg = node.data.chartConfig as Record<string, unknown> | undefined;
174
218
  if (chartCfg) {
175
219
  const chartTitle = (chartCfg.title as string) || 'Untitled chart';
@@ -218,6 +262,7 @@ export function serializeNodeForAgentContext(
218
262
  return {
219
263
  id: node.id,
220
264
  type: node.type,
265
+ kind: getCanvasNodeKind(node),
221
266
  title: typeof node.data.title === 'string' ? node.data.title : null,
222
267
  content: summarizeNodeForAgentContext(node, options) || null,
223
268
  ...(metadata ? { metadata } : {}),
@@ -234,7 +279,9 @@ export function buildAgentContextPreamble(
234
279
  const title = (typeof node.data.title === 'string' && node.data.title) ? node.data.title : node.id;
235
280
  const content = summarizeNodeForAgentContext(node, options);
236
281
  if (!content) return '';
237
- return `[Context from "${title}" (${node.type})]\n${content}\n`;
282
+ const kind = getCanvasNodeKind(node);
283
+ const typeLabel = kind === node.type ? node.type : `${node.type}/${kind}`;
284
+ return `[Context from "${title}" (${typeLabel})]\n${content}\n`;
238
285
  })
239
286
  .filter((section) => section.length > 0);
240
287
 
@@ -43,6 +43,16 @@ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId
43
43
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
44
44
  export type CanvasPinMode = 'set' | 'add' | 'remove';
45
45
 
46
+ let canvasLayoutUpdateEmitter: (() => void) | null = null;
47
+
48
+ export function setCanvasLayoutUpdateEmitter(emitter: (() => void) | null): void {
49
+ canvasLayoutUpdateEmitter = emitter;
50
+ }
51
+
52
+ function emitCanvasLayoutUpdate(): void {
53
+ canvasLayoutUpdateEmitter?.();
54
+ }
55
+
46
56
  export interface CanvasFitViewOptions {
47
57
  width?: number;
48
58
  height?: number;
@@ -76,6 +86,12 @@ interface CanvasAddNodeInput {
76
86
  title?: string;
77
87
  content?: string;
78
88
  data?: Record<string, unknown>;
89
+ toolName?: string;
90
+ category?: string;
91
+ status?: string;
92
+ duration?: string;
93
+ resultSummary?: string;
94
+ error?: string;
79
95
  x?: number;
80
96
  y?: number;
81
97
  width?: number;
@@ -109,6 +125,7 @@ interface CanvasNodeLookupInput {
109
125
  }
110
126
 
111
127
  const MAX_CONTEXT_PINS = 20;
128
+ const TRACE_DATA_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
112
129
 
113
130
  function isRecord(value: unknown): value is Record<string, unknown> {
114
131
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -757,10 +774,23 @@ function buildWebpageNodeData(input: CanvasAddNodeInput): Record<string, unknown
757
774
  };
758
775
  }
759
776
 
777
+ function normalizeTraceNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
778
+ const data: Record<string, unknown> = { ...(input.data ?? {}) };
779
+ for (const field of TRACE_DATA_FIELDS) {
780
+ const value = input[field];
781
+ if (typeof value === 'string') data[field] = value;
782
+ }
783
+ if (input.title) data.title = input.title;
784
+ if (input.content) data.content = input.content;
785
+ if (input.strictSize) data.strictSize = true;
786
+ return data;
787
+ }
788
+
760
789
  function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
761
790
  if (input.type === 'file') return buildFileNodeData(input);
762
791
  if (input.type === 'image') return buildImageNodeData(input);
763
792
  if (input.type === 'webpage') return buildWebpageNodeData(input);
793
+ if (input.type === 'trace') return normalizeTraceNodeData(input);
764
794
  return {
765
795
  ...(input.data ?? {}),
766
796
  ...(input.title ? { title: input.title } : {}),
@@ -769,6 +799,21 @@ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
769
799
  };
770
800
  }
771
801
 
802
+ export function mergeTraceNodeDataFields(
803
+ base: Record<string, unknown>,
804
+ input: Record<string, unknown>,
805
+ ): Record<string, unknown> {
806
+ const next: Record<string, unknown> = { ...base };
807
+ for (const field of TRACE_DATA_FIELDS) {
808
+ if (typeof input[field] === 'string') next[field] = input[field];
809
+ }
810
+ return next;
811
+ }
812
+
813
+ export function hasTraceNodeDataFields(input: Record<string, unknown>): boolean {
814
+ return TRACE_DATA_FIELDS.some((field) => typeof input[field] === 'string');
815
+ }
816
+
772
817
  export function scheduleCodeGraphRecompute(onComplete?: () => void): void {
773
818
  if (codeGraphTimer) clearTimeout(codeGraphTimer);
774
819
  codeGraphTimer = setTimeout(() => {
@@ -1114,8 +1159,8 @@ export function setCanvasContextPins(
1114
1159
  };
1115
1160
  }
1116
1161
 
1117
- export function listCanvasSnapshots(): CanvasSnapshot[] {
1118
- return canvasState.listSnapshots();
1162
+ export function listCanvasSnapshots(options?: Parameters<typeof canvasState.listSnapshots>[0]): CanvasSnapshot[] {
1163
+ return canvasState.listSnapshots(options);
1119
1164
  }
1120
1165
 
1121
1166
  export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
@@ -1125,7 +1170,10 @@ export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
1125
1170
  export async function restoreCanvasSnapshot(idOrName: string): Promise<{ ok: boolean }> {
1126
1171
  const ok = canvasState.restoreSnapshot(idOrName);
1127
1172
  if (ok) {
1128
- await syncCanvasRuntimeBackends({ forceRehydrateExtApps: true });
1173
+ primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
1174
+ void syncCanvasRuntimeBackends({ forceRehydrateExtApps: true, alreadyPrimed: true }).finally(() => {
1175
+ emitCanvasLayoutUpdate();
1176
+ });
1129
1177
  canvasState.flushToDisk();
1130
1178
  }
1131
1179
  return { ok };
@@ -1135,6 +1183,10 @@ export function deleteCanvasSnapshot(id: string): { ok: boolean } {
1135
1183
  return { ok: canvasState.deleteSnapshot(id) };
1136
1184
  }
1137
1185
 
1186
+ export function gcCanvasSnapshots(options?: Parameters<typeof canvasState.gcSnapshots>[0]): ReturnType<typeof canvasState.gcSnapshots> {
1187
+ return canvasState.gcSnapshots(options);
1188
+ }
1189
+
1138
1190
  export function addCanvasEdge(input: {
1139
1191
  from?: string;
1140
1192
  to?: string;
@@ -14,6 +14,7 @@ export type CanvasNodeType =
14
14
  | 'trace'
15
15
  | 'file'
16
16
  | 'image'
17
+ | 'html'
17
18
  | 'group';
18
19
 
19
20
  export type CanvasNodeProvenanceSourceKind =
@@ -199,7 +200,7 @@ function inferMcpAppProvenance(data: Record<string, unknown>): CanvasNodeProvena
199
200
  if (resourceUri) details.resourceUri = resourceUri;
200
201
  const transportType = isRecord(data.transportConfig) ? pickString(data.transportConfig.type) : null;
201
202
  if (transportType) details.transportType = transportType;
202
- if (isRecord(data.toolInput)) details.toolInput = data.toolInput;
203
+ if (isRecord(data.toolInput) && data.toolInput.__pmxCanvasBlob !== 'v1') details.toolInput = data.toolInput;
203
204
 
204
205
  return {
205
206
  sourceKind: 'mcp-tool',