pmx-canvas 0.1.1 → 0.1.3

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 (36) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +70 -70
  4. package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -1
  5. package/dist/types/client/state/canvas-store.d.ts +2 -1
  6. package/dist/types/client/types.d.ts +3 -0
  7. package/dist/types/server/bundled-skills.d.ts +40 -0
  8. package/dist/types/server/diagram-presets.d.ts +13 -0
  9. package/dist/types/server/index.d.ts +6 -1
  10. package/dist/types/server/web-artifacts.d.ts +1 -0
  11. package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
  12. package/package.json +2 -1
  13. package/skills/pmx-canvas/SKILL.md +26 -5
  14. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  15. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  16. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  17. package/src/cli/agent.ts +78 -7
  18. package/src/cli/index.ts +22 -2
  19. package/src/client/App.tsx +2 -1
  20. package/src/client/canvas/CanvasNode.tsx +3 -2
  21. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  22. package/src/client/nodes/ExtAppFrame.tsx +183 -38
  23. package/src/client/state/canvas-store.ts +63 -1
  24. package/src/client/state/sse-bridge.ts +5 -0
  25. package/src/client/types.ts +12 -0
  26. package/src/mcp/server.ts +92 -6
  27. package/src/server/bundled-skills.ts +143 -0
  28. package/src/server/canvas-operations.ts +57 -8
  29. package/src/server/canvas-schema.ts +2 -1
  30. package/src/server/diagram-presets.ts +219 -4
  31. package/src/server/index.ts +22 -10
  32. package/src/server/server.ts +172 -45
  33. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  34. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  35. package/src/server/web-artifacts.ts +83 -3
  36. package/src/shared/ext-app-tool-result.ts +25 -0
@@ -44,6 +44,8 @@ import {
44
44
  } from './mcp-app-runtime.js';
45
45
  import {
46
46
  buildExcalidrawOpenMcpAppInput,
47
+ ensureExcalidrawCheckpointId,
48
+ isExcalidrawCreateView,
47
49
  type DiagramPresetOpenInput,
48
50
  } from './diagram-presets.js';
49
51
  import {
@@ -323,16 +325,22 @@ export class PmxCanvas extends EventEmitter {
323
325
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
324
326
  }
325
327
 
326
- focusNode(id: string): void {
328
+ focusNode(id: string, options?: { noPan?: boolean }): { focused: string; panned: boolean } | null {
327
329
  const node = canvasState.getNode(id);
328
- if (!node) return;
329
- canvasState.setViewport({
330
- x: node.position.x - 100,
331
- y: node.position.y - 100,
332
- });
333
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id });
334
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
330
+ if (!node) return null;
331
+ const noPan = options?.noPan === true;
332
+ if (!noPan) {
333
+ canvasState.setViewport({
334
+ x: node.position.x - 100,
335
+ y: node.position.y - 100,
336
+ });
337
+ }
338
+ emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
339
+ if (!noPan) {
340
+ emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
341
+ }
335
342
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
343
+ return { focused: id, panned: !noPan };
336
344
  }
337
345
 
338
346
  getLayout(): CanvasLayout {
@@ -488,6 +496,10 @@ export class PmxCanvas extends EventEmitter {
488
496
  ...(input.serverName ? { serverName: input.serverName } : {}),
489
497
  });
