pmx-canvas 0.1.2 → 0.1.4

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 (42) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +69 -69
  4. package/dist/json-render/index.css +1 -1
  5. package/dist/json-render/index.js +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
  7. package/dist/types/client/state/canvas-store.d.ts +2 -1
  8. package/dist/types/client/types.d.ts +3 -0
  9. package/dist/types/json-render/charts/components.d.ts +2 -1
  10. package/dist/types/server/canvas-serialization.d.ts +1 -0
  11. package/dist/types/server/diagram-presets.d.ts +13 -0
  12. package/dist/types/server/ext-app-lookup.d.ts +22 -0
  13. package/dist/types/server/index.d.ts +8 -1
  14. package/dist/types/server/web-artifacts.d.ts +1 -0
  15. package/package.json +2 -1
  16. package/skills/pmx-canvas/SKILL.md +35 -10
  17. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  18. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  19. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  20. package/src/cli/agent.ts +114 -21
  21. package/src/cli/index.ts +3 -1
  22. package/src/client/App.tsx +2 -1
  23. package/src/client/canvas/CanvasNode.tsx +3 -2
  24. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  25. package/src/client/nodes/ExtAppFrame.tsx +97 -26
  26. package/src/client/state/canvas-store.ts +63 -1
  27. package/src/client/state/sse-bridge.ts +19 -4
  28. package/src/client/types.ts +12 -0
  29. package/src/json-render/charts/components.tsx +6 -4
  30. package/src/json-render/charts/extra-components.tsx +5 -5
  31. package/src/json-render/renderer/index.css +14 -0
  32. package/src/mcp/server.ts +44 -5
  33. package/src/server/canvas-operations.ts +43 -5
  34. package/src/server/canvas-schema.ts +16 -14
  35. package/src/server/canvas-serialization.ts +19 -1
  36. package/src/server/diagram-presets.ts +219 -4
  37. package/src/server/ext-app-lookup.ts +49 -0
  38. package/src/server/index.ts +33 -25
  39. package/src/server/server.ts +199 -45
  40. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  41. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  42. package/src/server/web-artifacts.ts +44 -1
@@ -39,12 +39,14 @@ import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } fro
39
39
  import { basename, extname, join, relative, resolve } from 'node:path';
40
40
  import * as marked from 'marked';
41
41
  import type {
42
+ CallToolResult,
42
43
  ListPromptsResult,
43
44
  ListResourcesResult,
44
45
  ListResourceTemplatesResult,
45
46
  ListToolsResult,
46
47
  } from '@modelcontextprotocol/sdk/types.js';
47
48
  import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
