pmx-canvas 0.4.5 → 0.4.7
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 +118 -0
- package/Readme.md +76 -36
- package/dist/canvas/global.css +169 -3
- package/dist/canvas/index.js +112 -87
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +18 -1
- package/dist/types/server/canvas-validation.d.ts +28 -1
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +31 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +29 -0
- package/docs/mcp.md +13 -1
- package/docs/node-types.md +47 -1
- package/docs/screenshot.png +0 -0
- package/package.json +5 -4
- package/skills/pmx-canvas/SKILL.md +123 -25
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/shared.ts +35 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +12 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +32 -1
- package/src/client/theme/global.css +169 -3
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +305 -4
- package/src/server/canvas-operations.ts +83 -26
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +46 -10
- package/src/server/canvas-validation.ts +88 -0
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +15 -2
- package/src/server/server.ts +154 -6
- package/src/server/web-artifacts.ts +109 -0
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/node-sizes.ts +52 -0
- package/src/shared/surface.ts +2 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -211,6 +211,17 @@ function escapeSurfaceHtml(value: string): string {
|
|
|
211
211
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Author-HTML fragment for a `mermaid` node surface: the HTML-escaped diagram
|
|
216
|
+
* source in a hidden `<pre>` the entry script reads back via textContent, plus
|
|
217
|
+
* the separately-built renderer bundle (dist/canvas/mermaid-entry.js — kept out
|
|
218
|
+
* of the main SPA bundle). Escaping is mandatory: the source is arbitrary node
|
|
219
|
+
* data and must not be parsed as markup.
|
|
220
|
+
*/
|
|
221
|
+
export function buildMermaidSurfaceHtml(source: string): string {
|
|
222
|
+
return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre><script src="/canvas/mermaid-entry.js"></script>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
214
225
|
export interface HtmlSurfaceOptions {
|
|
215
226
|
theme: SurfaceTheme;
|
|
216
227
|
/**
|
package/src/server/index.ts
CHANGED
|
@@ -542,15 +542,22 @@ export class PmxCanvas extends EventEmitter {
|
|
|
542
542
|
nodeIds?: string[];
|
|
543
543
|
data?: Record<string, unknown> | null;
|
|
544
544
|
},
|
|
545
|
-
options?: { source?: PmxAxSource },
|
|
545
|
+
options?: { source?: PmxAxSource; agentId?: string | null },
|
|
546
546
|
): PmxAxEvent {
|
|
547
|
-
const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk' });
|
|
547
|
+
const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk', agentId: options?.agentId });
|
|
548
548
|
emitPrimaryWorkbenchEvent('ax-event-created', { event });
|
|
549
549
|
return event;
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
sendSteering(
|
|
553
|
-
|
|
552
|
+
sendSteering(
|
|
553
|
+
message: string,
|
|
554
|
+
options?: { source?: PmxAxSource; agentId?: string | null; target?: string | null },
|
|
555
|
+
): PmxAxSteeringMessage {
|
|
556
|
+
const steering = canvasState.recordSteeringMessage(message, {
|
|
557
|
+
source: options?.source ?? 'sdk',
|
|
558
|
+
agentId: options?.agentId,
|
|
559
|
+
target: options?.target,
|
|
560
|
+
});
|
|
554
561
|
emitPrimaryWorkbenchEvent('ax-event-created', { steering });
|
|
555
562
|
return steering;
|
|
556
563
|
}
|
|
@@ -604,7 +611,13 @@ export class PmxCanvas extends EventEmitter {
|
|
|
604
611
|
}
|
|
605
612
|
|
|
606
613
|
addWorkItem(
|
|
607
|
-
input: {
|
|
614
|
+
input: {
|
|
615
|
+
title: string;
|
|
616
|
+
status?: PmxAxWorkItemStatus;
|
|
617
|
+
detail?: string | null;
|
|
618
|
+
nodeIds?: string[];
|
|
619
|
+
agentId?: string | null;
|
|
620
|
+
},
|
|
608
621
|
options?: { source?: PmxAxSource },
|
|
609
622
|
): PmxAxWorkItem {
|
|
610
623
|
const workItem = canvasState.addWorkItem(input, { source: options?.source ?? 'sdk' });
|
|
@@ -614,7 +627,13 @@ export class PmxCanvas extends EventEmitter {
|
|
|
614
627
|
|
|
615
628
|
updateWorkItem(
|
|
616
629
|
id: string,
|
|
617
|
-
patch: {
|
|
630
|
+
patch: {
|
|
631
|
+
title?: string;
|
|
632
|
+
status?: PmxAxWorkItemStatus;
|
|
633
|
+
detail?: string | null;
|
|
634
|
+
nodeIds?: string[];
|
|
635
|
+
agentId?: string | null;
|
|
636
|
+
},
|
|
618
637
|
options?: { source?: PmxAxSource },
|
|
619
638
|
): PmxAxWorkItem | null {
|
|
620
639
|
const workItem = canvasState.updateWorkItem(id, patch, { source: options?.source ?? 'sdk' });
|
|
@@ -156,14 +156,15 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
|
|
|
156
156
|
{
|
|
157
157
|
toolName: 'canvas_render',
|
|
158
158
|
description:
|
|
159
|
-
'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph).',
|
|
160
|
-
actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph',
|
|
159
|
+
'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph); "workboard" materializes a live AX work-item board (a json-render node with a column per status) that auto-refreshes whenever work items change — idempotent, rebuilding the existing board in place.',
|
|
160
|
+
actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph | workboard',
|
|
161
161
|
actions: {
|
|
162
162
|
'describe-schema': 'schema.describe',
|
|
163
163
|
validate: 'spec.validate',
|
|
164
164
|
'add-json-render': 'jsonrender.add',
|
|
165
165
|
'stream-json-render': 'jsonrender.stream',
|
|
166
166
|
'add-graph': 'graph.add',
|
|
167
|
+
workboard: 'render.workboard',
|
|
167
168
|
},
|
|
168
169
|
},
|
|
169
170
|
{
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
* This module must never import server.ts or index.ts.
|
|
46
46
|
*/
|
|
47
47
|
import { z } from 'zod';
|
|
48
|
+
// Side-effect import: wires the work-item change listener (live workboard refresh
|
|
49
|
+
// + materialized-flow loop advance). Work items are this module's domain, and the
|
|
50
|
+
// ops registry loads it on every entry path (HTTP, MCP, SDK), so the flow loop is
|
|
51
|
+
// armed wherever work items can change.
|
|
52
|
+
import '../../ax-flow-loop.js';
|
|
48
53
|
import { canvasState } from '../../canvas-state.js';
|
|
49
54
|
import type {
|
|
50
55
|
PmxAxMode,
|
|
@@ -375,6 +375,14 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
|
|
|
375
375
|
// ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
|
|
376
376
|
const envelope = await runWithSuppressedEmits(() => runBatch(operations));
|
|
377
377
|
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
378
|
+
// The suppression window also swallows the pins op's context-pins-changed
|
|
379
|
+
// frame, leaving the client's pin state (menus, indicators) stale until
|
|
380
|
+
// reload (0.4.5 report Finding X). If the batch touched pins, follow the
|
|
381
|
+
// layout frame with ONE final pins frame carrying the end state.
|
|
382
|
+
if (operations.some((entry) => entry.op.startsWith('pin.'))) {
|
|
383
|
+
const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
|
|
384
|
+
ctx.emit('context-pins-changed', { count: nodeIds.length, nodeIds });
|
|
385
|
+
}
|
|
378
386
|
return envelope;
|
|
379
387
|
},
|
|
380
388
|
});
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from '../../canvas-operations.js';
|
|
40
40
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from '../../canvas-schema.js';
|
|
41
41
|
import { isHtmlPrimitiveKind } from '../../html-primitives.js';
|
|
42
|
+
import { renderWorkboard } from '../../workboard.js';
|
|
42
43
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
43
44
|
import {
|
|
44
45
|
buildNodeResponse,
|
|
@@ -311,6 +312,48 @@ const jsonRenderStreamOperation = defineOperation<z.infer<typeof jsonRenderStrea
|
|
|
311
312
|
// Wire shape is `{ id, url, ok, applied, ... }` — handler output verbatim.
|
|
312
313
|
});
|
|
313
314
|
|
|
315
|
+
// ── render.workboard ──────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
const workboardRenderShape = {
|
|
318
|
+
intentId: z
|
|
319
|
+
.string()
|
|
320
|
+
.optional()
|
|
321
|
+
.catch(undefined)
|
|
322
|
+
.describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
323
|
+
x: z.number().optional().catch(undefined).describe('Optional X position (only used when creating the board)'),
|
|
324
|
+
y: z.number().optional().catch(undefined).describe('Optional Y position (only used when creating the board)'),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const workboardRenderSchema = z.looseObject(workboardRenderShape);
|
|
328
|
+
|
|
329
|
+
const workboardRenderOperation = defineOperation<
|
|
330
|
+
z.infer<typeof workboardRenderSchema>,
|
|
331
|
+
ReturnType<typeof renderWorkboard>
|
|
332
|
+
>({
|
|
333
|
+
name: 'render.workboard',
|
|
334
|
+
mutates: true,
|
|
335
|
+
input: workboardRenderSchema,
|
|
336
|
+
inputShape: workboardRenderShape,
|
|
337
|
+
http: {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
path: '/api/canvas/workboard',
|
|
340
|
+
},
|
|
341
|
+
mcp: {
|
|
342
|
+
toolName: 'canvas_render_workboard',
|
|
343
|
+
description:
|
|
344
|
+
'Materialize a live work-item board: a json-render node with one column per status (todo → in-progress → blocked → done → cancelled) showing every AX work item as a card (title, agentId chip, detail). Idempotent: rebuilds the existing board node in place when one exists, otherwise creates it. The board auto-refreshes whenever work items change.',
|
|
345
|
+
},
|
|
346
|
+
handler: (input) => {
|
|
347
|
+
const body: Record<string, unknown> = input;
|
|
348
|
+
const x = pickFiniteNumber(body, 'x');
|
|
349
|
+
const y = pickFiniteNumber(body, 'y');
|
|
350
|
+
return renderWorkboard({
|
|
351
|
+
...(x !== undefined ? { x } : {}),
|
|
352
|
+
...(y !== undefined ? { y } : {}),
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
314
357
|
// ── graph.add ─────────────────────────────────────────────────
|
|
315
358
|
|
|
316
359
|
const graphAddShape = {
|
|
@@ -829,6 +872,7 @@ const specValidateOperation = defineOperation<z.infer<typeof specValidateSchema>
|
|
|
829
872
|
export const jsonRenderOperations: Operation[] = [
|
|
830
873
|
jsonRenderAddOperation,
|
|
831
874
|
jsonRenderStreamOperation,
|
|
875
|
+
workboardRenderOperation,
|
|
832
876
|
graphAddOperation,
|
|
833
877
|
schemaDescribeOperation,
|
|
834
878
|
specValidateOperation,
|
|
@@ -28,7 +28,12 @@ import {
|
|
|
28
28
|
LEDGER_NODE_DEFAULT_SIZE,
|
|
29
29
|
} from '../../canvas-operations.js';
|
|
30
30
|
import { normalizeNodeAxCapabilities } from '../../ax-interaction.js';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
buildHtmlPrimitive,
|
|
33
|
+
getHtmlPrimitiveDescriptor,
|
|
34
|
+
getHtmlPrimitiveSemanticMetadata,
|
|
35
|
+
isHtmlPrimitiveKind,
|
|
36
|
+
} from '../../html-primitives.js';
|
|
32
37
|
import { closeMcpAppSession } from '../../mcp-app-runtime.js';
|
|
33
38
|
import {
|
|
34
39
|
getCanvasNodeTitle,
|
|
@@ -55,6 +60,8 @@ export const NODE_TYPES = [
|
|
|
55
60
|
'ledger',
|
|
56
61
|
'trace',
|
|
57
62
|
'file',
|
|
63
|
+
'diff',
|
|
64
|
+
'mermaid',
|
|
58
65
|
'image',
|
|
59
66
|
'mcp-app',
|
|
60
67
|
'webpage',
|
|
@@ -74,6 +81,10 @@ export function defaultNodeSize(type: string): { width: number; height: number }
|
|
|
74
81
|
// a useful snippet — the generic default rendered as a content-less bar
|
|
75
82
|
// (0.4.4 portal feedback).
|
|
76
83
|
return { width: 520, height: 360 };
|
|
84
|
+
case 'diff':
|
|
85
|
+
return { width: 640, height: 420 };
|
|
86
|
+
case 'mermaid':
|
|
87
|
+
return { width: 640, height: 460 };
|
|
77
88
|
case 'markdown':
|
|
78
89
|
return MARKDOWN_NODE_DEFAULT_SIZE;
|
|
79
90
|
case 'mcp-app':
|
|
@@ -625,6 +636,14 @@ function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
|
|
|
625
636
|
throw new OperationError(error instanceof Error ? error.message : String(error));
|
|
626
637
|
}
|
|
627
638
|
const geometry = resolveCreateGeometry(body);
|
|
639
|
+
// `html` is AX-opt-in, so an emitting primitive is inert without capabilities:
|
|
640
|
+
// apply the ones its descriptor declares. An explicit caller value wins (both are
|
|
641
|
+
// clamped to the `html` type ceiling by resolveNodeAxCapabilities). The declared
|
|
642
|
+
// set is copied — the descriptor is a module constant shared by every node.
|
|
643
|
+
const declared = getHtmlPrimitiveDescriptor(built.kind).axCapabilities;
|
|
644
|
+
const axCapabilities =
|
|
645
|
+
normalizeNodeAxCapabilities(body.axCapabilities) ??
|
|
646
|
+
(declared ? { enabled: declared.enabled, allowed: [...declared.allowed] } : undefined);
|
|
628
647
|
const { node } = addCanvasNode({
|
|
629
648
|
type: 'html',
|
|
630
649
|
title: built.title,
|
|
@@ -635,6 +654,7 @@ function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
|
|
|
635
654
|
description: built.summary,
|
|
636
655
|
agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
|
|
637
656
|
...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
|
|
657
|
+
...(axCapabilities ? { axCapabilities } : {}),
|
|
638
658
|
...getHtmlPrimitiveSemanticMetadata(built.data),
|
|
639
659
|
},
|
|
640
660
|
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
@@ -783,7 +803,7 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
|
|
|
783
803
|
mcp: {
|
|
784
804
|
toolName: 'canvas_add_node',
|
|
785
805
|
description:
|
|
786
|
-
'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
|
|
806
|
+
'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, diff, mermaid, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
|
|
787
807
|
extraShape: {
|
|
788
808
|
type: z
|
|
789
809
|
.enum([
|
|
@@ -793,6 +813,8 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
|
|
|
793
813
|
'ledger',
|
|
794
814
|
'trace',
|
|
795
815
|
'file',
|
|
816
|
+
'diff',
|
|
817
|
+
'mermaid',
|
|
796
818
|
'image',
|
|
797
819
|
'webpage',
|
|
798
820
|
'mcp-app',
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { canvasState } from '../../canvas-state.js';
|
|
17
|
-
import { arrangeCanvasNodes, clearCanvas, fitCanvasView } from '../../canvas-operations.js';
|
|
17
|
+
import { arrangeCanvasNodes, clearCanvas, fitCanvasView, setClientViewportSize } from '../../canvas-operations.js';
|
|
18
18
|
import { validateCanvasLayout } from '../../canvas-validation.js';
|
|
19
19
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
20
20
|
import { closeNodeAppSession, isRecord } from './nodes.js';
|
|
@@ -121,7 +121,14 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
|
|
|
121
121
|
if (!node) throw new OperationError(`Node "${nodeId}" not found.`, 404);
|
|
122
122
|
const noPan = body.noPan === true;
|
|
123
123
|
if (!noPan) {
|
|
124
|
-
|
|
124
|
+
// The viewport is a SCREEN-space translate: screen = world * scale + v
|
|
125
|
+
// (CanvasViewport's matrix(scale,0,0,scale,x,y)), the same convention
|
|
126
|
+
// fitCanvasView and the client's focusNode use. So park the node's
|
|
127
|
+
// top-left at a fixed screen-space margin — 64/96 screen px, which
|
|
128
|
+
// clears the 44px floating toolbar at ANY zoom (0.4.5 report Finding Z:
|
|
129
|
+
// the old world-space margin shrank below the toolbar at low scales).
|
|
130
|
+
const scale = canvasState.viewport.scale || 1;
|
|
131
|
+
canvasState.setViewport({ x: 64 - node.position.x * scale, y: 96 - node.position.y * scale });
|
|
125
132
|
} else {
|
|
126
133
|
const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
|
|
127
134
|
canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
|
|
@@ -221,6 +228,8 @@ const viewportSetShape = {
|
|
|
221
228
|
y: z.unknown().optional().describe('Viewport y offset'),
|
|
222
229
|
scale: z.unknown().optional().describe('Viewport zoom scale'),
|
|
223
230
|
recordHistory: z.unknown().optional().describe('Pass false to skip the undo-history entry'),
|
|
231
|
+
clientWidth: z.unknown().optional().describe("The reporting client's window width in CSS pixels"),
|
|
232
|
+
clientHeight: z.unknown().optional().describe("The reporting client's window height in CSS pixels"),
|
|
224
233
|
};
|
|
225
234
|
|
|
226
235
|
const viewportSetSchema = z.looseObject(viewportSetShape);
|
|
@@ -237,6 +246,10 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
|
|
|
237
246
|
// Legacy wire: emits canvas-viewport-update (never a layout update).
|
|
238
247
|
handler: (input, ctx) => {
|
|
239
248
|
const body: Record<string, unknown> = input;
|
|
249
|
+
// The browser piggybacks its real window size here so a later agent `fit`
|
|
250
|
+
// sizes to the human's actual window instead of a hardcoded 1440x900
|
|
251
|
+
// (0.4.6 orb feedback #2).
|
|
252
|
+
setClientViewportSize(body.clientWidth, body.clientHeight);
|
|
240
253
|
const next = {
|
|
241
254
|
x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
|
|
242
255
|
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
package/src/server/server.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - POST /api/canvas/json-render -> create a native json-render node
|
|
23
23
|
* - POST /api/canvas/graph -> create a native graph node
|
|
24
24
|
* - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
|
|
25
|
+
* - GET /api/canvas/file-bytes?nodeId=... -> raw bytes for a file node
|
|
25
26
|
* - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
|
|
26
27
|
* - GET /api/workbench/events -> SSE event stream
|
|
27
28
|
* - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
|
|
38
39
|
import { spawnSync } from 'node:child_process';
|
|
39
40
|
import { randomUUID } from 'node:crypto';
|
|
40
|
-
import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
41
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
41
42
|
import { readFile } from 'node:fs/promises';
|
|
42
43
|
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
43
44
|
import * as marked from 'marked';
|
|
@@ -47,10 +48,12 @@ import {
|
|
|
47
48
|
buildAxStateBridge,
|
|
48
49
|
buildContentHeightReporter,
|
|
49
50
|
buildHtmlSurfaceDocument,
|
|
51
|
+
buildMermaidSurfaceHtml,
|
|
50
52
|
HTML_SURFACE_SANDBOX,
|
|
51
53
|
normalizeSurfaceTheme,
|
|
52
54
|
} from './html-surface.js';
|
|
53
55
|
import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
|
|
56
|
+
import { isPdfFilePath } from './file-content.js';
|
|
54
57
|
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
55
58
|
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
56
59
|
import { closeMcpAppSession, closeAllMcpAppSessions } from './mcp-app-runtime.js';
|
|
@@ -61,6 +64,7 @@ import { buildCanvasAxSurfaceSnapshot } from './ax-context.js';
|
|
|
61
64
|
import { resolveNodeAxCapabilities } from './ax-interaction.js';
|
|
62
65
|
import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
|
|
63
66
|
import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
|
|
67
|
+
import { canOpenNodeAsSurface } from '../shared/surface.js';
|
|
64
68
|
import { validateLocalImageFile } from './image-source.js';
|
|
65
69
|
import {
|
|
66
70
|
cancelCodeGraphRecompute,
|
|
@@ -625,9 +629,28 @@ function resolveWorkspaceMarkdownPath(pathLike: string): string | null {
|
|
|
625
629
|
function resolveWorkspaceArtifactPath(pathLike: string): string | null {
|
|
626
630
|
if (!pathLike || typeof pathLike !== 'string') return null;
|
|
627
631
|
const resolved = resolve(pathLike);
|
|
628
|
-
|
|
632
|
+
// Containment must be checked on REAL paths: a lexical resolve() only stops
|
|
633
|
+
// `..` and absolute escapes, so a symlink INSIDE the workspace pointing at
|
|
634
|
+
// /etc/passwd or ~/.ssh/id_rsa would pass. Both sides are realpath'd — the
|
|
635
|
+
// root itself is commonly a symlink (macOS /tmp → /private/tmp), and
|
|
636
|
+
// comparing a real candidate against a symlinked root would reject
|
|
637
|
+
// legitimate files.
|
|
638
|
+
let realResolved = resolved;
|
|
639
|
+
let realRoot = activeWorkspaceRoot;
|
|
640
|
+
try {
|
|
641
|
+
realResolved = realpathSync(resolved);
|
|
642
|
+
} catch {
|
|
643
|
+
// Not yet on disk (callers handle existence themselves) — fall back to the
|
|
644
|
+
// lexical path, whose parent chain is still checked below.
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
realRoot = realpathSync(activeWorkspaceRoot);
|
|
648
|
+
} catch {
|
|
649
|
+
// Workspace root unreadable — keep the configured value.
|
|
650
|
+
}
|
|
651
|
+
const workspaceRel = relative(realRoot, realResolved);
|
|
629
652
|
const insideWorkspace = !(workspaceRel.startsWith('..') || workspaceRel === '..');
|
|
630
|
-
return insideWorkspace ?
|
|
653
|
+
return insideWorkspace ? realResolved : null;
|
|
631
654
|
}
|
|
632
655
|
|
|
633
656
|
function escapeHtml(text: string): string {
|
|
@@ -1004,6 +1027,7 @@ function canvasSpaHtml(): string {
|
|
|
1004
1027
|
// (see src/client/state/iframe-mode.ts).
|
|
1005
1028
|
process.env.AMP_ORB ? '<script>window.__PMX_AMP_ORB = true;</script>' : ''
|
|
1006
1029
|
}
|
|
1030
|
+
<script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
|
|
1007
1031
|
<script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
|
|
1008
1032
|
</body>
|
|
1009
1033
|
</html>`;
|
|
@@ -1263,6 +1287,14 @@ function isSafeSurfaceRedirect(target: string): boolean {
|
|
|
1263
1287
|
return false;
|
|
1264
1288
|
}
|
|
1265
1289
|
|
|
1290
|
+
// Why a node has no standalone surface, in one wording shared by the route's own
|
|
1291
|
+
// 404 and by open-external's refusal — the two must never disagree about a node.
|
|
1292
|
+
function surfaceRefusalMessage(type: string): string {
|
|
1293
|
+
return type === 'mcp-app'
|
|
1294
|
+
? 'This MCP app renders in the canvas and cannot be opened as a standalone site.'
|
|
1295
|
+
: 'Node type cannot be opened as a site';
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1266
1298
|
function handleNodeSurface(pathname: string, url: URL): Response {
|
|
1267
1299
|
const nodeId = decodeURIComponent(pathname.slice('/api/canvas/surface/'.length));
|
|
1268
1300
|
if (!nodeId) return responseText('Missing node id', 400);
|
|
@@ -1271,6 +1303,28 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1271
1303
|
|
|
1272
1304
|
const theme = normalizeSurfaceTheme(url.searchParams.get('theme'));
|
|
1273
1305
|
|
|
1306
|
+
if (node.type === 'mermaid') {
|
|
1307
|
+
const source = typeof node.data.content === 'string' ? node.data.content : '';
|
|
1308
|
+
if (!source.trim()) return responseText('Mermaid node has no content', 404);
|
|
1309
|
+
const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id;
|
|
1310
|
+
// Display-only surface: same theme/height plumbing as html nodes, but no AX
|
|
1311
|
+
// bridge and no presentation mode — the document is our own wrapper around
|
|
1312
|
+
// escaped diagram source, rendered by /canvas/mermaid-entry.js.
|
|
1313
|
+
const doc = buildHtmlSurfaceDocument(buildMermaidSurfaceHtml(source), {
|
|
1314
|
+
theme,
|
|
1315
|
+
title: surfaceTitle,
|
|
1316
|
+
themeToken: url.searchParams.get('themeToken') ?? undefined,
|
|
1317
|
+
// Content-height reporter nonce (lets the node grow to fit the diagram).
|
|
1318
|
+
...(url.searchParams.get('frameToken')
|
|
1319
|
+
? { contentHeightToken: url.searchParams.get('frameToken') as string }
|
|
1320
|
+
: {}),
|
|
1321
|
+
// Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
|
|
1322
|
+
// are styled without depending on the <link> subresource.
|
|
1323
|
+
...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
|
|
1324
|
+
});
|
|
1325
|
+
return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1274
1328
|
if (node.type === 'html') {
|
|
1275
1329
|
const html =
|
|
1276
1330
|
typeof node.data.html === 'string'
|
|
@@ -1329,7 +1383,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1329
1383
|
// `-32601` (report #61). It is intentionally NOT openable as a site — open such
|
|
1330
1384
|
// apps externally through their own app, or view them in the canvas. (Kept in sync
|
|
1331
1385
|
// with canOpenNodeAsSurface in src/shared/surface.ts, which hides the UI control.)
|
|
1332
|
-
return responseText(
|
|
1386
|
+
return responseText(surfaceRefusalMessage(node.type), 404);
|
|
1333
1387
|
}
|
|
1334
1388
|
|
|
1335
1389
|
if (node.type === 'webpage') {
|
|
@@ -1339,7 +1393,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1339
1393
|
return responseText('Webpage node has no url', 404);
|
|
1340
1394
|
}
|
|
1341
1395
|
|
|
1342
|
-
return responseText(
|
|
1396
|
+
return responseText(surfaceRefusalMessage(node.type), 404);
|
|
1343
1397
|
}
|
|
1344
1398
|
|
|
1345
1399
|
// ── Serve image file for image nodes ─────────────────────────
|
|
@@ -1378,6 +1432,66 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
|
1378
1432
|
});
|
|
1379
1433
|
}
|
|
1380
1434
|
|
|
1435
|
+
// ── Serve raw bytes for file nodes (PDF viewer, downloads) ───
|
|
1436
|
+
/**
|
|
1437
|
+
* Ceiling for the raw byte route. The route streams via Bun.file, so this is a
|
|
1438
|
+
* sanity bound (an embedded PDF viewer is the only consumer), not a memory
|
|
1439
|
+
* guard — but it stops a stray multi-GB path from tying up a connection.
|
|
1440
|
+
*/
|
|
1441
|
+
const FILE_BYTES_MAX = 100 * 1024 * 1024;
|
|
1442
|
+
|
|
1443
|
+
async function handleCanvasFileBytes(url: URL): Promise<Response> {
|
|
1444
|
+
const nodeId = url.searchParams.get('nodeId') ?? '';
|
|
1445
|
+
if (!nodeId) return responseText('Missing nodeId', 400);
|
|
1446
|
+
const node = canvasState.getNode(nodeId);
|
|
1447
|
+
if (node?.type !== 'file') {
|
|
1448
|
+
return responseText('File node not found', 404);
|
|
1449
|
+
}
|
|
1450
|
+
const filePath = typeof node.data.path === 'string' ? node.data.path : '';
|
|
1451
|
+
if (!filePath) {
|
|
1452
|
+
return responseText('File node is not backed by a path', 404);
|
|
1453
|
+
}
|
|
1454
|
+
// Same containment guard as the image route: `path` comes from node data,
|
|
1455
|
+
// which any unauthenticated local caller can set, so an unguarded read here
|
|
1456
|
+
// would serve arbitrary host files.
|
|
1457
|
+
const safePath = resolveWorkspaceArtifactPath(filePath);
|
|
1458
|
+
if (!safePath) {
|
|
1459
|
+
return responseText('File path is outside the workspace; the byte route only serves workspace files', 403);
|
|
1460
|
+
}
|
|
1461
|
+
if (!existsSync(safePath)) {
|
|
1462
|
+
return responseText('File not found', 404);
|
|
1463
|
+
}
|
|
1464
|
+
let stats: ReturnType<typeof statSync>;
|
|
1465
|
+
try {
|
|
1466
|
+
stats = statSync(safePath);
|
|
1467
|
+
} catch {
|
|
1468
|
+
return responseText('File not found', 404);
|
|
1469
|
+
}
|
|
1470
|
+
// Regular files only: a directory would throw EISDIR into a 500, and a FIFO
|
|
1471
|
+
// would block the read forever (the server has no request timeout).
|
|
1472
|
+
if (!stats.isFile()) {
|
|
1473
|
+
return responseText('File path is not a regular file', 400);
|
|
1474
|
+
}
|
|
1475
|
+
if (stats.size > FILE_BYTES_MAX) {
|
|
1476
|
+
return responseText(`File is larger than the ${Math.floor(FILE_BYTES_MAX / 1_000_000)} MB byte-route limit`, 413);
|
|
1477
|
+
}
|
|
1478
|
+
// Content-Type is derived from the BYTES, never from node.data.mimeType:
|
|
1479
|
+
// node data is freely writable by any local caller, and this response is
|
|
1480
|
+
// framed same-origin — echoing a caller-chosen `text/html` here would run
|
|
1481
|
+
// attacker markup on the canvas origin. PDFs are the only inline-rendered
|
|
1482
|
+
// type; everything else is an opaque download. nosniff stops the browser
|
|
1483
|
+
// from second-guessing us.
|
|
1484
|
+
const contentType = isPdfFilePath(safePath) ? 'application/pdf' : 'application/octet-stream';
|
|
1485
|
+
return new Response(Bun.file(safePath), {
|
|
1486
|
+
headers: {
|
|
1487
|
+
'Content-Type': contentType,
|
|
1488
|
+
'Content-Disposition': contentType === 'application/pdf' ? 'inline' : 'attachment',
|
|
1489
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1490
|
+
'Cache-Control': 'no-store',
|
|
1491
|
+
},
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1381
1495
|
async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
1382
1496
|
const nodeId = url.searchParams.get('nodeId') ?? '';
|
|
1383
1497
|
if (!nodeId) return responseText('Missing nodeId', 400);
|
|
@@ -1449,6 +1563,23 @@ function responseJson(data: unknown, status = 200): Response {
|
|
|
1449
1563
|
});
|
|
1450
1564
|
}
|
|
1451
1565
|
|
|
1566
|
+
// Resolved from the sibling package.json so it stays accurate through bunx,
|
|
1567
|
+
// global npm installs, and repo-local runs — same mechanism as --version in
|
|
1568
|
+
// src/cli/index.ts and the MCP server. Memoized: /health is polled.
|
|
1569
|
+
let cachedPackageVersion: string | null = null;
|
|
1570
|
+
function serverPackageVersion(): string {
|
|
1571
|
+
if (cachedPackageVersion !== null) return cachedPackageVersion;
|
|
1572
|
+
try {
|
|
1573
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8')) as {
|
|
1574
|
+
version?: string;
|
|
1575
|
+
};
|
|
1576
|
+
cachedPackageVersion = pkg.version ?? 'unknown';
|
|
1577
|
+
} catch {
|
|
1578
|
+
cachedPackageVersion = 'unknown';
|
|
1579
|
+
}
|
|
1580
|
+
return cachedPackageVersion;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1452
1583
|
// v0.4.0 (M4): every hand-written error response is a JSON envelope
|
|
1453
1584
|
// ({ ok: false, error }, same status codes) matching the registry dispatcher —
|
|
1454
1585
|
// consumers no longer need to branch on text/plain vs JSON error bodies.
|
|
@@ -1799,6 +1930,9 @@ function buildWorkbenchConnectSnapshot(
|
|
|
1799
1930
|
continuity,
|
|
1800
1931
|
path: primaryWorkbenchPath,
|
|
1801
1932
|
theme: canvasState.theme,
|
|
1933
|
+
// Lets a long-lived client detect that the server was upgraded under it
|
|
1934
|
+
// (stale in-memory SPA — 0.4.5 report Finding W) and reload once.
|
|
1935
|
+
version: serverPackageVersion(),
|
|
1802
1936
|
timestamp: new Date().toISOString(),
|
|
1803
1937
|
},
|
|
1804
1938
|
});
|
|
@@ -2241,6 +2375,11 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
|
2241
2375
|
if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
|
|
2242
2376
|
const node = canvasState.getNode(nodeId);
|
|
2243
2377
|
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
2378
|
+
// Same rule the surface route and the "Open as site" control apply: refuse here
|
|
2379
|
+
// rather than launching a browser at a URL that is guaranteed to 404 (report #61).
|
|
2380
|
+
if (!canOpenNodeAsSurface(node.type, node.data)) {
|
|
2381
|
+
return responseJson({ ok: false, opened: false, error: surfaceRefusalMessage(node.type) }, 400);
|
|
2382
|
+
}
|
|
2244
2383
|
const port = getCanvasServerPort();
|
|
2245
2384
|
if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
|
|
2246
2385
|
const defaultSurfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
|
|
@@ -3038,11 +3177,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3038
3177
|
// when the pid file is stale (e.g. an adapter respawned the server
|
|
3039
3178
|
// outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
|
|
3040
3179
|
// surfaces silent save failures (M4): the canvas keeps serving from
|
|
3041
|
-
// memory, but ok:false means durability is degraded.
|
|
3180
|
+
// memory, but ok:false means durability is degraded. `version` lets
|
|
3181
|
+
// `pmx-canvas smoke` verify the RUNNING server, not just the CLI —
|
|
3182
|
+
// version skew between the two is a known failure mode.
|
|
3042
3183
|
return responseJson({
|
|
3043
3184
|
ok: true,
|
|
3044
3185
|
workspace: activeWorkspaceRoot,
|
|
3045
3186
|
pid: process.pid,
|
|
3187
|
+
version: serverPackageVersion(),
|
|
3046
3188
|
persistence: canvasState.persistenceHealth,
|
|
3047
3189
|
});
|
|
3048
3190
|
}
|
|
@@ -3154,6 +3296,12 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3154
3296
|
return await handleCanvasImage(url.pathname);
|
|
3155
3297
|
}
|
|
3156
3298
|
|
|
3299
|
+
// HEAD answers like GET (see the standalone-surface note above): a PDF
|
|
3300
|
+
// viewer probes this URL for type/length before fetching the bytes.
|
|
3301
|
+
if (url.pathname === '/api/canvas/file-bytes' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3302
|
+
return await handleCanvasFileBytes(url);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3157
3305
|
if (url.pathname === '/api/canvas/open-external' && req.method === 'POST') {
|
|
3158
3306
|
return handleOpenExternalSurface(req);
|
|
3159
3307
|
}
|