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.
- package/CHANGELOG.md +131 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +70 -70
- package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -1
- package/dist/types/client/state/canvas-store.d.ts +2 -1
- package/dist/types/client/types.d.ts +3 -0
- package/dist/types/server/bundled-skills.d.ts +40 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +26 -5
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
- package/src/cli/agent.ts +78 -7
- package/src/cli/index.ts +22 -2
- package/src/client/App.tsx +2 -1
- package/src/client/canvas/CanvasNode.tsx +3 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
- package/src/client/nodes/ExtAppFrame.tsx +183 -38
- package/src/client/state/canvas-store.ts +63 -1
- package/src/client/state/sse-bridge.ts +5 -0
- package/src/client/types.ts +12 -0
- package/src/mcp/server.ts +92 -6
- package/src/server/bundled-skills.ts +143 -0
- package/src/server/canvas-operations.ts +57 -8
- package/src/server/canvas-schema.ts +2 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/index.ts +22 -10
- package/src/server/server.ts +172 -45
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
- package/src/server/web-artifacts.ts +83 -3
- package/src/shared/ext-app-tool-result.ts +25 -0
package/src/server/index.ts
CHANGED
|
@@ -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):
|
|
328
|
+
focusNode(id: string, options?: { noPan?: boolean }): { focused: string; panned: boolean } | null {
|
|
327
329
|
const node = canvasState.getNode(id);
|
|
328
|
-
if (!node) return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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:
|
|
515
|
-
result:
|
|
526
|
+
success: toolResult.isError !== true,
|
|
527
|
+
result: toolResult,
|
|
516
528
|
});
|
|
517
529
|
const nodeId = this.findCanvasExtAppNodeId(toolCallId);
|
|
518
530
|
return {
|
package/src/server/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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:
|
|
1875
|
-
result:
|
|
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
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
if (
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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 (
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
+
}
|