49
+ import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
48
50
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
49
51
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
50
52
  import {
@@ -92,7 +94,16 @@ import {
92
94
  } from './canvas-operations.js';
93
95
  import { validateCanvasLayout } from './canvas-validation.js';
94
96
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
95
- import { buildExcalidrawOpenMcpAppInput } from './diagram-presets.js';
97
+ import {
98
+ EXCALIDRAW_READ_CHECKPOINT_TOOL,
99
+ EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
100
+ buildExcalidrawCheckpointId,
101
+ buildExcalidrawOpenMcpAppInput,
102
+ buildExcalidrawRestoreCheckpointToolInput,
103
+ ensureExcalidrawCheckpointId,
104
+ getExcalidrawCheckpointIdFromToolResult,
105
+ isExcalidrawCreateView,
106
+ } from './diagram-presets.js';
96
107
  import { traceManager } from './trace-manager.js';
97
108
  import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
98
109
  import {
@@ -592,18 +603,102 @@ function getMarkdownPlacement(): { x: number; y: number } {
592
603
  }
593
604
 
594
605
  function findCanvasExtAppNodeId(toolCallId: string): string | null {
595
- const directId = `ext-app-${toolCallId}`;
596
- if (canvasState.getNode(directId)) return directId;
597
- for (const node of canvasState.getLayout().nodes) {
598
- if (
599
- node.type === 'mcp-app' &&
600
- node.data.mode === 'ext-app' &&
601
- node.data.toolCallId === toolCallId
602
- ) {
603
- return node.id;
604
- }
606
+ return findCanvasExtAppNodeIdShared(toolCallId, {
607
+ getNode: (id) => canvasState.getNode(id),
608
+ listNodes: () => canvasState.getLayout().nodes,
609
+ });
610
+ }
611
+
612
+ function isCheckpointToolName(toolName: string): boolean {
613
+ return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
614
+ }
615
+
616
+ /**
617
+ * Decide whether a fresh `callServerTool` result should *replace* the
618
+ * canvas node's bootstrap-replay `toolResult`.
619
+ *
620
+ * The bootstrap-replay toolResult is what the server re-sends to the
621
+ * widget on reload to restore visible state. We only want to overwrite
622
+ * it when the new result genuinely carries widget state — `isError` or
623
+ * `structuredContent`. A plain-text result (e.g. `read_checkpoint`
624
+ * returning a string status, or any informational message) updates
625
+ * `appModelContext` for the agent's record but should *not* clobber the
626
+ * bootstrap entry, because doing so would replace the widget's restored
627
+ * state with conversational noise on the next reload.
628
+ *
629
+ * This separation is exercised by:
630
+ * - tests/unit/server-api.test.ts "keeps ext-app model context
631
+ * separate from the replayed tool result" (text-only result preserves
632
+ * bootstrap replay)
633
+ * - tests/unit/server-api.test.ts "app-only text tool results update
634
+ * model context without replacing bootstrap replay"
635
+ * - tests/unit/server-api.test.ts "rehydrates Excalidraw checkpoint
636
+ * replay after server restart" (structured-content result becomes
637
+ * the new bootstrap replay)
638
+ */
639
+ function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
640
+ void toolName;
641
+ return Boolean(result.isError || result.structuredContent);
642
+ }
643
+
644
+ function isRecord(value: unknown): value is Record<string, unknown> {
645
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
646
+ }
647
+
648
+ function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
649
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
650
+ const storedCheckpointId = appCheckpoint?.id;
651
+ if (typeof storedCheckpointId === 'string' && storedCheckpointId.trim().length > 0) {
652
+ return storedCheckpointId.trim();
605
653
  }
606
- return null;
654
+ return getExcalidrawCheckpointIdFromToolResult(node.data.toolResult) ?? buildExcalidrawCheckpointId(node.id);
655
+ }
656
+
657
+ function getLocalExcalidrawCheckpointData(
658
+ node: CanvasNodeState,
659
+ args: Record<string, unknown> | undefined,
660
+ ): string | null {
661
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return null;
662
+ if (!isRecord(args) || typeof args.id !== 'string') return null;
663
+ if (args.id.trim() !== getExtAppNodeCheckpointId(node)) return null;
664
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
665
+ const data = appCheckpoint?.data;
666
+ return typeof data === 'string' ? data : '';
667
+ }
668
+
669
+ function persistExcalidrawCheckpointToNode(
670
+ nodeId: string,
671
+ node: CanvasNodeState,
672
+ args: Record<string, unknown> | undefined,
673
+ ): boolean {
674
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return false;
675
+ if (!isRecord(args) || typeof args.id !== 'string') return false;
676
+ const checkpointId = getExtAppNodeCheckpointId(node);
677
+ if (args.id.trim() !== checkpointId) return false;
678
+
679
+ const currentToolInput = isRecord(node.data.toolInput) ? node.data.toolInput : {};
680
+ const nextToolInput = {
681
+ ...currentToolInput,
682
+ elements: buildExcalidrawRestoreCheckpointToolInput(checkpointId, args.data),
683
+ };
684
+ const currentToolResult = isRecord(node.data.toolResult)
685
+ ? ensureExcalidrawCheckpointId(node.data.toolResult as CallToolResult, node.id, checkpointId)
686
+ : undefined;
687
+
688
+ canvasState.updateNode(nodeId, {
689
+ data: {
690
+ ...node.data,
691
+ toolInput: nextToolInput,
692
+ ...(currentToolResult ? { toolResult: currentToolResult } : {}),
693
+ appCheckpoint: {
694
+ toolName: EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
695
+ id: checkpointId,
696
+ ...(typeof args.data === 'string' ? { data: args.data } : {}),
697
+ updatedAt: new Date().toISOString(),
698
+ },
699
+ },
700
+ });
701
+ return true;
607
702
  }
608
703
 
609
704
  function findReusableCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
@@ -1323,7 +1418,13 @@ async function handleCanvasArrange(req: Request): Promise<Response> {
1323
1418
  }
1324
1419
  const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
1325
1420
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1326
- return responseJson({ ok: true, arranged: result.arranged, layout: result.layout });
1421
+ const validation = validateCanvasLayout(canvasState.getLayout());
1422
+ return responseJson({
1423
+ ok: validation.ok,
1424
+ arranged: result.arranged,
1425
+ layout: result.layout,
1426
+ ...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
1427
+ });
1327
1428
  }
