pmx-canvas 0.1.14 → 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 (50) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +129 -79
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  7. package/dist/types/client/state/canvas-store.d.ts +5 -1
  8. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  9. package/dist/types/client/types.d.ts +2 -2
  10. package/dist/types/json-render/catalog.d.ts +1 -1
  11. package/dist/types/mcp/canvas-access.d.ts +7 -1
  12. package/dist/types/server/agent-context.d.ts +1 -0
  13. package/dist/types/server/canvas-operations.d.ts +4 -2
  14. package/dist/types/server/canvas-provenance.d.ts +1 -1
  15. package/dist/types/server/canvas-serialization.d.ts +3 -0
  16. package/dist/types/server/canvas-state.d.ts +51 -4
  17. package/dist/types/server/demo.d.ts +5 -0
  18. package/dist/types/server/index.d.ts +13 -3
  19. package/dist/types/server/web-artifacts.d.ts +18 -0
  20. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  21. package/package.json +1 -1
  22. package/skills/pmx-canvas/SKILL.md +43 -0
  23. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  24. package/src/cli/agent.ts +52 -5
  25. package/src/cli/index.ts +2 -23
  26. package/src/client/canvas/AttentionHistory.tsx +14 -1
  27. package/src/client/canvas/CanvasNode.tsx +1 -1
  28. package/src/client/canvas/CanvasViewport.tsx +3 -0
  29. package/src/client/canvas/DockedNode.tsx +110 -12
  30. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
  31. package/src/client/canvas/Minimap.tsx +1 -0
  32. package/src/client/icons.tsx +1 -0
  33. package/src/client/nodes/HtmlNode.tsx +151 -0
  34. package/src/client/state/canvas-store.ts +24 -2
  35. package/src/client/state/intent-bridge.ts +4 -3
  36. package/src/client/state/sse-bridge.ts +1 -0
  37. package/src/client/theme/global.css +141 -0
  38. package/src/client/types.ts +3 -0
  39. package/src/mcp/canvas-access.ts +34 -7
  40. package/src/mcp/server.ts +178 -25
  41. package/src/server/agent-context.ts +50 -3
  42. package/src/server/canvas-operations.ts +20 -3
  43. package/src/server/canvas-provenance.ts +2 -1
  44. package/src/server/canvas-serialization.ts +38 -13
  45. package/src/server/canvas-state.ts +305 -34
  46. package/src/server/demo.ts +792 -0
  47. package/src/server/index.ts +33 -3
  48. package/src/server/server.ts +74 -13
  49. package/src/server/web-artifacts.ts +116 -3
  50. package/src/shared/canvas-node-kind.ts +14 -0
@@ -18,6 +18,7 @@ type OpenMcpAppResult = Awaited<ReturnType<PmxCanvas['openMcpApp']>>;
18
18
  type AddDiagramInput = Parameters<PmxCanvas['addDiagram']>[0];
19
19
  type AddJsonRenderNodeInput = Parameters<PmxCanvas['addJsonRenderNode']>[0];
20
20
  type AddJsonRenderNodeResult = ReturnType<PmxCanvas['addJsonRenderNode']>;
21
+ type AddHtmlNodeInput = Parameters<PmxCanvas['addHtmlNode']>[0];
21
22
  type AddGraphNodeInput = Parameters<PmxCanvas['addGraphNode']>[0];
22
23
  type AddGraphNodeResult = ReturnType<PmxCanvas['addGraphNode']>;
23
24
  type UpdateNodePatch = Parameters<PmxCanvas['updateNode']>[1];
@@ -34,8 +35,11 @@ type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
34
35
  type SetContextPinsResult = ReturnType<PmxCanvas['setContextPins']>;
35
36
  type RunBatchInput = Parameters<PmxCanvas['runBatch']>[0];
36
37
  type RunBatchResult = Awaited<ReturnType<PmxCanvas['runBatch']>>;
38
+ type SnapshotListOptions = Parameters<PmxCanvas['listSnapshots']>[0];
37
39
  type SnapshotList = ReturnType<PmxCanvas['listSnapshots']>;