490
498
  const toolCallId = `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
499
+ const nodeIdSeed = `ext-app-${toolCallId}`;
500
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
501
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
502
+ : opened.toolResult;
491
503
  emitPrimaryWorkbenchEvent('ext-app-open', {
492
504
  toolCallId,
493
505
  title: input.title ?? opened.tool.title ?? opened.tool.name,
@@ -511,8 +523,8 @@ export class PmxCanvas extends EventEmitter {
511
523
  toolCallId,
512
524
  serverName: opened.serverName,
513
525
  toolName: opened.toolName,
514
- success: opened.toolResult.isError !== true,
515
- result: opened.toolResult,
526
+ success: toolResult.isError !== true,
527
+ result: toolResult,
516
528
  });
517
529
  const nodeId = this.findCanvasExtAppNodeId(toolCallId);
518
530
  return {
@@ -39,6 +39,7 @@ 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,
@@ -92,7 +93,16 @@ import {
92
93
  } from './canvas-operations.js';
93
94
  import { validateCanvasLayout } from './canvas-validation.js';
94
95
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
95
- import { buildExcalidrawOpenMcpAppInput } from './diagram-presets.js';
96
+ import {
97
+ EXCALIDRAW_READ_CHECKPOINT_TOOL,
98
+ EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
99
+ buildExcalidrawCheckpointId,
100
+ buildExcalidrawOpenMcpAppInput,
101
+ buildExcalidrawRestoreCheckpointToolInput,
102
+ ensureExcalidrawCheckpointId,
103
+ getExcalidrawCheckpointIdFromToolResult,
104
+ isExcalidrawCreateView,
105
+ } from './diagram-presets.js';
96
106
  import { traceManager } from './trace-manager.js';
97
107
  import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
98
108
  import {
@@ -606,6 +616,79 @@ function findCanvasExtAppNodeId(toolCallId: string): string | null {
606
616
  return null;
607
617
  }
608
618
 
619
+ function isCheckpointToolName(toolName: string): boolean {
620
+ return toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL || toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL;
621
+ }
622
+
623
+ function shouldReplayAppToolResult(toolName: string, result: CallToolResult): boolean {
624
+ void toolName;
625
+ return Boolean(
626
+ result.isError ||
627
+ result.structuredContent ||
628
+ result.content.some((entry) => entry.type !== 'text' || entry.text !== 'ok'),
629
+ );
630
+ }
631
+
632
+ function isRecord(value: unknown): value is Record<string, unknown> {
633
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
634
+ }
635
+
636
+ function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
637
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
638
+ const storedCheckpointId = appCheckpoint?.id;
639
+ if (typeof storedCheckpointId === 'string' && storedCheckpointId.trim().length > 0) {
640
+ return storedCheckpointId.trim();
641
+ }
642
+ return getExcalidrawCheckpointIdFromToolResult(node.data.toolResult) ?? buildExcalidrawCheckpointId(node.id);
643
+ }
644
+
645
+ function getLocalExcalidrawCheckpointData(
646
+ node: CanvasNodeState,
647
+ args: Record<string, unknown> | undefined,
648
+ ): string | null {
649
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return null;
650
+ if (!isRecord(args) || typeof args.id !== 'string') return null;
651
+ if (args.id.trim() !== getExtAppNodeCheckpointId(node)) return null;
652
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
653
+ const data = appCheckpoint?.data;
654
+ return typeof data === 'string' ? data : '';
655
+ }
656
+
657
+ function persistExcalidrawCheckpointToNode(
658
+ nodeId: string,
659
+ node: CanvasNodeState,
660
+ args: Record<string, unknown> | undefined,
661
+ ): boolean {
662
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return false;
663
+ if (!isRecord(args) || typeof args.id !== 'string') return false;
664
+ const checkpointId = getExtAppNodeCheckpointId(node);
665
+ if (args.id.trim() !== checkpointId) return false;
666
+
667
+ const currentToolInput = isRecord(node.data.toolInput) ? node.data.toolInput : {};
668
+ const nextToolInput = {
669
+ ...currentToolInput,
670
+ elements: buildExcalidrawRestoreCheckpointToolInput(checkpointId, args.data),
671
+ };
672
+ const currentToolResult = isRecord(node.data.toolResult)
673
+ ? ensureExcalidrawCheckpointId(node.data.toolResult as CallToolResult, node.id, checkpointId)
674
+ : undefined;
675
+
676
+ canvasState.updateNode(nodeId, {
677
+ data: {
678
+ ...node.data,
679
+ toolInput: nextToolInput,
680
+ ...(currentToolResult ? { toolResult: currentToolResult } : {}),
681
+ appCheckpoint: {
682
+ toolName: EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
683
+ id: checkpointId,
684
+ ...(typeof args.data === 'string' ? { data: args.data } : {}),
685
+ updatedAt: new Date().toISOString(),
686
+ },
687
+ },
688
+ });
689
+ return true;
690
+ }
691
+
609
692
  function findReusableCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
610
693
  for (const node of canvasState.getLayout().nodes) {
611
694
  if (
@@ -1105,19 +1188,26 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1105
1188
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1106
1189
  ? body.data as Record<string, unknown>
1107
1190
  : undefined;
1108
- const { id, node, needsCodeGraphRecompute } = addCanvasNode({
1109
- type: type as CanvasNodeState['type'],
1110
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1111
- ...(typeof body.content === 'string' ? { content: body.content } : {}),
1112
- ...(extraData ? { data: extraData } : {}),
1113
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1114
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1115
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
1116
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
1117
- defaultWidth: 360,
1118
- defaultHeight: 200,
1119
- fileMode: 'auto',
1120
- });
1191
+ let added: ReturnType<typeof addCanvasNode>;
1192
+ try {
1193
+ added = addCanvasNode({
1194
+ type: type as CanvasNodeState['type'],
1195
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
1196
+ ...(typeof body.content === 'string' ? { content: body.content } : {}),
1197
+ ...(extraData ? { data: extraData } : {}),
1198
+ ...(typeof body.x === 'number' ? { x: body.x } : {}),
1199
+ ...(typeof body.y === 'number' ? { y: body.y } : {}),
1200
+ ...(typeof body.width === 'number' ? { width: body.width } : {}),
1201
+ ...(typeof body.height === 'number' ? { height: body.height } : {}),
1202
+ defaultWidth: 360,
1203
+ defaultHeight: 200,
1204
+ fileMode: 'auto',
1205
+ });
1206
+ } catch (error) {
1207
+ const message = error instanceof Error ? error.message : String(error);
1208
+ return responseJson({ ok: false, error: message }, 400);
1209
+ }
1210
+ const { node, needsCodeGraphRecompute } = added;
1121
1211
 
1122
1212
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1123
1213
  if (needsCodeGraphRecompute) {
@@ -1316,7 +1406,13 @@ async function handleCanvasArrange(req: Request): Promise<Response> {
1316
1406
  }
1317
1407
  const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
1318
1408
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1319
- return responseJson({ ok: true, arranged: result.arranged, layout: result.layout });
1409
+ const validation = validateCanvasLayout(canvasState.getLayout());
1410
+ return responseJson({
1411
+ ok: validation.ok,
1412
+ arranged: result.arranged,
1413
+ layout: result.layout,
1414
+ ...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
1415
+ });
1320
1416
  }
1321
1417
 
1322
1418
  // ── Focus on node ───────────────────────────────────────────
@@ -1326,11 +1422,17 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1326
1422
  if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
1327
1423
  const node = canvasState.getNode(nodeId);
1328
1424
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
1329
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
1330
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId });
1331
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1425
+ const noPan = body.noPan === true;
1426
+ if (!noPan) {
1427
+ canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
1428
+ } else {
1429
+ const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
1430
+ canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
1431
+ }
1432
+ emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
1433
+ if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1332
1434
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1333
- return responseJson({ ok: true, focused: nodeId });
1435
+ return responseJson({ ok: true, focused: nodeId, panned: !noPan });
1334
1436
  }
1335
1437
 
1336
1438
  async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
@@ -1368,6 +1470,9 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1368
1470
  ...(typeof body.bundleScriptPath === 'string'
1369
1471
  ? { bundleScriptPath: body.bundleScriptPath }
1370
1472
  : {}),
1473
+ ...(Array.isArray(body.deps)
1474
+ ? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
1475
+ : {}),
1371
1476
  ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
1372
1477
  ...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
1373
1478
  });
@@ -1846,6 +1951,10 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1846
1951
  });
1847
1952
 
1848
1953
  const toolCallId = randomExtAppToolCallId();
1954
+ const nodeIdSeed = `ext-app-${toolCallId}`;
1955
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
1956
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
1957
+ : opened.toolResult;
1849
1958
  const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
1850
1959
 
1851
1960
  emitPrimaryWorkbenchEvent('ext-app-open', {
@@ -1871,8 +1980,8 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
1871
1980
  toolCallId,
1872
1981
  serverName: opened.serverName,
1873
1982
  toolName: opened.toolName,
1874
- success: opened.toolResult.isError !== true,
1875
- result: opened.toolResult,
1983
+ success: toolResult.isError !== true,
1984
+ result: toolResult,
1876
1985
  });
1877
1986
  const nodeId = findCanvasExtAppNodeId(toolCallId);
1878
1987
 
@@ -1972,35 +2081,53 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
1972
2081
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
1973
2082
 
1974
2083
  try {
1975
- const result = await callMcpAppTool(sessionId, toolName, args);
2084
+ const requestedNode = nodeId ? canvasState.getNode(nodeId) : undefined;
2085
+ const canReadLocalCheckpoint =
2086
+ requestedNode?.type === 'mcp-app' &&
2087
+ requestedNode.data.mode === 'ext-app' &&
2088
+ requestedNode.data.appSessionId === sessionId;
2089
+ const localCheckpointData = canReadLocalCheckpoint && toolName === EXCALIDRAW_READ_CHECKPOINT_TOOL
2090
+ ? getLocalExcalidrawCheckpointData(requestedNode, args)
2091
+ : null;
2092
+ const result = localCheckpointData === null
2093
+ ? await callMcpAppTool(sessionId, toolName, args)
2094
+ : { content: [{ type: 'text', text: localCheckpointData }] } satisfies CallToolResult;
1976
2095
  if (nodeId) {
1977
2096
  const node = canvasState.getNode(nodeId);
1978
2097
  if (node?.type === 'mcp-app' && node.data.mode === 'ext-app' && node.data.appSessionId === sessionId) {
1979
- const nextData: Record<string, unknown> = {
1980
- ...node.data,
1981
- toolResult: result,
1982
- };
1983
- const nextModelContext: Record<string, unknown> = {};
1984
- if (Array.isArray(result.content)) {
1985
- nextModelContext.content = result.content;
1986
- }
1987
- if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
1988
- nextModelContext.structuredContent = result.structuredContent;
2098
+ let changed = false;
2099
+ if (toolName === EXCALIDRAW_SAVE_CHECKPOINT_TOOL && persistExcalidrawCheckpointToNode(nodeId, node, args)) {
2100
+ // Checkpoint saves are replayed through toolInput.elements instead of
2101
+ // replacing the original create_view result with a generic "ok".
2102
+ changed = true;
2103
+ } else if (!(isExcalidrawCreateView(node.data.serverName, node.data.toolName) && isCheckpointToolName(toolName))) {
2104
+ const nextData: Record<string, unknown> = { ...node.data };
2105
+ if (shouldReplayAppToolResult(toolName, result)) nextData.toolResult = result;
2106
+ const nextModelContext: Record<string, unknown> = {};
2107
+ if (Array.isArray(result.content)) {
2108
+ nextModelContext.content = result.content;
2109
+ }
2110
+ if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
2111
+ nextModelContext.structuredContent = result.structuredContent;
2112
+ }
2113
+ if (Object.keys(nextModelContext).length > 0) {
2114
+ nextData.appModelContext = {
2115
+ ...nextModelContext,
2116
+ updatedAt: new Date().toISOString(),
2117
+ };
2118
+ }
2119
+ canvasState.updateNode(nodeId, {
2120
+ data: nextData,
2121
+ });
2122
+ changed = true;
1989
2123
  }
1990
- if (Object.keys(nextModelContext).length > 0) {
1991
- nextData.appModelContext = {
1992
- ...nextModelContext,
1993
- updatedAt: new Date().toISOString(),
1994
- };
2124
+ if (changed) {
2125
+ broadcastWorkbenchEvent('canvas-layout-update', {
2126
+ layout: canvasState.getLayout(),
2127
+ sessionId: primaryWorkbenchSessionId,
2128
+ timestamp: new Date().toISOString(),
2129
+ });
1995
2130
  }
1996
- canvasState.updateNode(nodeId, {
1997
- data: nextData,
1998
- });
1999
- broadcastWorkbenchEvent('canvas-layout-update', {
2000
- layout: canvasState.getLayout(),
2001
- sessionId: primaryWorkbenchSessionId,
2002
- timestamp: new Date().toISOString(),
2003
- });
2004
2131
  }
2005
2132
  }
2006
2133
  return responseJson({ ok: true, result });
@@ -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
 
@@ -163,6 +164,12 @@ async function runProcess(
163
164
  ]).has(key);
164
165
  }),
165
166
  );
167
+ // Spawn in its own process group (POSIX only — Windows has a different model).
168
+ // This lets us kill every descendant — pnpm, bun, parcel, swc, lmdb, etc. —
169
+ // if the build hangs or times out, instead of leaving orphans that accumulate
170
+ // file descriptors and processes across retries (seen as later
171
+ // `fork: Resource temporarily unavailable` in end-user reports).
172
+ const isPosix = process.platform !== 'win32';
166
173
  const child = spawn(command, args, {
167
174
  cwd: options.cwd,
168
175
  env: {
@@ -171,10 +178,33 @@ async function runProcess(
171
178
  CI: '1',
172
179
  npm_config_yes: 'true',
173
180
  pnpm_config_yes: 'true',
181
+ // Cap pnpm's internal child concurrency so installs don't blow past
182
+ // macOS default ulimit -u (often 256-2048) when resolving the ~30
183
+ // radix-ui dependencies in one `pnpm add` call.
184
+ pnpm_config_child_concurrency: '2',
185
+ NPM_CONFIG_CHILD_CONCURRENCY: '2',
174
186
  },
175
187
  stdio: ['ignore', 'pipe', 'pipe'],
188
+ detached: isPosix,
176
189
  });
177
190
 
191
+ const killTree = (signal: NodeJS.Signals): void => {
192
+ if (isPosix && typeof child.pid === 'number') {
193
+ try {
194
+ // Negative pid = send to the whole process group.
195
+ process.kill(-child.pid, signal);
196
+ return;
197
+ } catch {
198
+ // fall through to direct kill
199
+ }
200
+ }
201
+ try {
202
+ child.kill(signal);
203
+ } catch {
204
+ // ignore
205
+ }
206
+ };
207
+
178
208
  let stdout = '';
179
209
  let stderr = '';
180
210
  let timedOut = false;
@@ -189,12 +219,13 @@ async function runProcess(
189
219
  await new Promise<void>((resolvePromise, rejectPromise) => {
190
220
  const timer = setTimeout(() => {
191
221
  timedOut = true;
192
- child.kill('SIGKILL');
222
+ killTree('SIGKILL');
193
223
  rejectPromise(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`));
