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.
Files changed (100) hide show
  1. package/CHANGELOG.md +461 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/index.js +82 -41
  4. package/dist/json-render/index.js +89 -334
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  6. package/dist/types/mcp/canvas-access.d.ts +12 -159
  7. package/dist/types/server/ax-context.d.ts +1 -1
  8. package/dist/types/server/ax-state-manager.d.ts +256 -0
  9. package/dist/types/server/ax-state.d.ts +29 -1
  10. package/dist/types/server/ax-wait.d.ts +23 -0
  11. package/dist/types/server/canvas-operations.d.ts +1 -12
  12. package/dist/types/server/canvas-state.d.ts +46 -14
  13. package/dist/types/server/html-surface.d.ts +7 -0
  14. package/dist/types/server/index.d.ts +66 -26
  15. package/dist/types/server/operations/composites.d.ts +121 -0
  16. package/dist/types/server/operations/http.d.ts +7 -0
  17. package/dist/types/server/operations/index.d.ts +8 -0
  18. package/dist/types/server/operations/invoker.d.ts +13 -0
  19. package/dist/types/server/operations/mcp.d.ts +15 -0
  20. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  21. package/dist/types/server/operations/ops/app.d.ts +33 -0
  22. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  23. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  24. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  25. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  26. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  27. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  28. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  29. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  30. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  31. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  32. package/dist/types/server/operations/ops/query.d.ts +2 -0
  33. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  34. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  35. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  36. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  37. package/dist/types/server/operations/registry.d.ts +15 -0
  38. package/dist/types/server/operations/types.d.ts +116 -0
  39. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  40. package/docs/RELEASE.md +5 -0
  41. package/docs/adr-001-bun-only-runtime.md +46 -0
  42. package/docs/api-stability.md +57 -0
  43. package/docs/ax-host-adapter-contract.md +65 -0
  44. package/docs/ax-state-contract.md +72 -0
  45. package/docs/http-api.md +34 -2
  46. package/docs/mcp.md +64 -11
  47. package/docs/plans/plan-005-operation-registry.md +84 -0
  48. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  49. package/docs/plans/plan-007-ax-domain.md +99 -0
  50. package/docs/plans/plan-008-registry-finish.md +91 -0
  51. package/docs/screenshot.png +0 -0
  52. package/docs/tech-debt-assessment-2026-06.md +90 -0
  53. package/package.json +3 -3
  54. package/skills/pmx-canvas/SKILL.md +233 -185
  55. package/skills/pmx-canvas/evals/evals.json +3 -3
  56. package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
  57. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
  58. package/src/cli/agent.ts +52 -31
  59. package/src/client/nodes/ExtAppFrame.tsx +73 -5
  60. package/src/client/nodes/HtmlNode.tsx +12 -3
  61. package/src/client/nodes/McpAppNode.tsx +12 -3
  62. package/src/json-render/renderer/index.tsx +3 -0
  63. package/src/mcp/canvas-access.ts +43 -774
  64. package/src/mcp/server.ts +190 -2001
  65. package/src/server/ax-context.ts +7 -1
  66. package/src/server/ax-state-manager.ts +808 -0
  67. package/src/server/ax-state.ts +89 -2
  68. package/src/server/ax-wait.ts +56 -0
  69. package/src/server/canvas-operations.ts +2 -328
  70. package/src/server/canvas-schema.ts +2 -2
  71. package/src/server/canvas-state.ts +140 -382
  72. package/src/server/html-surface.ts +49 -11
  73. package/src/server/index.ts +136 -192
  74. package/src/server/operations/composites.ts +355 -0
  75. package/src/server/operations/http.ts +103 -0
  76. package/src/server/operations/index.ts +65 -0
  77. package/src/server/operations/invoker.ts +87 -0
  78. package/src/server/operations/mcp.ts +221 -0
  79. package/src/server/operations/ops/annotation.ts +60 -0
  80. package/src/server/operations/ops/app.ts +447 -0
  81. package/src/server/operations/ops/ax-await.ts +216 -0
  82. package/src/server/operations/ops/ax-shared.ts +38 -0
  83. package/src/server/operations/ops/ax-state.ts +249 -0
  84. package/src/server/operations/ops/ax-timeline.ts +381 -0
  85. package/src/server/operations/ops/ax-work.ts +635 -0
  86. package/src/server/operations/ops/batch.ts +365 -0
  87. package/src/server/operations/ops/edges.ts +166 -0
  88. package/src/server/operations/ops/groups.ts +176 -0
  89. package/src/server/operations/ops/json-render.ts +691 -0
  90. package/src/server/operations/ops/nodes.ts +1047 -0
  91. package/src/server/operations/ops/query.ts +281 -0
  92. package/src/server/operations/ops/snapshots.ts +366 -0
  93. package/src/server/operations/ops/validate.ts +37 -0
  94. package/src/server/operations/ops/viewport.ts +219 -0
  95. package/src/server/operations/ops/webview.ts +339 -0
  96. package/src/server/operations/registry.ts +79 -0
  97. package/src/server/operations/types.ts +150 -0
  98. package/src/server/operations/webview-runner.ts +77 -0
  99. package/src/server/server.ts +253 -2170
  100. package/src/server/web-artifacts.ts +6 -2