38
40
  type DeleteSnapshotResult = ReturnType<PmxCanvas['deleteSnapshot']>;
41
+ type GcSnapshotsOptions = Parameters<PmxCanvas['gcSnapshots']>[0];
42
+ type GcSnapshotsResult = ReturnType<PmxCanvas['gcSnapshots']>;
39
43
  type DiffSnapshotResult = ReturnType<PmxCanvas['diffSnapshot']>;
40
44
  type CodeGraphResult = ReturnType<PmxCanvas['getCodeGraph']>;
41
45
  type ValidationResult = ReturnType<PmxCanvas['validate']>;
@@ -97,6 +101,7 @@ export interface CanvasAccess {
97
101
  openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult>;
98
102
  addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult>;
99
103
  addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult>;
104
+ addHtmlNode(input: AddHtmlNodeInput): Promise<string>;
100
105
  addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult>;
101
106
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
102
107
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
@@ -117,10 +122,11 @@ export interface CanvasAccess {
117
122
  setContextPins(nodeIds: string[], mode?: 'set' | 'add' | 'remove'): Promise<SetContextPinsResult>;
118
123
  getPinnedNodeIds(): Promise<string[]>;
119
124
  runBatch(operations: RunBatchInput): Promise<RunBatchResult>;
120
- listSnapshots(): Promise<SnapshotList>;
125
+ listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList>;
121
126
  saveSnapshot(name: string): Promise<CanvasSnapshot | null>;
122
127
  restoreSnapshot(id: string): Promise<{ ok: boolean }>;
123
128
  deleteSnapshot(id: string): Promise<DeleteSnapshotResult>;
129
+ gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult>;
124
130
  diffSnapshot(idOrName: string): Promise<DiffSnapshotResult>;
125
131
  getCodeGraph(): Promise<CodeGraphResult>;
126
132
  validate(): Promise<ValidationResult>;
@@ -177,6 +183,10 @@ class LocalCanvasAccess implements CanvasAccess {
177
183
  return this.canvas.addJsonRenderNode(input);
178
184
  }
179
185
 
186
+ async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
187
+ return this.canvas.addHtmlNode(input);
188
+ }
189
+
180
190
  async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
181
191
  return this.canvas.addGraphNode(input);
182
192
  }
@@ -257,8 +267,8 @@ class LocalCanvasAccess implements CanvasAccess {
257
267
  return await this.canvas.runBatch(operations);
258
268
  }
259
269
 
260
- async listSnapshots(): Promise<SnapshotList> {
261
- return this.canvas.listSnapshots();
270
+ async listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList> {
271
+ return this.canvas.listSnapshots(options);
262
272
  }
263
273
 
264
274
  async saveSnapshot(name: string): Promise<CanvasSnapshot | null> {
@@ -273,6 +283,10 @@ class LocalCanvasAccess implements CanvasAccess {
273
283
  return this.canvas.deleteSnapshot(id);
274
284
  }
275
285
 
286
+ async gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult> {
287
+ return this.canvas.gcSnapshots(options);
288
+ }
289
+
276
290
  async diffSnapshot(idOrName: string): Promise<DiffSnapshotResult> {
277
291
  return this.canvas.diffSnapshot(idOrName);
278
292
  }
@@ -359,11 +373,11 @@ class RemoteCanvasAccess implements CanvasAccess {
359
373
  }
360
374
 
361
375
  async getLayout(): Promise<CanvasLayout> {
362
- return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state');
376
+ return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state?includeBlobs=true');
363
377
  }
364
378
 
365
379
  async getNode(id: string): Promise<CanvasNodeState | undefined> {
366
- const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}`);
380
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}?includeBlobs=true`);
367
381
  if (response.status === 404) return undefined;
368
382
  const text = await response.text();
369
383
  let parsed: unknown = undefined;
@@ -415,6 +429,10 @@ class RemoteCanvasAccess implements CanvasAccess {
415
429
  return { id, url: response.url, spec: response.spec };
416
430
  }
417
431
 
432
+ async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
433
+ return await this.requestNodeId('POST', '/api/canvas/node', { type: 'html', ...input });
434
+ }
435
+
418
436
  async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
