pmx-canvas 0.1.35 → 0.2.0
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 +461 -0
- package/Readme.md +14 -2
- package/dist/canvas/index.js +82 -41
- package/dist/json-render/index.js +89 -334
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +12 -159
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/ax-state-manager.d.ts +256 -0
- package/dist/types/server/ax-state.d.ts +29 -1
- package/dist/types/server/ax-wait.d.ts +23 -0
- package/dist/types/server/canvas-operations.d.ts +1 -12
- package/dist/types/server/canvas-state.d.ts +46 -14
- package/dist/types/server/html-surface.d.ts +7 -0
- package/dist/types/server/index.d.ts +66 -26
- package/dist/types/server/operations/composites.d.ts +121 -0
- package/dist/types/server/operations/http.d.ts +7 -0
- package/dist/types/server/operations/index.d.ts +8 -0
- package/dist/types/server/operations/invoker.d.ts +13 -0
- package/dist/types/server/operations/mcp.d.ts +15 -0
- package/dist/types/server/operations/ops/annotation.d.ts +2 -0
- package/dist/types/server/operations/ops/app.d.ts +33 -0
- package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
- package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +19 -0
- package/dist/types/server/operations/ops/edges.d.ts +2 -0
- package/dist/types/server/operations/ops/groups.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +31 -0
- package/dist/types/server/operations/ops/nodes.d.ts +62 -0
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
- package/dist/types/server/operations/ops/validate.d.ts +2 -0
- package/dist/types/server/operations/ops/viewport.d.ts +2 -0
- package/dist/types/server/operations/ops/webview.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +15 -0
- package/dist/types/server/operations/types.d.ts +116 -0
- package/dist/types/server/operations/webview-runner.d.ts +69 -0
- package/docs/RELEASE.md +5 -0
- package/docs/adr-001-bun-only-runtime.md +46 -0
- package/docs/api-stability.md +57 -0
- package/docs/ax-host-adapter-contract.md +65 -0
- package/docs/ax-state-contract.md +72 -0
- package/docs/http-api.md +34 -2
- package/docs/mcp.md +64 -11
- package/docs/plans/plan-005-operation-registry.md +84 -0
- package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
- package/docs/plans/plan-007-ax-domain.md +99 -0
- package/docs/plans/plan-008-registry-finish.md +91 -0
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +90 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +233 -185
- package/skills/pmx-canvas/evals/evals.json +3 -3
- package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
- package/src/cli/agent.ts +52 -31
- package/src/client/nodes/ExtAppFrame.tsx +73 -5
- package/src/client/nodes/HtmlNode.tsx +12 -3
- package/src/client/nodes/McpAppNode.tsx +12 -3
- package/src/json-render/renderer/index.tsx +3 -0
- package/src/mcp/canvas-access.ts +43 -774
- package/src/mcp/server.ts +190 -2001
- package/src/server/ax-context.ts +7 -1
- package/src/server/ax-state-manager.ts +808 -0
- package/src/server/ax-state.ts +89 -2
- package/src/server/ax-wait.ts +56 -0
- package/src/server/canvas-operations.ts +2 -328
- package/src/server/canvas-schema.ts +2 -2
- package/src/server/canvas-state.ts +140 -382
- package/src/server/html-surface.ts +49 -11
- package/src/server/index.ts +136 -192
- package/src/server/operations/composites.ts +355 -0
- package/src/server/operations/http.ts +103 -0
- package/src/server/operations/index.ts +65 -0
- package/src/server/operations/invoker.ts +87 -0
- package/src/server/operations/mcp.ts +221 -0
- package/src/server/operations/ops/annotation.ts +60 -0
- package/src/server/operations/ops/app.ts +447 -0
- package/src/server/operations/ops/ax-await.ts +216 -0
- package/src/server/operations/ops/ax-shared.ts +38 -0
- package/src/server/operations/ops/ax-state.ts +249 -0
- package/src/server/operations/ops/ax-timeline.ts +381 -0
- package/src/server/operations/ops/ax-work.ts +635 -0
- package/src/server/operations/ops/batch.ts +365 -0
- package/src/server/operations/ops/edges.ts +166 -0
- package/src/server/operations/ops/groups.ts +176 -0
- package/src/server/operations/ops/json-render.ts +691 -0
- package/src/server/operations/ops/nodes.ts +1047 -0
- package/src/server/operations/ops/query.ts +281 -0
- package/src/server/operations/ops/snapshots.ts +366 -0
- package/src/server/operations/ops/validate.ts +37 -0
- package/src/server/operations/ops/viewport.ts +219 -0
- package/src/server/operations/ops/webview.ts +339 -0
- package/src/server/operations/registry.ts +79 -0
- package/src/server/operations/types.ts +150 -0
- package/src/server/operations/webview-runner.ts +77 -0
- package/src/server/server.ts +253 -2170
- package/src/server/web-artifacts.ts +6 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2 operations (plan-005): arrange / node.focus / view.fit /
|
|
3
|
+
* canvas.clear.
|
|
4
|
+
*
|
|
5
|
+
* Event notes (matching the legacy handlers exactly):
|
|
6
|
+
* - arrange: arrangeCanvasNodes records its own compound history entry; the
|
|
7
|
+
* registry emits the single canvas-layout-update (mutates: true).
|
|
8
|
+
* - node.focus: emits ax-state-changed, canvas-focus-node, and (when panning)
|
|
9
|
+
* canvas-viewport-update via ctx.emit; the registry appends the final
|
|
10
|
+
* canvas-layout-update.
|
|
11
|
+
* - view.fit: mutates: false with a manual canvas-viewport-update emit.
|
|
12
|
+
*
|
|
13
|
+
* This module must never import server.ts or index.ts.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { canvasState } from '../../canvas-state.js';
|
|
17
|
+
import {
|
|
18
|
+
arrangeCanvasNodes,
|
|
19
|
+
clearCanvas,
|
|
20
|
+
fitCanvasView,
|
|
21
|
+
} from '../../canvas-operations.js';
|
|
22
|
+
import { validateCanvasLayout } from '../../canvas-validation.js';
|
|
23
|
+
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
24
|
+
import { closeNodeAppSession, isRecord } from './nodes.js';
|
|
25
|
+
|
|
26
|
+
// ── arrange ───────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const arrangeShape = {
|
|
29
|
+
layout: z.unknown().optional().describe('Arrangement layout: grid (default), column, or flow'),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const arrangeSchema = z.looseObject(arrangeShape);
|
|
33
|
+
|
|
34
|
+
const arrangeOperation = defineOperation<z.infer<typeof arrangeSchema>, Record<string, unknown>>({
|
|
35
|
+
name: 'arrange',
|
|
36
|
+
mutates: true,
|
|
37
|
+
input: arrangeSchema,
|
|
38
|
+
inputShape: arrangeShape,
|
|
39
|
+
http: {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
path: '/api/canvas/arrange',
|
|
42
|
+
},
|
|
43
|
+
mcp: {
|
|
44
|
+
toolName: 'canvas_arrange',
|
|
45
|
+
description: 'Auto-arrange all nodes on the canvas. Layouts: grid (default), column (vertical stack), flow (horizontal row).',
|
|
46
|
+
extraShape: {
|
|
47
|
+
layout: z.enum(['grid', 'column', 'flow']).optional().describe('Arrangement layout (default: grid)'),
|
|
48
|
+
},
|
|
49
|
+
// Legacy tool reported { ok: true, layout } regardless of the arrange
|
|
50
|
+
// result (it ignored the arranged count and validation outcome).
|
|
51
|
+
formatResult: (result, input) => ({
|
|
52
|
+
content: [{
|
|
53
|
+
type: 'text' as const,
|
|
54
|
+
text: JSON.stringify({ ok: true, layout: typeof input.layout === 'string' ? input.layout : 'grid' }),
|
|
55
|
+
}],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
handler: (input) => {
|
|
59
|
+
const layout = typeof input.layout === 'string' ? input.layout : 'grid';
|
|
60
|
+
if (!['grid', 'column', 'flow'].includes(layout)) {
|
|
61
|
+
throw new OperationError(`Invalid layout: "${layout}". Use: grid, column, flow`);
|
|
62
|
+
}
|
|
63
|
+
// arrangeCanvasNodes records its own single compound history entry —
|
|
64
|
+
// nothing else here may record (no double-record).
|
|
65
|
+
const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
|
|
66
|
+
const validation = validateCanvasLayout(canvasState.getLayout());
|
|
67
|
+
return {
|
|
68
|
+
ok: validation.ok,
|
|
69
|
+
arranged: result.arranged,
|
|
70
|
+
layout: result.layout,
|
|
71
|
+
...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── node.focus ────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const focusShape = {
|
|
79
|
+
id: z.unknown().optional().describe('Node ID to focus on'),
|
|
80
|
+
noPan: z.unknown().optional().describe('If true, raise/select the node without panning the viewport. Default false.'),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const focusSchema = z.looseObject(focusShape);
|
|
84
|
+
|
|
85
|
+
const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<string, unknown>>({
|
|
86
|
+
name: 'node.focus',
|
|
87
|
+
mutates: true,
|
|
88
|
+
input: focusSchema,
|
|
89
|
+
inputShape: focusShape,
|
|
90
|
+
http: {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
path: '/api/canvas/focus',
|
|
93
|
+
},
|
|
94
|
+
mcp: {
|
|
95
|
+
toolName: 'canvas_focus_node',
|
|
96
|
+
description: 'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
|
|
97
|
+
extraShape: {
|
|
98
|
+
id: z.string().describe('Node ID to focus on'),
|
|
99
|
+
noPan: z
|
|
100
|
+
.boolean()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe('If true, raise/select the node without panning the viewport. Default false.'),
|
|
103
|
+
},
|
|
104
|
+
formatResult: (result) => {
|
|
105
|
+
const body = isRecord(result) ? result : {};
|
|
106
|
+
return {
|
|
107
|
+
content: [{
|
|
108
|
+
type: 'text' as const,
|
|
109
|
+
text: JSON.stringify({ ok: true, focused: body.focused, panned: body.panned }),
|
|
110
|
+
}],
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
handler: (input, ctx) => {
|
|
115
|
+
const body: Record<string, unknown> = input;
|
|
116
|
+
const nodeId = typeof body.id === 'string' ? body.id : '';
|
|
117
|
+
if (!nodeId) throw new OperationError('Missing id.');
|
|
118
|
+
const node = canvasState.getNode(nodeId);
|
|
119
|
+
if (!node) throw new OperationError(`Node "${nodeId}" not found.`, 404);
|
|
120
|
+
const noPan = body.noPan === true;
|
|
121
|
+
if (!noPan) {
|
|
122
|
+
canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
|
|
123
|
+
} else {
|
|
124
|
+
const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
|
|
125
|
+
canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
|
|
126
|
+
}
|
|
127
|
+
const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
|
|
128
|
+
ctx.emit('ax-state-changed', { focus });
|
|
129
|
+
ctx.emit('canvas-focus-node', { nodeId, noPan });
|
|
130
|
+
if (!noPan) ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
131
|
+
return { ok: true, focused: nodeId, panned: !noPan, axFocus: focus };
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── view.fit ──────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const fitShape = {
|
|
138
|
+
width: z.number().optional().catch(undefined).describe('Viewport width used for fit math (default 1440)'),
|
|
139
|
+
height: z.number().optional().catch(undefined).describe('Viewport height used for fit math (default 900)'),
|
|
140
|
+
padding: z.number().optional().catch(undefined).describe('World-space padding around fitted nodes (default 60)'),
|
|
141
|
+
maxScale: z.number().optional().catch(undefined).describe('Maximum zoom scale (default 1)'),
|
|
142
|
+
nodeIds: z.unknown().optional().describe('Optional node IDs to fit instead of the whole canvas'),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const fitSchema = z.looseObject(fitShape);
|
|
146
|
+
|
|
147
|
+
const fitOperation = defineOperation<z.infer<typeof fitSchema>, Record<string, unknown>>({
|
|
148
|
+
name: 'view.fit',
|
|
149
|
+
mutates: false,
|
|
150
|
+
input: fitSchema,
|
|
151
|
+
inputShape: fitShape,
|
|
152
|
+
http: {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
path: '/api/canvas/fit',
|
|
155
|
+
},
|
|
156
|
+
mcp: {
|
|
157
|
+
toolName: 'canvas_fit_view',
|
|
158
|
+
description: 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
|
|
159
|
+
extraShape: {
|
|
160
|
+
nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
|
|
161
|
+
},
|
|
162
|
+
formatResult: (result) => ({
|
|
163
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
handler: (input, ctx) => {
|
|
167
|
+
const body: Record<string, unknown> = input;
|
|
168
|
+
const nodeIds = Array.isArray(body.nodeIds)
|
|
169
|
+
? body.nodeIds.filter((id): id is string => typeof id === 'string')
|
|
170
|
+
: undefined;
|
|
171
|
+
const result = fitCanvasView({
|
|
172
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
173
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
174
|
+
...(typeof body.padding === 'number' ? { padding: body.padding } : {}),
|
|
175
|
+
...(typeof body.maxScale === 'number' ? { maxScale: body.maxScale } : {}),
|
|
176
|
+
...(nodeIds ? { nodeIds } : {}),
|
|
177
|
+
});
|
|
178
|
+
ctx.emit('canvas-viewport-update', { viewport: result.viewport });
|
|
179
|
+
return result as unknown as Record<string, unknown>;
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── canvas.clear ──────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
const clearShape = {};
|
|
186
|
+
|
|
187
|
+
const clearSchema = z.looseObject(clearShape);
|
|
188
|
+
|
|
189
|
+
const clearOperation = defineOperation<z.infer<typeof clearSchema>, Record<string, unknown>>({
|
|
190
|
+
name: 'canvas.clear',
|
|
191
|
+
mutates: true,
|
|
192
|
+
input: clearSchema,
|
|
193
|
+
inputShape: clearShape,
|
|
194
|
+
http: {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
path: '/api/canvas/clear',
|
|
197
|
+
},
|
|
198
|
+
mcp: {
|
|
199
|
+
toolName: 'canvas_clear',
|
|
200
|
+
description: 'Remove all nodes and edges from the canvas. Use with caution.',
|
|
201
|
+
formatResult: () => ({
|
|
202
|
+
content: [{ type: 'text' as const, text: JSON.stringify({ ok: true, cleared: true }) }],
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
handler: () => {
|
|
206
|
+
for (const node of canvasState.getLayout().nodes) {
|
|
207
|
+
closeNodeAppSession(node);
|
|
208
|
+
}
|
|
209
|
+
clearCanvas();
|
|
210
|
+
return { ok: true };
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
export const viewportOperations: Operation[] = [
|
|
215
|
+
arrangeOperation,
|
|
216
|
+
focusOperation,
|
|
217
|
+
fitOperation,
|
|
218
|
+
clearOperation,
|
|
219
|
+
];
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webview (Bun.WebView automation) operations (plan-008 Wave 3).
|
|
3
|
+
*
|
|
4
|
+
* The five browser-automation tools (status / start / stop / resize / evaluate)
|
|
5
|
+
* migrate to the registry. They are SIDE-SURFACE operations: `mutates: false`
|
|
6
|
+
* (no canvas node/edge state changes, so NO canvas-layout-update frame).
|
|
7
|
+
*
|
|
8
|
+
* The automation machinery lives in `../../server.ts`, which `operations/` must
|
|
9
|
+
* NEVER import. Each handler calls the INJECTED runner (`getWebviewRunner()`);
|
|
10
|
+
* server.ts wires the real automation functions via `setWebviewRunner` at module
|
|
11
|
+
* load — the same injection pattern as `setOperationEventEmitter`.
|
|
12
|
+
*
|
|
13
|
+
* Wire + MCP result shapes are byte-identical to the legacy hand-written tools:
|
|
14
|
+
* - status GET /api/workbench/webview → raw status object
|
|
15
|
+
* - start POST /api/workbench/webview/start → { ok, webview } (+error: { ok, error, webview })
|
|
16
|
+
* - stop DELETE /api/workbench/webview → { ok, stopped, webview }
|
|
17
|
+
* - resize POST /api/workbench/webview/resize → { ok, webview }
|
|
18
|
+
* - evaluate POST /api/workbench/webview/evaluate → { ok, value }
|
|
19
|
+
*
|
|
20
|
+
* `canvas_screenshot` is NOT migrated — it returns a binary payload and stays a
|
|
21
|
+
* standalone hand-written tool (the POST /api/workbench/webview/screenshot route
|
|
22
|
+
* also stays hand-written in server.ts).
|
|
23
|
+
*
|
|
24
|
+
* This module must never import server.ts or index.ts.
|
|
25
|
+
*/
|
|
26
|
+
import { resolve, relative, isAbsolute } from 'node:path';
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
29
|
+
import {
|
|
30
|
+
getWebviewRunner,
|
|
31
|
+
type WebviewStartOptions,
|
|
32
|
+
type WebviewStartResult,
|
|
33
|
+
type WebviewStatus,
|
|
34
|
+
} from '../webview-runner.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a workspace-relative path and reject anything escaping the workspace
|
|
38
|
+
* (mirrors the MCP server's `safeWorkspacePath`). Inlined here (pure node:path +
|
|
39
|
+
* process.cwd) so the registry op can enforce the workspace boundary without
|
|
40
|
+
* importing the MCP server. Applied in buildStartOptions for BOTH the MCP and
|
|
41
|
+
* HTTP surfaces (the legacy MCP tool sandboxed it; the HTTP route did not —
|
|
42
|
+
* unified here to the safer behavior).
|
|
43
|
+
*/
|
|
44
|
+
function safeWorkspacePath(pathLike: string): string {
|
|
45
|
+
const workspace = resolve(process.cwd());
|
|
46
|
+
const resolved = resolve(workspace, pathLike);
|
|
47
|
+
const rel = relative(workspace, resolved);
|
|
48
|
+
const inside = rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
49
|
+
if (!inside) {
|
|
50
|
+
throw new OperationError(`Path "${pathLike}" resolves outside workspace.`);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wrap a multi-statement script body in an async IIFE (legacy
|
|
56
|
+
* wrapCanvasAutomationScript). Inlined — it is a pure string template. */
|
|
57
|
+
function wrapScript(script: string): string {
|
|
58
|
+
return `(async () => {\n${script}\n})()`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run an injected automation-runner call, converting a runtime failure (e.g. no
|
|
63
|
+
* active session, or a page-side eval error) into the legacy
|
|
64
|
+
* `400 { ok:false, error, webview }` contract. Without this the plain Error the
|
|
65
|
+
* runner throws would escape dispatchOperationRoute (which only maps OperationError)
|
|
66
|
+
* and surface as Bun's default 500 HTML error overlay — a wire regression that also
|
|
67
|
+
* discloses the server source path. Mirrors the legacy resize/evaluate try/catch.
|
|
68
|
+
*/
|
|
69
|
+
async function runWebviewTask<T>(task: () => Promise<T> | T): Promise<T> {
|
|
70
|
+
try {
|
|
71
|
+
return await task();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
let webview: WebviewStatus | undefined;
|
|
75
|
+
try { webview = getWebviewRunner().status(); } catch { /* runner not wired */ }
|
|
76
|
+
throw new OperationError(message, 400, webview ? { webview } : undefined);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function statusText(status: WebviewStatus): { content: [{ type: 'text'; text: string }] } {
|
|
81
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(status, null, 2) }] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── webview.status ────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const statusShape = {};
|
|
87
|
+
const statusSchema = z.looseObject(statusShape);
|
|
88
|
+
|
|
89
|
+
const statusOperation = defineOperation<z.infer<typeof statusSchema>, WebviewStatus>({
|
|
90
|
+
name: 'webview.status',
|
|
91
|
+
mutates: false,
|
|
92
|
+
input: statusSchema,
|
|
93
|
+
inputShape: statusShape,
|
|
94
|
+
http: {
|
|
95
|
+
method: 'GET',
|
|
96
|
+
path: '/api/workbench/webview',
|
|
97
|
+
},
|
|
98
|
+
mcp: {
|
|
99
|
+
toolName: 'canvas_webview_status',
|
|
100
|
+
description:
|
|
101
|
+
'Get the current Bun.WebView automation status for the PMX Canvas workbench. Returns whether Bun.WebView is supported, whether an automation session is active, backend, viewport size, and the current workbench URL if active.',
|
|
102
|
+
formatResult: (result) => statusText(result as WebviewStatus),
|
|
103
|
+
},
|
|
104
|
+
handler: () => getWebviewRunner().status(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── webview.start ─────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const startShape = {
|
|
110
|
+
backend: z.unknown().optional().describe('Automation backend (chrome | webkit)'),
|
|
111
|
+
width: z.unknown().optional().describe('Viewport width in pixels (default: 1280)'),
|
|
112
|
+
height: z.unknown().optional().describe('Viewport height in pixels (default: 800)'),
|
|
113
|
+
chromePath: z.unknown().optional().describe('Optional Chrome/Chromium executable path'),
|
|
114
|
+
chromeArgv: z.unknown().optional().describe('Optional extra Chrome launch args'),
|
|
115
|
+
dataStoreDir: z.unknown().optional().describe('Optional persistent data store directory'),
|
|
116
|
+
};
|
|
117
|
+
const startSchema = z.looseObject(startShape);
|
|
118
|
+
|
|
119
|
+
function buildStartOptions(input: Record<string, unknown>): WebviewStartOptions {
|
|
120
|
+
const backend = input.backend === 'chrome' || input.backend === 'webkit' ? input.backend : undefined;
|
|
121
|
+
const width = typeof input.width === 'number' ? input.width : undefined;
|
|
122
|
+
const height = typeof input.height === 'number' ? input.height : undefined;
|
|
123
|
+
const chromePath = typeof input.chromePath === 'string' ? input.chromePath : undefined;
|
|
124
|
+
// Sandbox dataStoreDir to the workspace on BOTH surfaces. The legacy MCP tool
|
|
125
|
+
// sandboxed it (the HTTP route passed it raw — a cross-surface asymmetry);
|
|
126
|
+
// unify to the safer behavior here in the shared option builder so an
|
|
127
|
+
// out-of-workspace data store is a 400 over HTTP and MCP alike.
|
|
128
|
+
const dataStoreDir = typeof input.dataStoreDir === 'string' ? safeWorkspacePath(input.dataStoreDir) : undefined;
|
|
129
|
+
const chromeArgv = Array.isArray(input.chromeArgv)
|
|
130
|
+
? input.chromeArgv.filter((value): value is string => typeof value === 'string')
|
|
131
|
+
: undefined;
|
|
132
|
+
return {
|
|
133
|
+
...(backend ? { backend } : {}),
|
|
134
|
+
...(width !== undefined ? { width } : {}),
|
|
135
|
+
...(height !== undefined ? { height } : {}),
|
|
136
|
+
...(chromePath ? { chromePath } : {}),
|
|
137
|
+
...(chromeArgv ? { chromeArgv } : {}),
|
|
138
|
+
...(dataStoreDir ? { dataStoreDir } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const startOperation = defineOperation<z.infer<typeof startSchema>, WebviewStartResult>({
|
|
143
|
+
name: 'webview.start',
|
|
144
|
+
mutates: false,
|
|
145
|
+
input: startSchema,
|
|
146
|
+
inputShape: startShape,
|
|
147
|
+
http: {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
path: '/api/workbench/webview/start',
|
|
150
|
+
// Mirror the legacy handler status codes from the SERIALIZED wire body
|
|
151
|
+
// (`status` receives the serialized result): 200 ok; 503 server-not-running
|
|
152
|
+
// ({ ok:false, error } — no webview); else 501 when the runtime is
|
|
153
|
+
// unsupported (webview.supported === false) vs 500 for a supported failure.
|
|
154
|
+
status: (result) => {
|
|
155
|
+
const body = result as { ok?: boolean; webview?: WebviewStatus };
|
|
156
|
+
if (body.ok) return 200;
|
|
157
|
+
if (!body.webview) return 503;
|
|
158
|
+
return body.webview.supported ? 500 : 501;
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
mcp: {
|
|
162
|
+
toolName: 'canvas_webview_start',
|
|
163
|
+
description:
|
|
164
|
+
'Start or replace the headless Bun.WebView automation session for the current PMX Canvas workbench. Use this before screenshot, evaluate, or resize when no automation session is active.',
|
|
165
|
+
extraShape: {
|
|
166
|
+
backend: z.enum(['chrome', 'webkit']).optional()
|
|
167
|
+
.describe('Automation backend. Default: webkit on macOS, chrome elsewhere.'),
|
|
168
|
+
width: z.number().optional().describe('Viewport width in pixels (default: 1280)'),
|
|
169
|
+
height: z.number().optional().describe('Viewport height in pixels (default: 800)'),
|
|
170
|
+
chromePath: z.string().optional().describe('Optional Chrome/Chromium executable path'),
|
|
171
|
+
chromeArgv: z.array(z.string()).optional().describe('Optional extra Chrome launch args'),
|
|
172
|
+
dataStoreDir: z.string().optional().describe('Optional persistent data store directory'),
|
|
173
|
+
},
|
|
174
|
+
// dataStoreDir is sandboxed to the workspace in buildStartOptions (both the
|
|
175
|
+
// MCP and HTTP surfaces), so no MCP-only buildInput is needed.
|
|
176
|
+
// formatResult receives the SERIALIZED wire body. Legacy
|
|
177
|
+
// canvas_webview_start: on success JSON-stringifies the webview status; on
|
|
178
|
+
// failure surfaces a bare-message isError result.
|
|
179
|
+
formatResult: (result) => {
|
|
180
|
+
const body = result as { ok?: boolean; webview?: WebviewStatus; error?: string };
|
|
181
|
+
if (body.ok && body.webview) return statusText(body.webview);
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: 'text' as const, text: body.error ?? 'WebView start failed.' }],
|
|
184
|
+
isError: true,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
handler: (input) => getWebviewRunner().start(buildStartOptions(input)),
|
|
189
|
+
serialize: (output) => {
|
|
190
|
+
// HTTP wire body: { ok, webview } on success; { ok:false, error } (503,
|
|
191
|
+
// no webview) when the server is not running; { ok:false, error, webview }
|
|
192
|
+
// otherwise — byte-identical to the legacy handler bodies.
|
|
193
|
+
if (output.ok) return { ok: true, webview: output.webview };
|
|
194
|
+
if (output.serverNotRunning) return { ok: false, error: output.error };
|
|
195
|
+
return { ok: false, error: output.error, webview: output.webview };
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── webview.stop ──────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const stopShape = {};
|
|
202
|
+
const stopSchema = z.looseObject(stopShape);
|
|
203
|
+
|
|
204
|
+
const stopOperation = defineOperation<z.infer<typeof stopSchema>, { stopped: boolean; webview: WebviewStatus }>({
|
|
205
|
+
name: 'webview.stop',
|
|
206
|
+
mutates: false,
|
|
207
|
+
input: stopSchema,
|
|
208
|
+
inputShape: stopShape,
|
|
209
|
+
http: {
|
|
210
|
+
method: 'DELETE',
|
|
211
|
+
path: '/api/workbench/webview',
|
|
212
|
+
},
|
|
213
|
+
mcp: {
|
|
214
|
+
toolName: 'canvas_webview_stop',
|
|
215
|
+
description: 'Stop the current Bun.WebView automation session if one is active.',
|
|
216
|
+
// formatResult receives the SERIALIZED wire body { ok, stopped, webview }.
|
|
217
|
+
formatResult: (result) => {
|
|
218
|
+
const body = result as { ok: boolean; stopped: boolean; webview: WebviewStatus };
|
|
219
|
+
return {
|
|
220
|
+
content: [{
|
|
221
|
+
type: 'text' as const,
|
|
222
|
+
text: JSON.stringify({ ok: true, stopped: body.stopped, webview: body.webview }, null, 2),
|
|
223
|
+
}],
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
handler: async () => {
|
|
228
|
+
const runner = getWebviewRunner();
|
|
229
|
+
const stopped = await runner.stop();
|
|
230
|
+
return { stopped, webview: runner.status() };
|
|
231
|
+
},
|
|
232
|
+
serialize: (output) => ({ ok: true, stopped: output.stopped, webview: output.webview }),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── webview.resize ────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
const resizeShape = {
|
|
238
|
+
width: z.unknown().optional().describe('Viewport width in pixels'),
|
|
239
|
+
height: z.unknown().optional().describe('Viewport height in pixels'),
|
|
240
|
+
};
|
|
241
|
+
const resizeSchema = z.looseObject(resizeShape);
|
|
242
|
+
|
|
243
|
+
const resizeOperation = defineOperation<z.infer<typeof resizeSchema>, WebviewStatus>({
|
|
244
|
+
name: 'webview.resize',
|
|
245
|
+
mutates: false,
|
|
246
|
+
input: resizeSchema,
|
|
247
|
+
inputShape: resizeShape,
|
|
248
|
+
http: {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
path: '/api/workbench/webview/resize',
|
|
251
|
+
},
|
|
252
|
+
mcp: {
|
|
253
|
+
toolName: 'canvas_resize',
|
|
254
|
+
description: 'Resize the active Bun.WebView automation viewport. Requires an active automation session started via canvas_webview_start.',
|
|
255
|
+
extraShape: {
|
|
256
|
+
width: z.number().describe('Viewport width in pixels'),
|
|
257
|
+
height: z.number().describe('Viewport height in pixels'),
|
|
258
|
+
},
|
|
259
|
+
// formatResult receives the SERIALIZED wire body { ok, webview }; legacy
|
|
260
|
+
// canvas_resize JSON-stringifies just the webview status.
|
|
261
|
+
formatResult: (result) => statusText((result as { webview: WebviewStatus }).webview),
|
|
262
|
+
},
|
|
263
|
+
handler: async (input) => {
|
|
264
|
+
const width = typeof input.width === 'number' ? input.width : NaN;
|
|
265
|
+
const height = typeof input.height === 'number' ? input.height : NaN;
|
|
266
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
|
267
|
+
throw new OperationError('Missing required positive numeric fields: width, height.');
|
|
268
|
+
}
|
|
269
|
+
return runWebviewTask(() => getWebviewRunner().resize(width, height));
|
|
270
|
+
},
|
|
271
|
+
// HTTP wire body matches the legacy handler: { ok:true, webview }.
|
|
272
|
+
serialize: (output) => ({ ok: true, webview: output }),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── webview.evaluate ──────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
const evaluateShape = {
|
|
278
|
+
expression: z.unknown().optional().describe('JavaScript expression to evaluate in the page context'),
|
|
279
|
+
script: z.unknown().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
|
|
280
|
+
};
|
|
281
|
+
const evaluateSchema = z.looseObject(evaluateShape);
|
|
282
|
+
|
|
283
|
+
const evaluateOperation = defineOperation<z.infer<typeof evaluateSchema>, { value: unknown }>({
|
|
284
|
+
name: 'webview.evaluate',
|
|
285
|
+
mutates: false,
|
|
286
|
+
input: evaluateSchema,
|
|
287
|
+
inputShape: evaluateShape,
|
|
288
|
+
http: {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
path: '/api/workbench/webview/evaluate',
|
|
291
|
+
},
|
|
292
|
+
mcp: {
|
|
293
|
+
toolName: 'canvas_evaluate',
|
|
294
|
+
description: 'Evaluate JavaScript in the active Bun.WebView automation session for the workbench page. Use this to inspect rendered browser state. Requires an active automation session started via canvas_webview_start.',
|
|
295
|
+
extraShape: {
|
|
296
|
+
expression: z.string().optional().describe('JavaScript expression to evaluate in the page context'),
|
|
297
|
+
script: z.string().optional().describe('Multi-statement JavaScript body. The MCP server wraps it in an async IIFE and evaluates the resolved return value.'),
|
|
298
|
+
},
|
|
299
|
+
// Legacy canvas_evaluate validation: exactly one of expression/script (its
|
|
300
|
+
// own message). Validate here so the MCP tool throws the legacy message
|
|
301
|
+
// before dispatch; the handler re-validates with the HTTP-style message for
|
|
302
|
+
// the remote path.
|
|
303
|
+
buildInput: (input) => {
|
|
304
|
+
const hasExpression = typeof input.expression === 'string' && input.expression.length > 0;
|
|
305
|
+
const hasScript = typeof input.script === 'string' && input.script.length > 0;
|
|
306
|
+
if ((hasExpression ? 1 : 0) + (hasScript ? 1 : 0) !== 1) {
|
|
307
|
+
throw new OperationError('Pass exactly one of "expression" or "script".');
|
|
308
|
+
}
|
|
309
|
+
return input;
|
|
310
|
+
},
|
|
311
|
+
// Legacy canvas_evaluate JSON-stringifies { value }.
|
|
312
|
+
formatResult: (result) => {
|
|
313
|
+
const r = result as { value: unknown };
|
|
314
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ value: r.value }, null, 2) }] };
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
handler: async (input) => {
|
|
318
|
+
const expression = typeof input.expression === 'string' ? input.expression.trim() : '';
|
|
319
|
+
const script = typeof input.script === 'string' ? input.script.trim() : '';
|
|
320
|
+
if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
|
|
321
|
+
throw new OperationError(
|
|
322
|
+
'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an async IIFE).',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const source = script ? wrapScript(script) : expression;
|
|
326
|
+
const value = await runWebviewTask(() => getWebviewRunner().evaluate(source));
|
|
327
|
+
return { value };
|
|
328
|
+
},
|
|
329
|
+
// HTTP wire body matches the legacy handler: { ok:true, value }.
|
|
330
|
+
serialize: (output) => ({ ok: true, value: output.value }),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
export const webviewOperations: Operation[] = [
|
|
334
|
+
statusOperation,
|
|
335
|
+
startOperation,
|
|
336
|
+
stopOperation,
|
|
337
|
+
resizeOperation,
|
|
338
|
+
evaluateOperation,
|
|
339
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation registry: register/get/list plus the ONE execution path
|
|
3
|
+
* (`executeOperation`: validate → run → emit) shared by HTTP, MCP, and CLI.
|
|
4
|
+
*
|
|
5
|
+
* SSE wiring: server.ts injects the workbench event emitter via
|
|
6
|
+
* `setOperationEventEmitter` (same pattern as `setCanvasLayoutUpdateEmitter`).
|
|
7
|
+
* Handlers never emit `canvas-layout-update` themselves for the final state —
|
|
8
|
+
* `mutates: true` is the single source; extra events go through `ctx.emit`.
|
|
9
|
+
*/
|
|
10
|
+
import { canvasState } from '../canvas-state.js';
|
|
11
|
+
import { OperationError, type Operation, type OperationContext } from './types.js';
|
|
12
|
+
|
|
13
|
+
const operations = new Map<string, Operation>();
|
|
14
|
+
|
|
15
|
+
export function registerOperation(op: Operation): void {
|
|
16
|
+
if (operations.has(op.name)) {
|
|
17
|
+
throw new Error(`Operation "${op.name}" is already registered.`);
|
|
18
|
+
}
|
|
19
|
+
operations.set(op.name, op);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getOperation(name: string): Operation {
|
|
23
|
+
const op = operations.get(name);
|
|
24
|
+
if (!op) throw new OperationError(`Unknown operation "${name}".`, 400);
|
|
25
|
+
return op;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listOperations(): Operation[] {
|
|
29
|
+
return [...operations.values()];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type OperationEventEmitter = (event: string, payload: Record<string, unknown>) => void;
|
|
33
|
+
|
|
34
|
+
let operationEventEmitter: OperationEventEmitter | null = null;
|
|
35
|
+
|
|
36
|
+
export function setOperationEventEmitter(emitter: OperationEventEmitter | null): void {
|
|
37
|
+
operationEventEmitter = emitter;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Depth-counted emit suppression (mirrors canvasState._suppressRecordingDepth).
|
|
41
|
+
// While > 0, emitOperationEvent is a no-op so a meta-op (canvas.batch) can run
|
|
42
|
+
// many sub-ops without producing per-entry SSE frames, then emit ONE final
|
|
43
|
+
// layout frame itself. Both the `mutates` auto-emit and `ctx.emit` route through
|
|
44
|
+
// emitOperationEvent, so this covers both. Re-entrant-safe via the depth counter.
|
|
45
|
+
let suppressEmitDepth = 0;
|
|
46
|
+
|
|
47
|
+
function emitOperationEvent(event: string, payload: Record<string, unknown> = {}): void {
|
|
48
|
+
if (suppressEmitDepth > 0) return;
|
|
49
|
+
operationEventEmitter?.(event, payload);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True while operation SSE emits are being suppressed (inside a meta-op such as
|
|
53
|
+
* canvas.batch). Ops whose effect depends on a live SSE emit firing — e.g.
|
|
54
|
+
* mcpapp.open, whose canvas node is created as a side-effect of `ext-app-open` —
|
|
55
|
+
* use this to reject loudly instead of silently no-op'ing in a suppressed run. */
|
|
56
|
+
export function isEmitSuppressed(): boolean {
|
|
57
|
+
return suppressEmitDepth > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run `fn` with all operation SSE emits suppressed; restores depth on finally. */
|
|
61
|
+
export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T> {
|
|
62
|
+
suppressEmitDepth++;
|
|
63
|
+
try {
|
|
64
|
+
return await fn();
|
|
65
|
+
} finally {
|
|
66
|
+
suppressEmitDepth--;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const operationContext: OperationContext = { emit: emitOperationEvent };
|
|
71
|
+
|
|
72
|
+
export async function executeOperation(name: string, rawInput: unknown): Promise<unknown> {
|
|
73
|
+
const op = getOperation(name);
|
|
74
|
+
const result = await op.execute(rawInput, operationContext);
|
|
75
|
+
if (op.mutates) {
|
|
76
|
+
emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|