1328
1429
 
1329
1430
  // ── Focus on node ───────────────────────────────────────────
@@ -1333,11 +1434,17 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1333
1434
  if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
1334
1435
  const node = canvasState.getNode(nodeId);
1335
1436
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
1336
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
1337
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId });
1338
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1437
+ const noPan = body.noPan === true;
1438
+ if (!noPan) {
1439
+ canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
1440
+ } else {
1441
+ const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
1442
+ canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
1443
+ }
1444
+ emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
1445
+ if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1339
1446
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1340
- return responseJson({ ok: true, focused: nodeId });
1447
+ return responseJson({ ok: true, focused: nodeId, panned: !noPan });
1341
1448
  }
1342
1449
 
1343
1450
  async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
@@ -1375,6 +1482,9 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1375
1482
  ...(typeof body.bundleScriptPath === 'string'
1376
1483
  ? { bundleScriptPath: body.bundleScriptPath }
1377
1484
  : {}),
1485
+ ...(Array.isArray(body.deps)
1486
+ ? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
1487
+ : {}),
1378
1488
  ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
1379
1489
  ...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
1380
1490
  });
@@ -1447,10 +1557,22 @@ async function handleCanvasValidateSpec(req: Request): Promise<Response> {
1447
1557
  data,
1448
1558
  ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
1449
1559
  ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
1560
+ ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
1450
1561
  ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
1451
1562
  ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
1563
+ ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
1564
+ ...(Array.isArray(body.metrics)
1565
+ ? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
1566
+ : {}),
1567
+ ...(Array.isArray(body.series)
1568
+ ? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
1569
+ : {}),
1570
+ ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
1571
+ ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
1452
1572
  ...(aggregate ? { aggregate } : {}),
1453
1573
  ...(typeof body.color === 'string' ? { color: body.color } : {}),
1574
+ ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
1575
+ ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
1454
1576
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
1455
1577
  },
1456
1578
  }));
@@ -1773,7 +1895,7 @@ function handleRead(pathLike: string): Response {
1773
1895
  }
1774
1896
 