419
437
  const response = await this.requestJson<GraphNodeResponse>('POST', '/api/canvas/graph', {
420
438
  ...input,
@@ -527,8 +545,13 @@ class RemoteCanvasAccess implements CanvasAccess {
527
545
  return await this.requestJson<RunBatchResult>('POST', '/api/canvas/batch', { operations });
528
546
  }
529
547
 
530
- async listSnapshots(): Promise<SnapshotList> {
531
- return await this.requestJson<SnapshotList>('GET', '/api/canvas/snapshots');
548
+ async listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList> {
549
+ const params = new URLSearchParams();
550
+ if (typeof options?.limit === 'number') params.set('limit', String(options.limit));
551
+ if (options?.query) params.set('q', options.query);
552
+ if (options?.all) params.set('all', 'true');
553
+ const query = params.size > 0 ? `?${params.toString()}` : '';
554
+ return await this.requestJson<SnapshotList>('GET', `/api/canvas/snapshots${query}`);
532
555
  }
533
556
 
534
557
  async saveSnapshot(name: string): Promise<CanvasSnapshot | null> {
@@ -544,6 +567,10 @@ class RemoteCanvasAccess implements CanvasAccess {
544
567
  return await this.requestJson<DeleteSnapshotResult>('DELETE', `/api/canvas/snapshots/${encodeURIComponent(id)}`);
545
568
  }
546
569
 
570
+ async gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult> {
571
+ return await this.requestJson<GcSnapshotsResult>('POST', '/api/canvas/snapshots/gc', options ?? {});
572
+ }
573
+
547
574
  async diffSnapshot(idOrName: string): Promise<DiffSnapshotResult> {
548
575
  return await this.requestJson<DiffSnapshotResult>('GET', `/api/canvas/snapshots/${encodeURIComponent(idOrName)}/diff`);
549
576
  }
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)'),
@@ -267,6 +342,8 @@ export async function startMcpServer(): Promise<void> {
267
342
  duration: z.string().optional().describe('Trace node duration badge text'),
268
343
  resultSummary: z.string().optional().describe('Trace node result summary'),
269
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.'),
270
347
  },
271
348
  async (input) => {
272
349
  const c = await ensureCanvas();
@@ -297,7 +374,39 @@ export async function startMcpServer(): Promise<void> {
297
374
  : input;
298
375
  const id = await c.addNode(nodeInput);
299
376
  return {
300
- 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) }],
301
410
  };
302
411
  },
303
412
  );
@@ -554,7 +663,10 @@ export async function startMcpServer(): Promise<void> {
554
663
  bytes: result.fileSize,
555
664
  projectPath: result.projectPath,
556
665
  openedInCanvas: result.openedInCanvas,
666
+ startedAt: result.startedAt,
557
667
  completedAt: result.completedAt,
668
+ durationMs: result.durationMs,
669
+ timeoutMs: result.timeoutMs,
558
670
  // `id` only present when a canvas node was actually created.
559
671
  // See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
560
672
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
@@ -720,10 +832,19 @@ export async function startMcpServer(): Promise<void> {
720
832
  xKey: z.string().optional().describe('Graph x/category key'),
721
833
  yKey: z.string().optional().describe('Graph y/value key'),
722
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'),
723
841
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
724
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.'),
725
845
  },
726
- 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;
727
848
  const c = await ensureCanvas();
728
849
  const node = await c.getNode(id);
729
850
  if (!node) {
@@ -750,13 +871,19 @@ export async function startMcpServer(): Promise<void> {
750
871
  if (xKey !== undefined) patch.xKey = xKey;
751
872
  if (yKey !== undefined) patch.yKey = yKey;
752
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;
753
880
  if (arrangeLocked !== undefined) {
754
881
  patch.arrangeLocked = arrangeLocked;
755
882
  }
756
883
  await c.updateNode(id, patch);
757
884
  const updated = await c.getNode(id);
758
885
  return {
759
- 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) }],
760
887
  };