@@ -0,0 +1,447 @@
1
+ /**
2
+ * External / built-content app operations (plan-008 Wave 4):
3
+ * mcpapp.open → canvas_open_mcp_app → POST /api/canvas/mcp-app/open
4
+ * diagram.open → canvas_add_diagram → POST /api/canvas/diagram
5
+ * webartifact.build → canvas_build_web_artifact → POST /api/canvas/web-artifact
6
+ *
7
+ * These were deferred in plan-005/006 as "poor fits" (stateful external MCP
8
+ * session + custom SSE; long-running build). On reflection they migrate cleanly:
9
+ * `executeOperation` is async (the long-running build fits — its "long-running"
10
+ * caveat is about MCP client timeouts, not registry fit), and their runtimes are
11
+ * server-independent DOMAIN modules (mcp-app-runtime, diagram-presets,
12
+ * web-artifacts), not server.ts. So the op handlers call those modules directly
13
+ * — no runner injection needed.
14
+ *
15
+ * SSE parity (these are NOT canvas node/edge state mutations, so `mutates:false`
16
+ * — the registry must NOT emit canvas-layout-update):
17
+ * - mcpapp.open / diagram.open emit `ext-app-open` + `ext-app-result` via
18
+ * ctx.emit, byte-identical to the legacy emitPrimaryWorkbenchEvent calls.
19
+ * - webartifact.build's node creation emits its own `canvas-layout-update` from
20
+ * inside web-artifacts.ts (the injected canvas-operations emitter), so the op
21
+ * must not re-emit.
22
+ *
23
+ * Local-vs-remote asymmetry (the only allowed unification — documented):
24
+ * mcpapp.open's node-precondition failures THROW an OperationError here (404 for
25
+ * a missing node, 400 for a non-ext-app node). Over HTTP that becomes
26
+ * `{ ok:false, error }` with the right status (matching the legacy handler's
27
+ * explicit 404/400 responses); over MCP it becomes a bare-message isError result.
28
+ * The legacy SDK threw a plain Error for the same case; the registry's
29
+ * OperationError carries the status so the HTTP surface is unchanged.
30
+ *
31
+ * This module must never import server.ts or index.ts.
32
+ */
33
+ import { z } from 'zod';
34
+ import { canvasState } from '../../canvas-state.js';
35
+ import {
36
+ closeMcpAppSession,
37
+ openMcpApp as openExternalMcpApp,
38
+ type ExternalMcpTransportConfig,
39
+ } from '../../mcp-app-runtime.js';
40
+ import {
41
+ buildExcalidrawOpenMcpAppInput,
42
+ ensureExcalidrawCheckpointId,
43
+ isExcalidrawCreateView,
44
+ } from '../../diagram-presets.js';
45
+ import { findCanvasExtAppNodeId } from '../../ext-app-lookup.js';
46
+ import {
47
+ buildWebArtifactOnCanvas,
48
+ resolveWorkspacePath,
49
+ type WebArtifactCanvasBuildResult,
50
+ } from '../../web-artifacts.js';
51
+ import { isEmitSuppressed } from '../registry.js';
52
+ import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
53
+ import { isRecord } from './nodes.js';
54
+
55
+ // ── shared open-mcp-app core ──────────────────────────────────
56
+
57
+ export interface OpenMcpAppCoreInput {
58
+ transport: ExternalMcpTransportConfig;
59
+ toolName: string;
60
+ toolArguments?: Record<string, unknown>;
61
+ nodeId?: string;
62
+ serverName?: string;
63
+ title?: string;
64
+ x?: number;
65
+ y?: number;
66
+ width?: number;
67
+ height?: number;
68
+ timeoutMs?: number;
69
+ }
70
+
71
+ export interface OpenMcpAppCoreResult {
72
+ ok: true;
73
+ id?: string;
74
+ nodeId: string | null;
75
+ toolCallId: string;
76
+ sessionId: string;
77
+ resourceUri: string;
78
+ }
79
+
80
+ /**
81
+ * Open an external MCP app: connect + call + read resource (openExternalMcpApp),
82
+ * close any prior session on an in-place node, emit `ext-app-open` +
83
+ * `ext-app-result`, then resolve the resulting canvas node id. This is the exact
84
+ * legacy SDK `openMcpApp` body, relocated; both the mcpapp.open op AND the SDK
85
+ * call it. The diagram.open op delegates here after building the Excalidraw input
86
+ * (the SSE pair fires ONCE — diagram.open does not re-emit).
87
+ */
88
+ export async function openMcpAppCore(
89
+ input: OpenMcpAppCoreInput,
90
+ ctx: OperationContext,
91
+ ): Promise<OpenMcpAppCoreResult> {
92
+ // The canvas node is created as a side-effect of the `ext-app-open` SSE event
93
+ // (syncEventToCanvasState). Inside a suppressed-emit run (canvas.batch) that
94
+ // event is dropped, so the node would never be created and the just-opened
95
+ // external session would leak with a null nodeId. Reject loudly BEFORE opening
96
+ // any session rather than silently corrupting. mcpapp.open / diagram.open are
97
+ // not batchable — call canvas_open_mcp_app / canvas_add_diagram (or canvas_app).
98
+ if (isEmitSuppressed()) {
99
+ throw new OperationError(
100
+ 'mcpapp.open / diagram.open cannot run inside canvas_batch: the canvas node is created from the ext-app-open SSE event, which batch suppresses. Call the op directly (canvas_open_mcp_app / canvas_add_diagram, or canvas_app).',
101
+ );
102
+ }
103
+ const targetNode = input.nodeId ? canvasState.getNode(input.nodeId) : undefined;
104
+ if (input.nodeId && !targetNode) {
105
+ throw new OperationError(`Node "${input.nodeId}" not found.`, 404);
106
+ }
107
+ if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
108
+ throw new OperationError(`Node "${input.nodeId}" is not an external app node.`);
109
+ }
110
+
111
+ const opened = await openExternalMcpApp({
112
+ transport: input.transport,
113
+ toolName: input.toolName,
114
+ ...(input.toolArguments ? { toolArguments: input.toolArguments } : {}),
115
+ ...(input.serverName ? { serverName: input.serverName } : {}),
116
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
117
+ });
118
+ const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
119
+ const previousSessionId = targetNode?.data.appSessionId;
120
+ if (typeof previousSessionId === 'string' && previousSessionId.trim().length > 0) {
121
+ closeMcpAppSession(previousSessionId);
122
+ }
123
+ const nodeIdSeed = input.nodeId ?? `ext-app-${toolCallId}`;
124
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
125
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
126
+ : opened.toolResult;
127
+ ctx.emit('ext-app-open', {
128
+ toolCallId,
129
+ nodeId: nodeIdSeed,
130
+ // Preserve an existing in-place node's title when no override is given (legacy
131
+ // runAndEmitOpenMcpApp fallback order) — otherwise an in-place update with no
132
+ // `title` would reset the node title to the external tool's name.
133
+ title: input.title ?? (targetNode?.data.title as string | undefined) ?? opened.tool.title ?? opened.tool.name,
134
+ html: opened.html,
135
+ toolInput: opened.toolInput,
136
+ serverName: opened.serverName,
137
+ toolName: opened.toolName,
138
+ appSessionId: opened.sessionId,
139
+ transportConfig: input.transport,
140
+ resourceUri: opened.resourceUri,
141
+ toolDefinition: opened.tool,
142
+ sessionStatus: 'ready',
143
+ sessionError: null,
144
+ ...(opened.resourceMeta ? { resourceMeta: opened.resourceMeta } : {}),
145
+ ...(typeof input.x === 'number' ? { x: input.x } : {}),
146
+ ...(typeof input.y === 'number' ? { y: input.y } : {}),
147
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
148
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
149
+ });
150
+ ctx.emit('ext-app-result', {
151
+ toolCallId,
152
+ nodeId: nodeIdSeed,
153
+ serverName: opened.serverName,
154
+ toolName: opened.toolName,
155
+ success: toolResult.isError !== true,
156
+ result: toolResult,
157
+ });
158
+ const nodeId = input.nodeId ?? findCanvasExtAppNodeId(toolCallId, {
159
+ getNode: (id) => canvasState.getNode(id),
160
+ listNodes: () => canvasState.getLayout().nodes,
161
+ });
162
+ return {
163
+ ok: true,
164
+ ...(nodeId ? { id: nodeId } : {}),
165
+ nodeId,
166
+ toolCallId,
167
+ sessionId: opened.sessionId,
168
+ resourceUri: opened.resourceUri,
169
+ };
170
+ }
171
+
172
+ /** Build the OpenMcpAppCoreInput from a raw HTTP/MCP arg object (mcpapp.open). */
173
+ function buildOpenMcpAppInput(body: Record<string, unknown>): OpenMcpAppCoreInput {
174
+ const transport = body.transport as ExternalMcpTransportConfig | undefined;
175
+ if (!isRecord(transport)) {
176
+ throw new OperationError('Missing valid transport or toolName.');
177
+ }
178
+ const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
179
+ if (!toolName) {
180
+ throw new OperationError('Missing valid transport or toolName.');
181
+ }
182
+ const toolArguments = isRecord(body.toolArguments) ? body.toolArguments : undefined;
183
+ const serverName = typeof body.serverName === 'string' && body.serverName.trim().length > 0
184
+ ? body.serverName.trim()
185
+ : undefined;
186
+ const title = typeof body.title === 'string' && body.title.trim().length > 0
187
+ ? body.title.trim()
188
+ : undefined;
189
+ const nodeId = typeof body.nodeId === 'string' && body.nodeId.trim().length > 0
190
+ ? body.nodeId.trim()
191
+ : undefined;
192
+ return {
193
+ transport,
194
+ toolName,
195
+ ...(toolArguments ? { toolArguments } : {}),
196
+ ...(serverName ? { serverName } : {}),
197
+ ...(title ? { title } : {}),
198
+ ...(nodeId ? { nodeId } : {}),
199
+ ...(typeof body.x === 'number' ? { x: body.x } : {}),
200
+ ...(typeof body.y === 'number' ? { y: body.y } : {}),
201
+ ...(typeof body.width === 'number' ? { width: body.width } : {}),
202
+ ...(typeof body.height === 'number' ? { height: body.height } : {}),
203
+ ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
204
+ };
205
+ }
206
+
207
+ // ── mcpapp.open ───────────────────────────────────────────────
208
+
209
+ const openMcpAppShape = {
210
+ toolName: z.string().describe('Tool name on the external MCP server'),
211
+ serverName: z.string().optional().describe('Optional display name for the external MCP server'),
212
+ toolArguments: z.record(z.string(), z.unknown()).optional().describe('Arguments passed to the external tool call'),
213
+ nodeId: z.string().optional().describe('Existing mcp-app node ID to update in place instead of creating a new node.'),
214
+ title: z.string().optional().describe('Optional canvas node title override'),
215
+ x: z.number().optional().describe('X position (auto-placed if omitted)'),
216
+ y: z.number().optional().describe('Y position (auto-placed if omitted)'),
217
+ width: z.number().optional().describe('Width in pixels (default: 720)'),
218
+ height: z.number().optional().describe('Height in pixels (default: 500)'),
219
+ timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for cold external app servers'),
220
+ transport: z.union([
221
+ z.object({
222
+ type: z.literal('stdio'),
223
+ command: z.string().describe('Executable used to start the external MCP server'),
224
+ args: z.array(z.string()).optional().describe('Arguments for the executable'),
225
+ cwd: z.string().optional().describe('Optional working directory'),
226
+ env: z.record(z.string(), z.string()).optional().describe('Optional environment overrides'),
227
+ }),
228
+ z.object({
229
+ type: z.literal('http'),
230
+ url: z.string().describe('Streamable HTTP MCP endpoint URL'),
231
+ headers: z.record(z.string(), z.string()).optional().describe('Optional HTTP headers'),
232
+ }),
233
+ ]).describe('How PMX Canvas should connect to the external MCP server'),
234
+ };
235
+ const openMcpAppSchema = z.looseObject(openMcpAppShape);
236
+
237
+ const openMcpAppOperation = defineOperation<z.infer<typeof openMcpAppSchema>, OpenMcpAppCoreResult>({
238
+ name: 'mcpapp.open',
239
+ mutates: false,
240
+ input: openMcpAppSchema,
241
+ inputShape: openMcpAppShape,
242
+ http: {
243
+ method: 'POST',
244
+ path: '/api/canvas/mcp-app/open',
245
+ },
246
+ mcp: {
247
+ toolName: 'canvas_open_mcp_app',
248
+ description:
249
+ 'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_add_diagram for the built-in Excalidraw preset.',
250
+ formatResult: (result) => ({
251
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
252
+ }),
253
+ },
254
+ handler: (input, ctx) => openMcpAppCore(buildOpenMcpAppInput(input), ctx),
255
+ });
256
+
257
+ // ── diagram.open (Excalidraw preset → mcpapp.open core) ───────
258
+
259
+ const diagramShape = {
260
+ elements: z.union([
261
+ z.string().describe('JSON array string of Excalidraw elements'),
262
+ z.array(z.record(z.string(), z.unknown())).describe('Array of Excalidraw elements'),
263
+ ]).describe('Excalidraw elements to render. See https://github.com/excalidraw/excalidraw-mcp for the element format.'),
264
+ nodeId: z.string().optional().describe('Existing Excalidraw mcp-app node ID to update in place instead of creating a new node.'),
265
+ title: z.string().optional().describe('Optional canvas node title override'),
266
+ x: z.number().optional().describe('X position (auto-placed if omitted)'),
267
+ y: z.number().optional().describe('Y position (auto-placed if omitted)'),
268
+ width: z.number().optional().describe('Width in pixels (default: 720)'),
269
+ height: z.number().optional().describe('Height in pixels (default: 500)'),
270
+ timeoutMs: z.number().optional().describe('Optional MCP request timeout in milliseconds for Excalidraw cold starts. Client-side MCP hosts may still enforce their own total request timeout.'),
271
+ };
272
+ const diagramSchema = z.looseObject(diagramShape);
273
+
274
+ const diagramOperation = defineOperation<z.infer<typeof diagramSchema>, OpenMcpAppCoreResult>({
275
+ name: 'diagram.open',
276
+ mutates: false,
277
+ input: diagramSchema,
278
+ inputShape: diagramShape,
279
+ http: {
280
+ method: 'POST',
281
+ path: '/api/canvas/diagram',
282
+ },
283
+ mcp: {
284
+ toolName: 'canvas_add_diagram',
285
+ description:
286
+ 'Draw a hand-drawn diagram on the canvas via the hosted Excalidraw MCP app. Pass an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows, text). The diagram opens inside an mcp-app node that supports fullscreen editing. For other MCP apps, use canvas_open_mcp_app.',
287
+ formatResult: (result) => ({
288
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
289
+ }),
290
+ },
291
+ handler: (input, ctx) => {
292
+ let built;
293
+ try {
294
+ built = buildExcalidrawOpenMcpAppInput(input);
295
+ } catch (error) {
296
+ throw new OperationError(error instanceof Error ? error.message : String(error));
297
+ }
298
+ // Delegate to the shared open core (the SSE pair fires once — diagram.open
299
+ // does not re-emit).
300
+ return openMcpAppCore({
301
+ transport: built.transport,
302
+ toolName: built.toolName,
303
+ toolArguments: built.toolArguments,
304
+ serverName: built.serverName,
305
+ ...(built.nodeId ? { nodeId: built.nodeId } : {}),
306
+ ...(built.title ? { title: built.title } : {}),
307
+ ...(typeof built.x === 'number' ? { x: built.x } : {}),
308
+ ...(typeof built.y === 'number' ? { y: built.y } : {}),
309
+ ...(typeof built.width === 'number' ? { width: built.width } : {}),
310
+ ...(typeof built.height === 'number' ? { height: built.height } : {}),
311
+ ...(typeof built.timeoutMs === 'number' ? { timeoutMs: built.timeoutMs } : {}),
312
+ }, ctx);
313
+ },
314
+ });
315
+
316
+ // ── webartifact.build ─────────────────────────────────────────
317
+
318
+ const webArtifactShape = {
319
+ title: z.string().describe('Artifact title used for default project and output paths'),
320
+ appTsx: z.string().describe('Contents for src/App.tsx'),
321
+ indexCss: z.string().optional().describe('Optional contents for src/index.css'),
322
+ mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
323
+ indexHtml: z.string().optional().describe('Optional contents for index.html'),
324
+ files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
325
+ deps: z.array(z.string()).optional().describe('Optional npm dependencies to install before bundling (e.g. ["recharts", "framer-motion@^11"]). Validated against npm-name format; flags and shell metacharacters are rejected.'),
326
+ projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
327
+ outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
328
+ openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
329
+ includeLogs: z.boolean().optional().describe('Include raw build stdout/stderr in the response (default false)'),
330
+ initScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
331
+ bundleScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
332
+ timeoutMs: z.number().optional().describe('Optional timeout in milliseconds for init and bundle commands'),
333
+ };
334
+ const webArtifactSchema = z.looseObject(webArtifactShape);
335
+
336
+ /** Shape the byte-identical web-artifact response envelope from the build result. */
337
+ function webArtifactEnvelope(
338
+ result: WebArtifactCanvasBuildResult,
339
+ includeLogs: boolean,
340
+ ): Record<string, unknown> {
341
+ return {
342
+ ok: true,
343
+ path: result.filePath,
344
+ bytes: result.fileSize,
345
+ projectPath: result.projectPath,
346
+ openedInCanvas: result.openedInCanvas,
347
+ startedAt: result.startedAt,
348
+ completedAt: result.completedAt,
349
+ durationMs: result.durationMs,
350
+ timeoutMs: result.timeoutMs,
351
+ // `id` is the canvas node id alias used by every other add-style response.
352
+ // It is only present when a canvas node was actually created (openInCanvas
353
+ // not explicitly disabled), so consumers can `'id' in response` to detect
354
+ // the build-only case.
355
+ ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
356
+ nodeId: result.nodeId,
357
+ url: result.url,
358
+ metadata: result.metadata,
359
+ logs: result.logs,
360
+ ...(includeLogs ? { stdout: result.stdout, stderr: result.stderr } : {}),
361
+ };
362
+ }
363
+
364
+ const webArtifactOperation = defineOperation<z.infer<typeof webArtifactSchema>, Record<string, unknown>>({
365
+ name: 'webartifact.build',
366
+ // mutates:false — web-artifacts.ts emits its own canvas-layout-update on node
367
+ // creation (the injected canvas-operations emitter); the registry must NOT
368
+ // double-emit.
369
+ mutates: false,
370
+ input: webArtifactSchema,
371
+ inputShape: webArtifactShape,
372
+ http: {
373
+ method: 'POST',
374
+ path: '/api/canvas/web-artifact',
375
+ },
376
+ mcp: {
377
+ toolName: 'canvas_build_web_artifact',
378
+ description:
379
+ 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. MCP callers pass source content in appTsx (the CLI app-file flag reads a file before calling this path). Builds can exceed default 60s MCP client timeouts on cold workspaces; set a long client timeout or retry with the same projectPath/outputPath if the client times out. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
380
+ // formatResult receives the SERIALIZED wire envelope. Legacy
381
+ // canvas_build_web_artifact stringified a subset; the wire envelope IS that
382
+ // subset, so re-stringify it verbatim.
383
+ formatResult: (result) => ({
384
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
385
+ }),
386
+ },
387
+ handler: async (input) => {
388
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
389
+ const appTsx = typeof input.appTsx === 'string' ? input.appTsx : '';
390
+ if (!title || !appTsx) {
391
+ throw new OperationError('Missing required fields: title, appTsx.');
392
+ }
393
+
394
+ const files: Record<string, string> = {};
395
+ if (isRecord(input.files)) {
396
+ for (const [pathKey, value] of Object.entries(input.files)) {
397
+ if (typeof value === 'string') files[pathKey] = value;
398
+ }
399
+ }
400
+
401
+ const includeLogs = input.includeLogs === true;
402
+ try {
403
+ // web-artifacts.ts emits its own canvas-layout-update on node creation (the
404
+ // injected canvas-operations emitter), so this op stays mutates:false and
405
+ // does not emit. The SDK buildWebArtifact calls buildWebArtifactOnCanvas
406
+ // directly with the same runtime.
407
+ const result = await buildWebArtifactOnCanvas({
408
+ title,
409
+ appTsx,
410
+ ...(typeof input.indexCss === 'string' ? { indexCss: input.indexCss } : {}),
411
+ ...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
412
+ ...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
413
+ ...(Object.keys(files).length > 0 ? { files } : {}),
414
+ // Sandbox projectPath/outputPath to the workspace on BOTH surfaces (the
415
+ // legacy HTTP handler used resolveWorkspacePath; the MCP tool used its
416
+ // own safeWorkspacePath — both enforce containment). resolveWorkspacePath
417
+ // resolves against the active canvas workspace root.
418
+ ...(typeof input.projectPath === 'string'
419
+ ? { projectPath: resolveWorkspacePath(input.projectPath) }
420
+ : {}),
421
+ ...(typeof input.outputPath === 'string'
422
+ ? { outputPath: resolveWorkspacePath(input.outputPath) }
423
+ : {}),
424
+ // Script-path overrides are honored only when contained inside the
425
+ // workspace (enforced by resolveTrustedScriptPath in
426
+ // executeWebArtifactBuild), so they cannot point at an arbitrary host
427
+ // script for bash execution.
428
+ ...(typeof input.initScriptPath === 'string' ? { initScriptPath: input.initScriptPath } : {}),
429
+ ...(typeof input.bundleScriptPath === 'string' ? { bundleScriptPath: input.bundleScriptPath } : {}),
430
+ ...(Array.isArray(input.deps)
431
+ ? { deps: input.deps.filter((dep): dep is string => typeof dep === 'string') }
432
+ : {}),
433
+ ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
434
+ ...(typeof input.openInCanvas === 'boolean' ? { openInCanvas: input.openInCanvas } : {}),
435
+ });
436
+ return webArtifactEnvelope(result, includeLogs);
437
+ } catch (error) {
438
+ throw new OperationError(error instanceof Error ? error.message : String(error));
439
+ }
440
+ },
441
+ });
442
+
443
+ export const appOperations: Operation[] = [
444
+ openMcpAppOperation,
445
+ diagramOperation,
446
+ webArtifactOperation,
447
+ ];
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Plan-007 Slice B (wave 4) operations: the three long-poll gate READS —
3
+ * ax.approval.get / canvas_await_approval
4
+ * ax.elicitation.get / canvas_await_elicitation
5
+ * ax.mode.get / canvas_await_mode
6
+ *
7
+ * Report primitive D ("gates that actually gate"): each blocks until the gate
8
+ * leaves `pending` or a timeout elapses, then returns `{ <key>, pending }`.
9
+ * These are READS — `mutates: false` and NO `ctx.emit` (resolution events are
10
+ * emitted by the resolve/respond ops in ax-work.ts).
11
+ *
12
+ * The HANDLER performs the wait via `waitForAxResolution` (executeOperation is
13
+ * async). All three legacy `isResolved` predicates are `status !== 'pending'`
14
+ * (HTTP handlers + SDK PmxCanvas await* agree — the elicitation `answered`/
15
+ * `cancelled` and mode `approved`/`rejected` states all satisfy `!= pending`).
16
+ *
17
+ * timeoutMs normalization (one field the handler reads, sourced per surface):
18
+ * - HTTP: a custom `readInput` parses `?waitMs` (string→number) into `timeoutMs`.
19
+ * Absent / non-positive ⇒ `0` (a plain immediate read), matching the legacy
20
+ * `parseAxWaitMs`. waitForAxResolution clamps to [0, 120000].
21
+ * - MCP: `buildInput` passes `timeoutMs` through, defaulting an OMITTED value to
22
+ * 30000 and clamping to [0, 120000] — byte-identical to the legacy await tools
23
+ * (`timeoutMs ?? 30000` in PmxCanvas) plus the tool schema's `.min(0).max(120000)`.
24
+ *
25
+ * Missing gate (read returns null): handled WITHOUT throwing. Unlike the wave-2
26
+ * resolve/respond ops, "not found" is a normal await result, not an error — the
27
+ * legacy MCP tools returned a SUCCESS-shaped `{ ok:false, <key>:null, pending:false }`
28
+ * (parsed as JSON by the caller), and the legacy HTTP route returned 404. So:
29
+ * - The handler always returns `{ ok: value !== null, <key>: value, pending }`.
30
+ * - `http.status` maps `ok:false` → 404, preserving the legacy 404 STATUS (the
31
+ * server-api long-poll test asserts `missing.status === 404`).
32
+ * - MCP `formatResult` re-serializes the same body; an in-process (Local) MCP
33
+ * call never throws, so `{ <key>:null }` round-trips as JSON, matching legacy.
34
+ *
35
+ * Wire-body reconciliation (one op = one wire body; documented, same class as
36
+ * the wave-1 `ax.get` aggregate / wave-3 delivery broadening):
37
+ * - HTTP missing body changes from `{ ok:false, error:'<gate> not found.' }` to
38
+ * `{ ok:false, <key>:null, pending:false }` (status stays 404). No test asserts
39
+ * the legacy error body — server-api only reads `missing.status`.
40
+ *
41
+ * Documented behavior change (accepted long-poll tradeoff, plan-007 sub-slice):
42
+ * - The legacy HTTP handler passed `req.signal` to waitForAxResolution, so a
43
+ * client disconnect aborted the wait early. The registry handler has no access
44
+ * to the HTTP Request, so the wait now runs to its (≤120s, subscription-based,
45
+ * cheap) timeout instead of aborting on disconnect. Resolution detection,
46
+ * timeout, and the `{ value, pending }` result are otherwise identical.
47
+ * - Remote-MCP only (daemon mode, untested for these reads): the legacy
48
+ * RemoteCanvasAccess.await* methods special-cased a 404 → `{ <key>:null }`. The
49
+ * generic HttpOperationInvoker throws on 404, so an await-on-missing over a
50
+ * remote transport now surfaces as an isError result rather than a null body.
51
+ * The in-process (Local) MCP path — the only path the await tests exercise —
52
+ * is unaffected.
53
+ *
54
+ * This module must never import server.ts or index.ts.
55
+ */
56
+ import { z } from 'zod';
57
+ import { canvasState } from '../../canvas-state.js';
58
+ import type { PmxAxApprovalGate, PmxAxElicitation, PmxAxModeRequest } from '../../ax-state.js';
59
+ import { waitForAxResolution, AX_WAIT_MAX_MS } from '../../ax-wait.js';
60
+ import { defineOperation, type Operation } from '../types.js';
61
+ import { isRecord } from './nodes.js';
62
+
63
+ /** Legacy MCP/SDK default block when timeoutMs is omitted (0 = immediate read). */
64
+ const AX_AWAIT_DEFAULT_MS = 30000;
65
+
66
+ /** Coerce a raw timeout (MCP number or HTTP query string) to [0, AX_WAIT_MAX_MS]. */
67
+ function clampTimeoutMs(value: unknown): number {
68
+ const ms = Number(value);
69
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
70
+ return Math.min(ms, AX_WAIT_MAX_MS);
71
+ }
72
+
73
+ /** HTTP `?waitMs` → `timeoutMs`; absent/non-positive ⇒ 0 (plain read). Matches parseAxWaitMs. */
74
+ function readWaitMsInput(_req: Request, params: Record<string, string>, url: URL): Record<string, unknown> {
75
+ return { ...params, timeoutMs: clampTimeoutMs(url.searchParams.get('waitMs') ?? '') };
76
+ }
77
+
78
+ /** MCP buildInput: forward id + timeoutMs (omitted ⇒ 30000), clamped to [0, max]. */
79
+ function buildAwaitInput(input: Record<string, unknown>): Record<string, unknown> {
80
+ return {
81
+ ...(typeof input.id === 'string' ? { id: input.id } : {}),
82
+ timeoutMs: input.timeoutMs === undefined ? AX_AWAIT_DEFAULT_MS : clampTimeoutMs(input.timeoutMs),
83
+ };
84
+ }
85
+
86
+ const awaitInputShape = {
87
+ id: z.string().optional().catch(undefined).describe('The gate id.'),
88
+ timeoutMs: z.unknown().optional().describe('Max ms to block (0 = immediate read; capped at 120000).'),
89
+ };
90
+
91
+ const awaitInputSchema = z.looseObject(awaitInputShape);
92
+
93
+ const awaitMcpExtraShape = {
94
+ id: z.string(),
95
+ timeoutMs: z.number().int().min(0).max(120000).optional().describe('Max ms to block (default 30000; 0 = immediate read; capped at 120000).'),
96
+ };
97
+
98
+ // ── ax.approval.get (canvas_await_approval) ───────────────────
99
+
100
+ const axApprovalGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Record<string, unknown>>({
101
+ name: 'ax.approval.get',
102
+ mutates: false,
103
+ input: awaitInputSchema,
104
+ inputShape: awaitInputShape,
105
+ http: {
106
+ method: 'GET',
107
+ path: '/api/canvas/ax/approval/:id',
108
+ readInput: readWaitMsInput,
109
+ status: (result) => (isRecord(result) && result.ok === false ? 404 : 200),
110
+ },
111
+ mcp: {
112
+ toolName: 'canvas_await_approval',
113
+ description: 'Block until an approval gate resolves (the human approves/rejects it in the browser) or the timeout elapses — primitive D, gates that actually gate. timeoutMs 0 = read immediately without waiting. Returns { approvalGate, pending } (pending=true → still unresolved after the wait).',
114
+ extraShape: awaitMcpExtraShape,
115
+ buildInput: buildAwaitInput,
116
+ formatResult: (result) => {
117
+ const body = isRecord(result) ? result : {};
118
+ const approvalGate = (body.approvalGate ?? null) as PmxAxApprovalGate | null;
119
+ const pending = body.pending === true;
120
+ return {
121
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: approvalGate !== null, approvalGate, pending }) }],
122
+ };
123
+ },
124
+ },
125
+ handler: async (input) => {
126
+ const id = typeof input.id === 'string' ? input.id : '';
127
+ const { value, pending } = await waitForAxResolution<PmxAxApprovalGate>({
128
+ read: () => canvasState.getApproval(id),
129
+ isResolved: (g) => g.status !== 'pending',
130
+ timeoutMs: clampTimeoutMs(input.timeoutMs),
131
+ });
132
+ return { ok: value !== null, approvalGate: value, pending } as unknown as Record<string, unknown>;
133
+ },
134
+ });
135
+
136
+ // ── ax.elicitation.get (canvas_await_elicitation) ─────────────
137
+
138
+ const axElicitationGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Record<string, unknown>>({
139
+ name: 'ax.elicitation.get',
140
+ mutates: false,
141
+ input: awaitInputSchema,
142
+ inputShape: awaitInputShape,
143
+ http: {
144
+ method: 'GET',
145
+ path: '/api/canvas/ax/elicitation/:id',
146
+ readInput: readWaitMsInput,
147
+ status: (result) => (isRecord(result) && result.ok === false ? 404 : 200),
148
+ },
149
+ mcp: {
150
+ toolName: 'canvas_await_elicitation',
151
+ description: 'Block until an elicitation is answered (the human responds in the browser) or the timeout elapses — primitive D. timeoutMs 0 = read immediately. Returns { elicitation, pending }.',
152
+ extraShape: awaitMcpExtraShape,
153
+ buildInput: buildAwaitInput,
154
+ formatResult: (result) => {
155
+ const body = isRecord(result) ? result : {};
156
+ const elicitation = (body.elicitation ?? null) as PmxAxElicitation | null;
157
+ const pending = body.pending === true;
158
+ return {
159
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: elicitation !== null, elicitation, pending }) }],
160
+ };
161
+ },
162
+ },
163
+ handler: async (input) => {
164
+ const id = typeof input.id === 'string' ? input.id : '';
165
+ const { value, pending } = await waitForAxResolution<PmxAxElicitation>({
166
+ read: () => canvasState.getElicitation(id),
167
+ isResolved: (e) => e.status !== 'pending',
168
+ timeoutMs: clampTimeoutMs(input.timeoutMs),
169
+ });
170
+ return { ok: value !== null, elicitation: value, pending } as unknown as Record<string, unknown>;
171
+ },
172
+ });
173
+
174
+ // ── ax.mode.get (canvas_await_mode) ───────────────────────────
175
+
176
+ const axModeGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Record<string, unknown>>({
177
+ name: 'ax.mode.get',
178
+ mutates: false,
179
+ input: awaitInputSchema,
180
+ inputShape: awaitInputShape,
181
+ http: {
182
+ method: 'GET',
183
+ path: '/api/canvas/ax/mode/:id',
184
+ readInput: readWaitMsInput,
185
+ status: (result) => (isRecord(result) && result.ok === false ? 404 : 200),
186
+ },
187
+ mcp: {
188
+ toolName: 'canvas_await_mode',
189
+ description: 'Block until a mode request resolves (approved/rejected in the browser) or the timeout elapses — primitive D. timeoutMs 0 = read immediately. Returns { modeRequest, pending }.',
190
+ extraShape: awaitMcpExtraShape,
191
+ buildInput: buildAwaitInput,
192
+ formatResult: (result) => {
193
+ const body = isRecord(result) ? result : {};
194
+ const modeRequest = (body.modeRequest ?? null) as PmxAxModeRequest | null;
195
+ const pending = body.pending === true;
196
+ return {
197
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: modeRequest !== null, modeRequest, pending }) }],
198
+ };
199
+ },
200
+ },
201
+ handler: async (input) => {
202
+ const id = typeof input.id === 'string' ? input.id : '';
203
+ const { value, pending } = await waitForAxResolution<PmxAxModeRequest>({
204
+ read: () => canvasState.getModeRequest(id),
205
+ isResolved: (m) => m.status !== 'pending',
206
+ timeoutMs: clampTimeoutMs(input.timeoutMs),
207
+ });
208
+ return { ok: value !== null, modeRequest: value, pending } as unknown as Record<string, unknown>;
209
+ },
210
+ });
211
+
212
+ export const axAwaitOperations: Operation[] = [
213
+ axApprovalGetOperation,
214
+ axElicitationGetOperation,
215
+ axModeGetOperation,
216
+ ];