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.
Files changed (109) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/Readme.md +76 -36
  3. package/dist/canvas/global.css +169 -3
  4. package/dist/canvas/index.js +112 -87
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/smoke.d.ts +1 -0
  9. package/dist/types/cli/shared.d.ts +13 -1
  10. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  11. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  12. package/dist/types/client/mermaid-entry.d.ts +11 -0
  13. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  14. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  15. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  16. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  17. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  18. package/dist/types/client/state/canvas-store.d.ts +10 -0
  19. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  20. package/dist/types/client/types.d.ts +2 -2
  21. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  22. package/dist/types/server/ax-interaction.d.ts +21 -2
  23. package/dist/types/server/canvas-operations.d.ts +5 -0
  24. package/dist/types/server/canvas-provenance.d.ts +1 -1
  25. package/dist/types/server/canvas-state.d.ts +18 -1
  26. package/dist/types/server/canvas-validation.d.ts +28 -1
  27. package/dist/types/server/file-content.d.ts +46 -0
  28. package/dist/types/server/html-primitives.d.ts +13 -1
  29. package/dist/types/server/html-surface.d.ts +8 -0
  30. package/dist/types/server/index.d.ts +5 -0
  31. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  32. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  33. package/dist/types/server/server.d.ts +1 -0
  34. package/dist/types/server/web-artifacts.d.ts +14 -0
  35. package/dist/types/server/workboard.d.ts +46 -0
  36. package/dist/types/shared/ax-flow.d.ts +51 -0
  37. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  38. package/dist/types/shared/node-sizes.d.ts +33 -0
  39. package/docs/cli.md +31 -2
  40. package/docs/environment.md +1 -0
  41. package/docs/http-api.md +29 -0
  42. package/docs/mcp.md +13 -1
  43. package/docs/node-types.md +47 -1
  44. package/docs/screenshot.png +0 -0
  45. package/package.json +5 -4
  46. package/skills/pmx-canvas/SKILL.md +123 -25
  47. package/skills/pmx-canvas/evals/evals.json +54 -3
  48. package/skills/pmx-canvas/references/full-reference.md +11 -6
  49. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  50. package/src/cli/agent.ts +2 -0
  51. package/src/cli/commands/ax.ts +28 -3
  52. package/src/cli/commands/history.ts +1 -1
  53. package/src/cli/commands/nodes.ts +2 -2
  54. package/src/cli/commands/query.ts +1 -1
  55. package/src/cli/commands/smoke.ts +212 -0
  56. package/src/cli/commands/view.ts +1 -1
  57. package/src/cli/index.ts +7 -0
  58. package/src/cli/shared.ts +35 -5
  59. package/src/client/App.tsx +28 -15
  60. package/src/client/canvas/CanvasNode.tsx +54 -5
  61. package/src/client/canvas/CanvasViewport.tsx +6 -0
  62. package/src/client/canvas/EdgeLayer.tsx +44 -24
  63. package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
  64. package/src/client/canvas/Minimap.tsx +2 -0
  65. package/src/client/canvas/auto-fit.ts +12 -1
  66. package/src/client/canvas/use-pan-zoom.ts +16 -1
  67. package/src/client/icons.tsx +4 -0
  68. package/src/client/mermaid-entry.ts +76 -0
  69. package/src/client/nodes/AxStepControls.tsx +226 -0
  70. package/src/client/nodes/DiffNode.tsx +114 -0
  71. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  72. package/src/client/nodes/FileNode.tsx +249 -51
  73. package/src/client/nodes/MermaidNode.tsx +81 -0
  74. package/src/client/state/canvas-store.ts +19 -0
  75. package/src/client/state/intent-bridge.ts +32 -0
  76. package/src/client/state/node-factory.ts +2 -0
  77. package/src/client/state/sse-bridge.ts +32 -1
  78. package/src/client/theme/global.css +169 -3
  79. package/src/client/types.ts +6 -0
  80. package/src/mcp/server.ts +19 -1
  81. package/src/server/ax-flow-loop.ts +114 -0
  82. package/src/server/ax-interaction.ts +305 -4
  83. package/src/server/canvas-operations.ts +83 -26
  84. package/src/server/canvas-provenance.ts +2 -0
  85. package/src/server/canvas-schema.ts +33 -0
  86. package/src/server/canvas-state.ts +46 -10
  87. package/src/server/canvas-validation.ts +88 -0
  88. package/src/server/demo-state.json +4605 -523
  89. package/src/server/demo.ts +24 -0
  90. package/src/server/file-content.ts +152 -0
  91. package/src/server/file-watcher.ts +7 -10
  92. package/src/server/html-primitives.ts +636 -0
  93. package/src/server/html-surface.ts +11 -0
  94. package/src/server/index.ts +25 -6
  95. package/src/server/operations/composites.ts +3 -2
  96. package/src/server/operations/ops/ax-work.ts +5 -0
  97. package/src/server/operations/ops/batch.ts +8 -0
  98. package/src/server/operations/ops/json-render.ts +44 -0
  99. package/src/server/operations/ops/nodes.ts +24 -2
  100. package/src/server/operations/ops/viewport.ts +15 -2
  101. package/src/server/server.ts +154 -6
  102. package/src/server/web-artifacts.ts +109 -0
  103. package/src/server/workboard.ts +159 -0
  104. package/src/shared/ax-flow.ts +102 -0
  105. package/src/shared/ax-surface-protocol.ts +8 -0
  106. package/src/shared/node-sizes.ts +52 -0
  107. package/src/shared/surface.ts +2 -0
  108. package/dist/types/server/chart-template.d.ts +0 -29
  109. package/src/server/chart-template.ts +0 -448
@@ -211,6 +211,17 @@ function escapeSurfaceHtml(value: string): string {
211
211
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
  /**
@@ -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(message: string, options?: { source?: PmxAxSource }): PmxAxSteeringMessage {
553
- const steering = canvasState.recordSteeringMessage(message, { source: options?.source ?? 'sdk' });
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: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
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: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
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 { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind } from '../../html-primitives.js';
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
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
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,
@@ -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
- const workspaceRel = relative(activeWorkspaceRoot, resolved);
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 ? resolved : null;
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('This MCP app renders in the canvas and cannot be opened as a standalone site.', 404);
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('Node type cannot be opened as a site', 404);
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
  }