761
888
  },
762
889
  );
@@ -1463,12 +1590,14 @@ export async function startMcpServer(): Promise<void> {
1463
1590
  width: z.number().optional().describe('Width (auto-computed from children if omitted)'),
1464
1591
  height: z.number().optional().describe('Height (auto-computed from children if omitted)'),
1465
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.'),
1466
1595
  },
1467
1596
  async (input) => {
1468
1597
  const c = await ensureCanvas();
1469
1598
  const id = await c.createGroup(input);
1470
1599
  return {
1471
- 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) }],
1472
1601
  };
1473
1602
  },
1474
1603
  );
@@ -1501,12 +1630,15 @@ export async function startMcpServer(): Promise<void> {
1501
1630
  assign: z.string().optional().describe('Optional reference name for later operations'),
1502
1631
  args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
1503
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.'),
1504
1635
  },
1505
- async ({ operations }) => {
1636
+ async (input) => {
1506
1637
  const c = await ensureCanvas();
1507
- const result = await c.runBatch(operations);
1638
+ const result = await c.runBatch(input.operations);
1639
+ const payload = wantsFullPayload(input) ? result : compactBatchResult(result);
1508
1640
  return {
1509
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1641
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1510
1642
  ...(result.ok ? {} : { isError: true }),
1511
1643
  };
1512
1644
  },
@@ -1586,12 +1718,33 @@ export async function startMcpServer(): Promise<void> {
1586
1718
  // ── canvas_list_snapshots ───────────────────────────────────
1587
1719
  server.tool(
1588
1720
  'canvas_list_snapshots',
1589
- 'List all saved canvas snapshots with IDs, names, timestamps, and node/edge counts.',
1590
- {},
1591
- 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) => {
1592
1728
  const c = await ensureCanvas();
1593
1729
  return {
1594
- content: [{ type: 'text', text: JSON.stringify({ snapshots: await c.listSnapshots() }, null, 2) }],
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) => {
1744
+ const c = await ensureCanvas();
1745
+ const result = await c.gcSnapshots(input);
1746
+ return {
1747
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1595
1748
  };
1596
1749
  },
1597
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;
@@ -1149,8 +1159,8 @@ export function setCanvasContextPins(
1149
1159
  };
1150
1160
  }
1151
1161
 
1152
- export function listCanvasSnapshots(): CanvasSnapshot[] {
1153
- return canvasState.listSnapshots();
1162
+ export function listCanvasSnapshots(options?: Parameters<typeof canvasState.listSnapshots>[0]): CanvasSnapshot[] {
1163
+ return canvasState.listSnapshots(options);
1154
1164
  }
1155
1165
 
1156
1166
  export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
@@ -1160,7 +1170,10 @@ export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
1160
1170
  export async function restoreCanvasSnapshot(idOrName: string): Promise<{ ok: boolean }> {
1161
1171
  const ok = canvasState.restoreSnapshot(idOrName);
1162
1172
  if (ok) {
1163
- await syncCanvasRuntimeBackends({ forceRehydrateExtApps: true });
1173
+ primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
1174
+ void syncCanvasRuntimeBackends({ forceRehydrateExtApps: true, alreadyPrimed: true }).finally(() => {
1175
+ emitCanvasLayoutUpdate();
1176
+ });
1164
1177
  canvasState.flushToDisk();
1165
1178
  }
1166
1179
  return { ok };
@@ -1170,6 +1183,10 @@ export function deleteCanvasSnapshot(id: string): { ok: boolean } {
1170
1183
  return { ok: canvasState.deleteSnapshot(id) };
1171
1184
  }
1172
1185
 
1186
+ export function gcCanvasSnapshots(options?: Parameters<typeof canvasState.gcSnapshots>[0]): ReturnType<typeof canvasState.gcSnapshots> {
1187
+ return canvasState.gcSnapshots(options);
1188
+ }
1189
+
1173
1190
  export function addCanvasEdge(input: {
1174
1191
  from?: string;
1175
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',