1775
1897
  function randomExtAppToolCallId(): string {
1776
- return `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1898
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1777
1899
  }
1778
1900
 
1779
1901
  function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
@@ -1853,10 +1975,15 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1853
1975
  });
1854
1976
 
1855
1977
  const toolCallId = randomExtAppToolCallId();
1978
+ const nodeIdSeed = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
1979
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
1980
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
1981
+ : opened.toolResult;
1856
1982
  const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
1857
1983
 
1858
1984
  emitPrimaryWorkbenchEvent('ext-app-open', {
1859
1985
  toolCallId,
1986
+ nodeId: nodeIdSeed,
1860
1987
  title: nodeTitle,
1861
1988
  html: opened.html,
1862
1989
  toolInput: opened.toolInput,
@@ -1876,15 +2003,17 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1876
2003
  });
1877
2004
  emitPrimaryWorkbenchEvent('ext-app-result', {
1878
2005
  toolCallId,
2006
+ nodeId: nodeIdSeed,
1879
2007
  serverName: opened.serverName,
1880
2008
  toolName: opened.toolName,
1881
- success: opened.toolResult.isError !== true,
1882
- result: opened.toolResult,
2009
+ success: toolResult.isError !== true,
2010
+ result: toolResult,
1883
2011
  });
1884
2012
  const nodeId = findCanvasExtAppNodeId(toolCallId);
1885
2013
 
1886
2014
  return responseJson({
1887
2015
  ok: true,
2016
+ ...(nodeId ? { id: nodeId } : {}),
1888
2017
  nodeId,
1889
2018
  toolCallId,
1890
2019
  sessionId: opened.sessionId,
@@ -1979,35 +2108,53 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
1979
2108
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
1980
2109
 
1981
2110
  try {
1982
- const result = await callMcpAppTool(sessionId, toolName, args);
2111
+ const requestedNode = nodeId ? canvasState.getNode(nodeId) : undefined;
2112
+ const canReadLocalCheckpoint =
2113
+ requestedNode?.type === 'mcp-app' &&
2114
+ requestedNode.data.mode === 'ext-app' &&
2115
+ requestedNode.data.appSessionId === sessionId;
2116
+ const localCheckpointData = canReadLocalCheckpoint && toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL
2117
+ ? getLocalExcalidrawCheckpointData(requestedNode, args)
2118
+ : null;
2119
+ const result = localCheckpointData === null
2120
+ ? await callMcpAppTool(sessionId, toolName, args)
2121
+ : { content: [{ type: 'text', text: localCheckpointData }] } satisfies CallToolResult;
1983
2122
  if (nodeId) {
1984
2123
  const node = canvasState.getNode(nodeId);
1985
2124
  if (node?.type === 'mcp-app' && node.data.mode === 'ext-app' && node.data.appSessionId === sessionId) {
1986
- const nextData: Record<string, unknown> = {
1987
- ...node.data,
1988
- toolResult: result,
1989
- };
1990
- const nextModelContext: Record<string, unknown> = {};
1991
- if (Array.isArray(result.content)) {
1992
- nextModelContext.content = result.content;
1993
- }
1994
- if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
1995
- nextModelContext.structuredContent = result.structuredContent;
2125
+ let changed = false;
2126
+ if (toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL && persistExcalidrawCheckpointToNode(nodeId, node, args)) {
2127
+ // Checkpoint saves are replayed through toolInput.elements instead of
2128
+ // replacing the original create_view result with a generic "ok".
2129
+ changed = true;
2130
+ } else if (!(isExcalidrawCreateView(node.data.serverName, node.data.toolName) && isCheckpointToolName(toolName))) {
2131
+ const nextData: Record<string, unknown> = { ...node.data };
2132
+ if (shouldReplayAppToolResult(toolName, result)) nextData.toolResult = result;
2133
+ const nextModelContext: Record<string, unknown> = {};
2134
+ if (Array.isArray(result.content)) {
2135
+ nextModelContext.content = result.content;
2136
+ }
2137
+ if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
2138
+ nextModelContext.structuredContent = result.structuredContent;
2139
+ }
2140
+ if (Object.keys(nextModelContext).length > 0) {
2141
+ nextData.appModelContext = {
2142
+ ...nextModelContext,
2143
+ updatedAt: new Date().toISOString(),
2144
+ };
2145
+ }
2146
+ canvasState.updateNode(nodeId, {
2147
+ data: nextData,
2148
+ });
2149
+ changed = true;
1996
2150
  }
1997
- if (Object.keys(nextModelContext).length > 0) {
1998
- nextData.appModelContext = {
1999
- ...nextModelContext,
2000
- updatedAt: new Date().toISOString(),
2001
- };
2151
+ if (changed) {
2152
+ broadcastWorkbenchEvent('canvas-layout-update', {
2153
+ layout: canvasState.getLayout(),
2154
+ sessionId: primaryWorkbenchSessionId,
2155
+ timestamp: new Date().toISOString(),
2156
+ });
2002
2157
  }
2003
- canvasState.updateNode(nodeId, {
2004
- data: nextData,
2005
- });
2006
- broadcastWorkbenchEvent('canvas-layout-update', {
2007
- layout: canvasState.getLayout(),
2008
- sessionId: primaryWorkbenchSessionId,
2009
- timestamp: new Date().toISOString(),
2010
- });
2011
2158
  }
2012
2159
  }
2013
2160
  return responseJson({ ok: true, result });
@@ -3031,10 +3178,13 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3031
3178
  } else if (event === 'ext-app-open') {
3032
3179
  const toolCallId = payload.toolCallId as string;
3033
3180
  if (!toolCallId) return;
3034
- const id = `ext-app-${toolCallId}`;
3181
+ const id = typeof payload.nodeId === 'string' && payload.nodeId.length > 0
3182
+ ? payload.nodeId
3183
+ : toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
3035
3184
  const dataPatch = {
3036
3185
  mode: 'ext-app',
3037
3186
  toolCallId,
3187
+ nodeId: id,
3038
3188
  ...(typeof payload.title === 'string' && payload.title.trim().length > 0
3039
3189
  ? { title: payload.title.trim() }
3040
3190
  : {}),
@@ -3102,7 +3252,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3102
3252
  } else if (event === 'ext-app-update') {
3103
3253
  const toolCallId = payload.toolCallId as string;
3104
3254
  if (!toolCallId) return;
3255
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3105
3256
  const id =
3257
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3106
3258
  findCanvasExtAppNodeId(toolCallId) ||
3107
3259
  (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3108
3260
  ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
@@ -3115,7 +3267,9 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3115
3267
  } else if (event === 'ext-app-result') {
3116
3268
  const toolCallId = payload.toolCallId as string;
3117
3269
  if (!toolCallId) return;
3270
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3118
3271
  const id =
3272
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3119
3273
  findCanvasExtAppNodeId(toolCallId) ||
3120
3274
  (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3121
3275
  ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
@@ -152,10 +152,20 @@ rm -rf dist bundle.html
152
152
  echo "🔨 Building with Parcel..."
153
153
  run_with_filtered_stderr run_local_binary parcel build index.html --dist-dir dist --no-source-maps --log-level error
154
154
 
155
+ if [ ! -s "dist/index.html" ]; then
156
+ echo "❌ Error: Parcel did not produce dist/index.html" >&2
157
+ exit 1
158
+ fi
159
+
155
160
  # Inline everything into single HTML
156
161
  echo "🎯 Inlining all assets into single HTML file..."
157
162
  run_with_filtered_stderr run_local_binary html-inline dist/index.html > bundle.html
158
163
 
164
+ if [ ! -s "bundle.html" ]; then
165
+ echo "❌ Error: Bundled artifact is empty" >&2
166
+ exit 1
167
+ fi
168
+
159
169
  # Get file size
160
170
  FILE_SIZE=$(du -h bundle.html | cut -f1)
161
171
 
@@ -173,7 +173,7 @@ fi
173
173
 
174
174
  echo "📦 Installing Tailwind CSS and dependencies..."
175
175
  run_pnpm_allow_build add -D tailwindcss@3.4.1 postcss @types/node tailwindcss-animate parcel @parcel/config-default parcel-resolver-tspaths html-inline
176
- run_pnpm_quiet add class-variance-authority clsx tailwind-merge lucide-react next-themes
176
+ run_pnpm_quiet add class-variance-authority clsx tailwind-merge lucide-react next-themes recharts
177
177
 
178
178
  echo "⚙️ Creating Tailwind and PostCSS configuration..."
179
179
  cat > .postcssrc.json << 'EOF'
@@ -38,6 +38,7 @@ export interface WebArtifactBuildInput {
38
38
  outputPath?: string;
39
39
  initScriptPath?: string;
40
40
  bundleScriptPath?: string;
41
+ deps?: string[];
41
42
  timeoutMs?: number;
42
43
  }
43
44
 
@@ -322,6 +323,19 @@ function ensurePackageManagerBoundary(dirPath: string): void {
322
323
  writeFileSync(packageJsonPath, JSON.stringify(nextPackageJson, null, 2), 'utf-8');
323
324
  }
324
325
 
326
+ function normalizeDependencyNames(deps: string[] | undefined): string[] {
327
+ const normalized = new Set<string>();
328
+ for (const dep of deps ?? []) {
329
+ const trimmed = dep.trim();
330
+ if (!trimmed) continue;
331
+ if (trimmed.startsWith('-') || !/^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[\w.+~^*-][\w.+~^*-]*)?$/i.test(trimmed)) {
332
+ throw new Error(`Invalid web-artifact dependency name: ${dep}`);
333
+ }
334
+ normalized.add(trimmed);
335
+ }
336
+ return [...normalized];
337
+ }
338
+
325
339
  function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
326
340
  if (!text.trim()) return undefined;
327
341
 
@@ -391,6 +405,23 @@ export async function executeWebArtifactBuild(
391
405
 
392
406
  writeProjectFiles(projectPath, input);
393
407
 
408
+ const deps = normalizeDependencyNames(input.deps);
409
+ if (deps.length > 0) {
410
+ const quotedDeps = deps.map((dep) => `'${dep.replaceAll("'", "'\\''")}'`).join(' ');
411
+ const pnpmVersion = DEFAULT_PACKAGE_MANAGER.split('@')[1] ?? '10.33.0';
412
+ // Use `bash -c` (not `-lc`): `-lc` sources the user's login profile
413
+ // (~/.bashrc, ~/.zshrc), which can mutate PATH or set aliases that
414
+ // change install behavior. With deps already validated against
415
+ // npm-name format and quoted via single-quote escaping, the regular
416
+ // shell is sufficient and reproducible across machines.
417
+ const depResult = await runProcess('bash', ['-c', `if command -v pnpm >/dev/null 2>&1; then pnpm --silent add --ignore-scripts -- ${quotedDeps}; elif command -v bun >/dev/null 2>&1; then bun x pnpm@${pnpmVersion} --silent add --ignore-scripts -- ${quotedDeps}; else npm install --ignore-scripts -- ${quotedDeps}; fi`], {
418
+ cwd: projectPath,
419
+ timeoutMs,
420
+ });
421
+ stdout = [stdout, depResult.stdout].filter(Boolean).join('\n');
422
+ stderr = [stderr, depResult.stderr].filter(Boolean).join('\n');
423
+ }
424
+
394
425
  const bundleResult = await runProcess('bash', [bundleScriptPath], {
395
426
  cwd: projectPath,
396
427
  timeoutMs,
@@ -403,18 +434,29 @@ export async function executeWebArtifactBuild(
403
434
  throw new Error(`Expected bundled artifact at ${bundlePath}`);
404
435
  }
405
436
 
437
+ const bundleSize = statSync(bundlePath).size;
438
+ if (bundleSize <= 0) {
439
+ throw new Error(`Bundled artifact is empty: ${bundlePath}`);
440
+ }
441
+
406
442
  mkdirSync(dirname(outputPath), { recursive: true });
407
443
  copyFileSync(bundlePath, outputPath);
444
+ // The script-side check in bundle-artifact.sh and the bundleSize check
445
+ // above already guarantee a non-empty source; copyFileSync would throw
446
+ // on a filesystem failure. A post-copy size check would be redundant
447
+ // defensive noise — see CLAUDE.md TypeScript Guardrail #3.
448
+ const fileSize = bundleSize;
408
449
 
409
450
  return {
410
451
  filePath: outputPath,
411
- fileSize: statSync(outputPath).size,
452
+ fileSize,
412
453
  projectPath,
413
454
  metadata: {
414
455
  title: input.title,
415
456
  bundlePath,
416
457
  projectPath,
417
458
  hasIndexCss: typeof input.indexCss === 'string',
459
+ ...(deps.length > 0 ? { deps } : {}),
418
460
  extraFileCount: Object.keys(input.files ?? {}).length,
419
461
  outputPreview: readFileSync(outputPath, 'utf-8').slice(0, 200),
420
462
  },
@@ -452,6 +494,7 @@ export function openWebArtifactInCanvas(input: {
452
494
  trustedDomain: true,
453
495
  sourceServer: 'pmx-canvas',
454
496
  hostMode: 'hosted',
497
+ viewerType: 'web-artifact',
455
498
  },
456
499
  };
457
500