pmx-canvas 0.1.14 → 0.1.16

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 (56) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +124 -74
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ContextNode.d.ts +11 -2
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/StatusNode.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +11 -3
  10. package/dist/types/client/state/intent-bridge.d.ts +5 -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 +4 -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/index.d.ts +13 -3
  21. package/dist/types/server/web-artifacts.d.ts +18 -0
  22. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/skills/pmx-canvas/SKILL.md +43 -0
  25. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  26. package/src/cli/agent.ts +52 -5
  27. package/src/cli/index.ts +2 -23
  28. package/src/client/canvas/AttentionHistory.tsx +14 -1
  29. package/src/client/canvas/CanvasNode.tsx +1 -1
  30. package/src/client/canvas/CanvasViewport.tsx +3 -0
  31. package/src/client/canvas/ContextPinBar.tsx +2 -1
  32. package/src/client/canvas/DockedNode.tsx +112 -13
  33. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
  34. package/src/client/canvas/Minimap.tsx +1 -0
  35. package/src/client/icons.tsx +1 -0
  36. package/src/client/nodes/ContextNode.tsx +128 -6
  37. package/src/client/nodes/HtmlNode.tsx +151 -0
  38. package/src/client/nodes/StatusNode.tsx +16 -1
  39. package/src/client/nodes/StatusSummary.tsx +2 -1
  40. package/src/client/state/canvas-store.ts +37 -7
  41. package/src/client/state/intent-bridge.ts +9 -4
  42. package/src/client/state/sse-bridge.ts +2 -1
  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 +178 -25
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +20 -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/index.ts +33 -3
  54. package/src/server/server.ts +98 -14
  55. package/src/server/web-artifacts.ts +116 -3
  56. 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)'),
@@ -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',
@@ -4,6 +4,7 @@ import {
4
4
  normalizeCanvasNodeData,
5
5
  type CanvasNodeProvenance,
6
6
  } from './canvas-provenance.js';
7
+ import { getCanvasNodeKind as getSharedCanvasNodeKind } from '../shared/canvas-node-kind.js';
7
8
 
8
9
  export interface SerializedCanvasNode extends CanvasNodeState {
9
10
  kind: string;
@@ -18,6 +19,14 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
18
19
  nodes: SerializedCanvasNode[];
19
20
  }
20
21
 
22
+ interface BlobSummary {
23
+ stored: 'sidecar';
24
+ path: string;
25
+ bytes: number;
26
+ jsonBytes: number;
27
+ sha256: string;
28
+ }
29
+
21
30
  function pickString(value: unknown): string | null {
22
31
  return typeof value === 'string' && value.length > 0 ? value : null;
23
32
  }
@@ -27,19 +36,8 @@ function pickProvenance(value: unknown): CanvasNodeProvenance | null {
27
36
  return value as CanvasNodeProvenance;
28
37
  }
29
38
 
30
- function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string {
31
- if (node.type !== 'mcp-app') return node.type;
32
- // Authoritative discriminator added in v0.1.4. New web-artifacts always set
33
- // it; matching here first means a future URL-only artifact (no `data.path`)
34
- // still classifies correctly without falling through to the legacy heuristic.
35
- if (data.viewerType === 'web-artifact') return 'web-artifact';
36
- if (data.mode === 'ext-app') return 'external-app';
37
- // Transitional fallback for canvas state.json files persisted before v0.1.4
38
- // introduced `viewerType`. Web-artifacts written by older versions always
39
- // stored a `path` to the bundled HTML file, so this heuristic is safe for
40
- // existing data. Remove in v0.2.x once a one-shot migration runs at boot.
41
- if (data.hostMode === 'hosted' && typeof data.path === 'string') return 'web-artifact';
42
- return 'mcp-app';
39
+ export function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string {
40
+ return getSharedCanvasNodeKind({ type: node.type, data });
43
41
  }
44
42
 
45
43
  export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
@@ -72,6 +70,26 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
72
70
  };
73
71
  }
74
72
 
73
+ function summarizeBlobValue(value: unknown): unknown {
74
+ if (!canvasState.isBlobReference(value)) return value;
75
+ return {
76
+ stored: 'sidecar',
77
+ path: value.path,
78
+ bytes: value.bytes,
79
+ jsonBytes: value.jsonBytes,
80
+ sha256: value.sha256,
81
+ } satisfies BlobSummary;
82
+ }
83
+
84
+ export function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode {
85
+ const serialized = serializeCanvasNode(node);
86
+ if (serialized.type !== 'mcp-app') return serialized;
87
+ const data = Object.fromEntries(
88
+ Object.entries(serialized.data).map(([key, value]) => [key, summarizeBlobValue(value)]),
89
+ );
90
+ return { ...serialized, data };
91
+ }
92
+
75
93
  export function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout {
76
94
  return {
77
95
  ...layout,
@@ -79,6 +97,13 @@ export function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLay
79
97
  };
80
98
  }
81
99
 
100
+ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout {
101
+ return {
102
+ ...layout,
103
+ nodes: layout.nodes.map(serializeCanvasNodeWithBlobSummaries),
104
+ };
105
+ }
106
+
82
107
  export interface CanvasSummary {
83
108
  totalNodes: number;
84
109
  totalEdges: number;