194
224
  }, options.timeoutMs);
195
225
 
196
226
  child.on('error', (error) => {
197
227
  clearTimeout(timer);
228
+ killTree('SIGKILL');
198
229
  rejectPromise(error);
199
230
  });
200
231
 
@@ -202,9 +233,17 @@ async function runProcess(
202
233
  clearTimeout(timer);
203
234
  if (timedOut) return;
204
235
  if (code !== 0) {
236
+ const trimmedStderr = stderr.trim();
237
+ const stderrTail = trimmedStderr.split('\n').slice(-20).join('\n');
238
+ const trimmedStdout = stdout.trim();
239
+ const stdoutTail = trimmedStdout.split('\n').slice(-20).join('\n');
205
240
  rejectPromise(
206
241
  new Error(
207
- [`Command failed (${code}): ${command} ${args.join(' ')}`, stderr.trim()]
242
+ [
243
+ `Command failed (${code}): ${command} ${args.join(' ')}`,
244
+ stderrTail && `stderr:\n${stderrTail}`,
245
+ !trimmedStderr && stdoutTail && `stdout:\n${stdoutTail}`,
246
+ ]
208
247
  .filter(Boolean)
209
248
  .join('\n'),
210
249
  ),
@@ -284,6 +323,19 @@ function ensurePackageManagerBoundary(dirPath: string): void {
284
323
  writeFileSync(packageJsonPath, JSON.stringify(nextPackageJson, null, 2), 'utf-8');
285
324
  }
286
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
+
287
339
  function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
288
340
  if (!text.trim()) return undefined;
289
341
 
@@ -353,6 +405,23 @@ export async function executeWebArtifactBuild(
353
405
 
354
406
  writeProjectFiles(projectPath, input);
355
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
+
356
425
  const bundleResult = await runProcess('bash', [bundleScriptPath], {
357
426
  cwd: projectPath,
358
427
  timeoutMs,
@@ -365,18 +434,29 @@ export async function executeWebArtifactBuild(
365
434
  throw new Error(`Expected bundled artifact at ${bundlePath}`);
366
435
  }
367
436
 
437
+ const bundleSize = statSync(bundlePath).size;
438
+ if (bundleSize <= 0) {
439
+ throw new Error(`Bundled artifact is empty: ${bundlePath}`);
440
+ }
441
+
368
442
  mkdirSync(dirname(outputPath), { recursive: true });
369
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;
370
449
 
371
450
  return {
372
451
  filePath: outputPath,
373
- fileSize: statSync(outputPath).size,
452
+ fileSize,
374
453
  projectPath,
375
454
  metadata: {
376
455
  title: input.title,
377
456
  bundlePath,
378
457
  projectPath,
379
458
  hasIndexCss: typeof input.indexCss === 'string',
459
+ ...(deps.length > 0 ? { deps } : {}),
380
460
  extraFileCount: Object.keys(input.files ?? {}).length,
381
461
  outputPreview: readFileSync(outputPath, 'utf-8').slice(0, 200),
382
462
  },
@@ -74,3 +74,28 @@ export function normalizeExtAppToolResult(
74
74
  isError,
75
75
  };
76
76
  }
77
+
78
+ /**
79
+ * Structural equality between two `CallToolResult` values, used by the host
80
+ * ExtAppFrame to suppress echo-back re-renders when an SSE layout update
81
+ * mints a new object reference for an otherwise-unchanged tool result.
82
+ *
83
+ * JSON-stringify is adequate here: tool results are strictly JSON (no
84
+ * functions, symbols, or cycles), typically small, and on the hot path we
85
+ * only hit this when references already differ. For very large payloads
86
+ * (> ~2MB) an early length check skips the stringify to avoid a user-visible
87
+ * stall — such results are treated as "changed" and forwarded to the widget.
88
+ */
89
+ export function extAppToolResultsMatch(a: CallToolResult, b: CallToolResult): boolean {
90
+ if (a === b) return true;
91
+ if (a.isError !== b.isError) return false;
92
+ try {
93
+ const sa = JSON.stringify(a);
94
+ const sb = JSON.stringify(b);
95
+ if (sa === undefined || sb === undefined) return false;
96
+ if (Math.abs(sa.length - sb.length) > 2_000_000) return false;
97
+ return sa === sb;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }