pmx-canvas 0.3.3 → 0.3.4

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 (64) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/Readme.md +2 -1
  3. package/dist/canvas/index.js +83 -83
  4. package/dist/json-render/index.js +47 -47
  5. package/dist/types/cli/agent.d.ts +13 -8
  6. package/dist/types/cli/commands/apps.d.ts +1 -0
  7. package/dist/types/cli/commands/ax.d.ts +1 -0
  8. package/dist/types/cli/commands/copilot.d.ts +1 -0
  9. package/dist/types/cli/commands/edges.d.ts +1 -0
  10. package/dist/types/cli/commands/groups.d.ts +1 -0
  11. package/dist/types/cli/commands/history.d.ts +1 -0
  12. package/dist/types/cli/commands/nodes.d.ts +1 -0
  13. package/dist/types/cli/commands/pins.d.ts +1 -0
  14. package/dist/types/cli/commands/query.d.ts +1 -0
  15. package/dist/types/cli/commands/view.d.ts +1 -0
  16. package/dist/types/cli/commands/webview.d.ts +1 -0
  17. package/dist/types/cli/shared.d.ts +73 -0
  18. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
  19. package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
  20. package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
  21. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
  22. package/dist/types/server/canvas-operations.d.ts +2 -0
  23. package/dist/types/server/diagram-presets.d.ts +8 -0
  24. package/dist/types/server/operations/composites.d.ts +0 -9
  25. package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
  26. package/docs/bun-webview-integration.md +5 -0
  27. package/docs/environment.md +48 -0
  28. package/docs/node-types.md +5 -0
  29. package/docs/plans/plan-009-tech-debt-backlog.md +27 -9
  30. package/docs/screenshot.png +0 -0
  31. package/package.json +8 -5
  32. package/skills/pmx-canvas/evals/evals.json +5 -5
  33. package/skills/pmx-canvas/references/full-reference.md +8 -1
  34. package/src/cli/agent.ts +22 -3512
  35. package/src/cli/commands/apps.ts +165 -0
  36. package/src/cli/commands/ax.ts +604 -0
  37. package/src/cli/commands/copilot.ts +43 -0
  38. package/src/cli/commands/edges.ts +73 -0
  39. package/src/cli/commands/groups.ts +81 -0
  40. package/src/cli/commands/history.ts +220 -0
  41. package/src/cli/commands/nodes.ts +954 -0
  42. package/src/cli/commands/pins.ts +42 -0
  43. package/src/cli/commands/query.ts +223 -0
  44. package/src/cli/commands/view.ts +186 -0
  45. package/src/cli/commands/webview.ts +227 -0
  46. package/src/cli/shared.ts +860 -0
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
  48. package/src/client/nodes/ExtAppFrame.tsx +53 -49
  49. package/src/client/nodes/HtmlNode.tsx +8 -51
  50. package/src/client/nodes/McpAppNode.tsx +12 -47
  51. package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
  52. package/src/json-render/renderer/index.tsx +3 -2
  53. package/src/mcp/server.ts +0 -47
  54. package/src/server/canvas-operations.ts +8 -0
  55. package/src/server/diagram-presets.ts +15 -1
  56. package/src/server/html-surface.ts +11 -6
  57. package/src/server/operations/composites.ts +1 -13
  58. package/src/server/operations/invoker.ts +10 -3
  59. package/src/server/operations/mcp.ts +51 -1
  60. package/src/server/operations/ops/app.ts +5 -0
  61. package/src/server/server.ts +2 -0
  62. package/src/shared/ax-surface-protocol.ts +22 -0
  63. package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
  64. package/src/client/canvas/ContextPinHud.tsx +0 -17
@@ -1,3 +1,4 @@
1
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
1
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
3
  import { ContextNode } from '../nodes/ContextNode';
3
4
  import { FileNode } from '../nodes/FileNode';
@@ -86,7 +87,7 @@ function isPresentationExitMessage(value: unknown, token: string): boolean {
86
87
  return (
87
88
  value !== null &&
88
89
  typeof value === 'object' &&
89
- (value as { source?: unknown }).source === 'pmx-canvas-html-node' &&
90
+ (value as { source?: unknown }).source === HTML_SURFACE_PUSH_SOURCE &&
90
91
  (value as { type?: unknown }).type === 'presentation-exit' &&
91
92
  (value as { token?: unknown }).token === token
92
93
  );
@@ -134,7 +135,7 @@ export function ExpandedNodeOverlay() {
134
135
  );
135
136
  frame?.contentWindow?.postMessage(
136
137
  {
137
- source: 'pmx-canvas-html-node',
138
+ source: HTML_SURFACE_PUSH_SOURCE,
138
139
  token: presentationExitToken,
139
140
  ...message,
140
141
  },
@@ -2,10 +2,13 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
2
2
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
+ import {
6
+ AX_SURFACE_ACK_SOURCE,
7
+ AX_SURFACE_EMIT_SOURCE,
8
+ EXT_APP_BOOT_BEACON_SOURCE,
9
+ } from '../../shared/ax-surface-protocol.js';
5
10
  import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
6
11
  import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
7
- import { submitAxInteractionFromClient } from '../state/intent-bridge';
8
- import { showToast } from '../state/attention-bridge';
9
12
  import {
10
13
  canvasTheme,
11
14
  collapseExpandedNode,
@@ -18,6 +21,7 @@ import {
18
21
  import type { CanvasNodeState } from '../types';
19
22
  import { AUTO_FIT_TITLEBAR_HEIGHT } from '../canvas/auto-fit';
20
23
  import { useIframeDocument } from './iframe-document-url';
24
+ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
21
25
 
22
26
  type McpUiTheme = 'light' | 'dark';
23
27
 
@@ -205,7 +209,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
205
209
  }, 10000);
206
210
  pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
207
211
  window.parent.postMessage({
208
- source: 'pmx-canvas-ax',
212
+ source: '${AX_SURFACE_EMIT_SOURCE}',
209
213
  token: PMX_AX_TOKEN,
210
214
  nodeId: PMX_AX_NODE_ID,
211
215
  correlationId: correlationId,
@@ -218,7 +222,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
218
222
  };
219
223
  window.addEventListener('message', function (event) {
220
224
  const m = event.data;
221
- if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
225
+ if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
222
226
  const result = m.result || { ok: false };
223
227
  const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
224
228
  if (resolver) { pending.delete(m.correlationId); resolver(result); }
@@ -245,7 +249,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
245
249
  */
246
250
  export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
247
251
  return `<script data-pmx-canvas-boot-beacon>
248
- window.parent.postMessage({ source: 'pmx-canvas-ext-app-alive', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
252
+ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
249
253
  </script>`;
250
254
  }
251
255
 
@@ -363,7 +367,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
363
367
  function onBootBeacon(event: MessageEvent) {
364
368
  if (event.source !== iframeRef.current?.contentWindow) return;
365
369
  const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
366
- if (!data || data.source !== 'pmx-canvas-ext-app-alive' || data.token !== axToken || data.nodeId !== nodeId)
370
+ if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
367
371
  return;
368
372
  appScriptsRanRef.current = true;
369
373
  }
@@ -371,46 +375,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
371
375
  return () => window.removeEventListener('message', onBootBeacon);
372
376
  }, [axToken, nodeId]);
373
377
 
374
- useEffect(() => {
375
- if (!axEnabled) return;
376
- function onAxMessage(event: MessageEvent) {
377
- if (event.source !== iframeRef.current?.contentWindow) return;
378
- const data = event.data as {
379
- source?: string;
380
- token?: string;
381
- nodeId?: string;
382
- correlationId?: string;
383
- interaction?: { type?: unknown; payload?: unknown };
384
- } | null;
385
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
386
- const interaction = data.interaction;
387
- if (!interaction || typeof interaction.type !== 'string') return;
388
- const interactionType = interaction.type;
389
- void submitAxInteractionFromClient({
390
- type: interactionType,
391
- sourceNodeId: nodeId,
392
- sourceSurface: 'mcp-app',
393
- ...(interaction.payload && typeof interaction.payload === 'object'
394
- ? { payload: interaction.payload as Record<string, unknown> }
395
- : {}),
396
- }).then((res) => {
397
- if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
398
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
399
- iframeRef.current?.contentWindow?.postMessage(
400
- {
401
- source: 'pmx-canvas-ax-ack',
402
- token: axToken,
403
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
404
- interaction: { type: interactionType },
405
- result: res,
406
- },
407
- '*',
408
- );
409
- });
410
- }
411
- window.addEventListener('message', onAxMessage);
412
- return () => window.removeEventListener('message', onAxMessage);
413
- }, [axEnabled, axToken, nodeId]);
378
+ // Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
379
+ useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
414
380
 
415
381
  // Enqueue one serialized remount attempt for this node (Finding F recovery).
416
382
  // Attempts are capped so a persistently-failing app degrades to the manual
@@ -439,6 +405,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
439
405
  // every scheduling path (post-boot repaint, boot watchdog, re-arm).
440
406
  bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
441
407
  extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
408
+ // Back to 'loading' so the connecting overlay covers this recovery
409
+ // remount's boot window too (the fallback boot flipped status to
410
+ // ready/done even when the previous frame was dead — without this the
411
+ // overlay only ever covered the INITIAL boot, not recovery).
412
+ setStatus('loading');
442
413
  setRetryKey((k) => k + 1);
443
414
  return true;
444
415
  },
@@ -1077,9 +1048,6 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1077
1048
  </button>
1078
1049
  </div>
1079
1050
  )}
1080
- {status === 'loading' && (
1081
- <div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
1082
- )}
1083
1051
  {/* Iframe stack: the widget renders a preview; when not expanded, a
1084
1052
  transparent click-catcher sits on top so the first click always
1085
1053
  expands the node. Without this, widgets like Excalidraw show their
@@ -1109,6 +1077,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1109
1077
  }}
1110
1078
  title={`Ext App: ${toolName}`}
1111
1079
  />
1080
+ {/* Connecting overlay: an opaque sibling ABOVE the iframe while this
1081
+ frame boots. During WebKit recovery remounts the iframe's own layer
1082
+ can composite black (Finding N), but sibling DOM always paints — so
1083
+ the multi-second recovery window reads as an intentional loading
1084
+ state instead of a broken black tile. pointer-events pass through
1085
+ so the expand click-catcher keeps working. */}
1086
+ {status === 'loading' && !error && (
1087
+ <div
1088
+ style={{
1089
+ position: 'absolute',
1090
+ inset: 0,
1091
+ display: 'flex',
1092
+ flexDirection: 'column',
1093
+ alignItems: 'center',
1094
+ justifyContent: 'center',
1095
+ gap: '10px',
1096
+ background: 'var(--c-panel)',
1097
+ color: 'var(--c-muted)',
1098
+ fontSize: '12px',
1099
+ pointerEvents: 'none',
1100
+ }}
1101
+ >
1102
+ <div
1103
+ style={{
1104
+ width: '22px',
1105
+ height: '22px',
1106
+ border: '2px solid var(--c-line)',
1107
+ borderTopColor: 'var(--c-muted)',
1108
+ borderRadius: '50%',
1109
+ animation: 'spin 1s linear infinite',
1110
+ }}
1111
+ />
1112
+ <div>Connecting to {toolName}...</div>
1113
+ <style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
1114
+ </div>
1115
+ )}
1112
1116
  {!isExpanded && (
1113
1117
  <button
1114
1118
  type="button"
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
2
3
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
3
- import { submitAxInteractionFromClient } from '../state/intent-bridge';
4
- import { showToast } from '../state/attention-bridge';
5
4
  import type { CanvasNodeState } from '../types';
6
5
  import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
6
+ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
7
7
  import { useIframeContentHeight } from './use-iframe-content-height';
8
8
 
9
9
  export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
@@ -64,56 +64,13 @@ export function HtmlNode({
64
64
  // Never in the expanded overlay — there the surface fills the large overlay frame.
65
65
  useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
66
66
 
67
- // Phase 3 HTML bridge: receive window.PMX_AX.emit(...) messages from the
68
- // sandboxed iframe, validate the nonce + node id, and submit the interaction
69
- // through the capability-gated endpoint (the server re-validates capabilities).
70
- useEffect(() => {
71
- function onAxMessage(event: MessageEvent) {
72
- // Bind to THIS node's own iframe (matches the ext-app bridge); the nonce +
73
- // nodeId are a second gate, not the only one.
74
- if (event.source !== iframeRef.current?.contentWindow) return;
75
- const data = event.data as {
76
- source?: string;
77
- token?: string;
78
- nodeId?: string;
79
- correlationId?: string;
80
- interaction?: { type?: unknown; payload?: unknown };
81
- } | null;
82
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
83
- const interaction = data.interaction;
84
- if (!interaction || typeof interaction.type !== 'string') return;
85
- const interactionType = interaction.type;
86
- void submitAxInteractionFromClient({
87
- type: interactionType,
88
- sourceNodeId: node.id,
89
- sourceSurface: 'html-node',
90
- ...(interaction.payload && typeof interaction.payload === 'object'
91
- ? { payload: interaction.payload as Record<string, unknown> }
92
- : {}),
93
- }).then((res) => {
94
- if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
95
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
96
- // Report #55: ack back to the surface so it can self-confirm (e.g. "queued ✓").
97
- iframeRef.current?.contentWindow?.postMessage(
98
- {
99
- source: 'pmx-canvas-ax-ack',
100
- token: axToken,
101
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
102
- interaction: { type: interactionType },
103
- result: res,
104
- },
105
- '*',
106
- );
107
- });
108
- }
109
- window.addEventListener('message', onAxMessage);
110
- return () => window.removeEventListener('message', onAxMessage);
111
- }, [axToken, node.id]);
67
+ // Phase 3 HTML bridge — the shared sandboxed-surface trust boundary (M2).
68
+ useAxSurfaceBridge({ enabled: true, token: axToken, nodeId: node.id, sourceSurface: 'html-node', iframeRef });
112
69
 
113
70
  useEffect(() => {
114
71
  iframeRef.current?.contentWindow?.postMessage(
115
72
  {
116
- source: 'pmx-canvas-html-node',
73
+ source: HTML_SURFACE_PUSH_SOURCE,
117
74
  type: 'theme-update',
118
75
  token: themeToken,
119
76
  theme,
@@ -134,7 +91,7 @@ export function HtmlNode({
134
91
  if (!axEnabled || axStateValue == null) return;
135
92
  iframeRef.current?.contentWindow?.postMessage(
136
93
  {
137
- source: 'pmx-canvas-html-node',
94
+ source: HTML_SURFACE_PUSH_SOURCE,
138
95
  type: 'ax-update',
139
96
  token: axToken,
140
97
  state: axStateValue,
@@ -152,7 +109,7 @@ export function HtmlNode({
152
109
  const handleFrameLoad = () => {
153
110
  iframeRef.current?.contentWindow?.postMessage(
154
111
  {
155
- source: 'pmx-canvas-html-node',
112
+ source: HTML_SURFACE_PUSH_SOURCE,
156
113
  type: 'theme-update',
157
114
  token: themeToken,
158
115
  theme,
@@ -162,7 +119,7 @@ export function HtmlNode({
162
119
  if (axEnabled && axSurfaceState.value != null) {
163
120
  iframeRef.current?.contentWindow?.postMessage(
164
121
  {
165
- source: 'pmx-canvas-html-node',
122
+ source: HTML_SURFACE_PUSH_SOURCE,
166
123
  type: 'ax-update',
167
124
  token: axToken,
168
125
  state: axSurfaceState.value,
@@ -1,10 +1,10 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
2
3
  import type { CanvasNodeState } from '../types';
3
4
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
4
5
  import { shouldContentFitIframeNode } from '../canvas/auto-fit';
5
- import { submitAxInteractionFromClient } from '../state/intent-bridge';
6
- import { showToast } from '../state/attention-bridge';
7
6
  import { ExtAppFrame } from './ExtAppFrame';
7
+ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
8
8
  import { useIframeContentHeight } from './use-iframe-content-height';
9
9
 
10
10
  function withViewerParams(
@@ -80,50 +80,15 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
80
80
  const frameToken = useMemo(() => (contentFit ? `frame-${crypto.randomUUID()}` : ''), [contentFit]);
81
81
  useIframeContentHeight(node, iframeRef, frameToken);
82
82
 
83
- // Receive AX emits forwarded by the json-render viewer; validate (bound to this
84
- // node's iframe + nonce + node id) and submit through the capability-gated
85
- // endpoint, which re-validates server-side.
86
- useEffect(() => {
87
- if (!isAxViewer || !axToken) return;
88
- function onAxMessage(event: MessageEvent) {
89
- if (event.source !== iframeRef.current?.contentWindow) return;
90
- const data = event.data as {
91
- source?: string;
92
- token?: string;
93
- nodeId?: string;
94
- correlationId?: string;
95
- interaction?: { type?: unknown; payload?: unknown };
96
- } | null;
97
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
98
- const interaction = data.interaction;
99
- if (!interaction || typeof interaction.type !== 'string') return;
100
- const interactionType = interaction.type;
101
- void submitAxInteractionFromClient({
102
- type: interactionType,
103
- sourceNodeId: node.id,
104
- sourceSurface: axSurface,
105
- ...(interaction.payload && typeof interaction.payload === 'object'
106
- ? { payload: interaction.payload as Record<string, unknown> }
107
- : {}),
108
- }).then((res) => {
109
- if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
110
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
111
- // Report #55: ack back to the viewer so the surface can self-confirm.
112
- iframeRef.current?.contentWindow?.postMessage(
113
- {
114
- source: 'pmx-canvas-ax-ack',
115
- token: axToken,
116
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
117
- interaction: { type: interactionType },
118
- result: res,
119
- },
120
- '*',
121
- );
122
- });
123
- }
124
- window.addEventListener('message', onAxMessage);
125
- return () => window.removeEventListener('message', onAxMessage);
126
- }, [isAxViewer, axToken, node.id]);
83
+ // AX emits forwarded by the json-render viewer — the shared sandboxed-surface
84
+ // trust boundary (M2).
85
+ useAxSurfaceBridge({
86
+ enabled: isAxViewer && Boolean(axToken),
87
+ token: axToken,
88
+ nodeId: node.id,
89
+ sourceSurface: axSurface,
90
+ iframeRef,
91
+ });
127
92
 
128
93
  // Read-side: push live AX state into the json-render viewer so a spec bound to
129
94
  // /ax reflects the work queue. Validated by the viewer against axToken.
@@ -132,7 +97,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
132
97
  if (!isAxViewer || !axToken || axStateValue == null) return;
133
98
  iframeRef.current?.contentWindow?.postMessage(
134
99
  {
135
- source: 'pmx-canvas-html-node',
100
+ source: HTML_SURFACE_PUSH_SOURCE,
136
101
  type: 'ax-update',
137
102
  token: axToken,
138
103
  state: axStateValue,
@@ -0,0 +1,71 @@
1
+ import type { RefObject } from 'preact';
2
+ import { useEffect } from 'preact/hooks';
3
+ import { AX_SURFACE_ACK_SOURCE, AX_SURFACE_EMIT_SOURCE } from '../../shared/ax-surface-protocol.js';
4
+ import { showToast } from '../state/attention-bridge';
5
+ import { submitAxInteractionFromClient } from '../state/intent-bridge';
6
+
7
+ /** Sandboxed surfaces that emit through this bridge (clamped to their own node server-side). */
8
+ export type AxSurfaceBridgeSurface = 'json-render' | 'html-node' | 'mcp-app';
9
+
10
+ /**
11
+ * The sandboxed-surface AX trust boundary, single-sited (plan-009 M2; the
12
+ * listener was copy-pasted across HtmlNode, McpAppNode, and ExtAppFrame — a
13
+ * prior near-miss lived exactly in that duplication). Receives a
14
+ * `window.PMX_AX.emit(...)` message from THIS node's iframe, validates the
15
+ * event source against the live contentWindow plus the per-mount nonce token
16
+ * and node id, submits the interaction through the capability-gated endpoint
17
+ * (the server re-validates capabilities and clamps sandboxed surfaces to their
18
+ * own node), and acks the result back so the surface can self-confirm (#55).
19
+ */
20
+ export function useAxSurfaceBridge(options: {
21
+ enabled: boolean;
22
+ token: string;
23
+ nodeId: string;
24
+ sourceSurface: AxSurfaceBridgeSurface;
25
+ iframeRef: RefObject<HTMLIFrameElement>;
26
+ }): void {
27
+ const { enabled, token, nodeId, sourceSurface, iframeRef } = options;
28
+ useEffect(() => {
29
+ if (!enabled || !token) return;
30
+ function onAxMessage(event: MessageEvent) {
31
+ // Bind to THIS node's own iframe; the nonce + nodeId are a second gate,
32
+ // not the only one.
33
+ if (event.source !== iframeRef.current?.contentWindow) return;
34
+ const data = event.data as {
35
+ source?: string;
36
+ token?: string;
37
+ nodeId?: string;
38
+ correlationId?: string;
39
+ interaction?: { type?: unknown; payload?: unknown };
40
+ } | null;
41
+ if (!data || data.source !== AX_SURFACE_EMIT_SOURCE || data.token !== token || data.nodeId !== nodeId) return;
42
+ const interaction = data.interaction;
43
+ if (!interaction || typeof interaction.type !== 'string') return;
44
+ const interactionType = interaction.type;
45
+ void submitAxInteractionFromClient({
46
+ type: interactionType,
47
+ sourceNodeId: nodeId,
48
+ sourceSurface,
49
+ ...(interaction.payload && typeof interaction.payload === 'object'
50
+ ? { payload: interaction.payload as Record<string, unknown> }
51
+ : {}),
52
+ }).then((res) => {
53
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
54
+ else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
55
+ iframeRef.current?.contentWindow?.postMessage(
56
+ {
57
+ source: AX_SURFACE_ACK_SOURCE,
58
+ token,
59
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
60
+ interaction: { type: interactionType },
61
+ result: res,
62
+ },
63
+ '*',
64
+ );
65
+ });
66
+ }
67
+ window.addEventListener('message', onAxMessage);
68
+ return () => window.removeEventListener('message', onAxMessage);
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [enabled, token, nodeId, sourceSurface]);
71
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Spec } from '@json-render/core';
11
+ import { AX_SURFACE_EMIT_SOURCE, HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
11
12
  import { useEffect } from 'react';
12
13
  import { createRoot } from 'react-dom/client';
13
14
  import { defineRegistry, JSONUIProvider, Renderer, useStateBinding } from '@json-render/react';
@@ -101,7 +102,7 @@ function AxStateSync() {
101
102
  if (!token) return undefined;
102
103
  function onMessage(event: MessageEvent) {
103
104
  const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
104
- if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== token) return;
105
+ if (!m || m.source !== HTML_SURFACE_PUSH_SOURCE || m.type !== 'ax-update' || m.token !== token) return;
105
106
  window.__PMX_CANVAS_AX_STATE__ = m.state;
106
107
  setAx(m.state);
107
108
  }
@@ -141,7 +142,7 @@ function buildAxHandlers(): Record<string, (params: Record<string, unknown>) =>
141
142
  handlers[type] = (params: Record<string, unknown>) => {
142
143
  window.parent.postMessage(
143
144
  {
144
- source: 'pmx-canvas-ax',
145
+ source: AX_SURFACE_EMIT_SOURCE,
145
146
  token,
146
147
  nodeId,
147
148
  interaction: { type, payload: params && typeof params === 'object' ? params : {} },
package/src/mcp/server.ts CHANGED
@@ -175,53 +175,6 @@ function encodeBase64(bytes: Uint8Array): string {
175
175
  return Buffer.from(bytes).toString('base64');
176
176
  }
177
177
 
178
- function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): boolean {
179
- return input.full === true || input.verbose === true || input.includeData === true;
180
- }
181
-
182
- function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
183
- if (!node) return null;
184
- const serialized = serializeCanvasNode(node);
185
- return {
186
- id: serialized.id,
187
- type: serialized.type,
188
- kind: serialized.kind,
189
- title: serialized.title,
190
- content: serialized.content,
191
- position: serialized.position,
192
- size: serialized.size,
193
- pinned: serialized.pinned,
194
- collapsed: serialized.collapsed,
195
- dockPosition: serialized.dockPosition,
196
- provenance: serialized.provenance,
197
- };
198
- }
199
-
200
- function compactLayoutPayload(
201
- layout: Awaited<ReturnType<CanvasAccess['getLayout']>>,
202
- pinnedIds: string[],
203
- ): Record<string, unknown> {
204
- return {
205
- summary: buildSummaryFromLayout(layout, pinnedIds),
206
- viewport: layout.viewport,
207
- annotations: (layout.annotations ?? []).map((annotation) =>
208
- summarizeCanvasAnnotationForContext(annotation, layout.nodes),
209
- ),
210
- nodes: layout.nodes
211
- .map((node) => compactNodePayload(node))
212
- .filter((node): node is Record<string, unknown> => node !== null),
213
- edges: layout.edges.map((edge) => ({
214
- id: edge.id,
215
- from: edge.from,
216
- to: edge.to,
217
- type: edge.type,
218
- ...(edge.label ? { label: edge.label } : {}),
219
- ...(edge.style ? { style: edge.style } : {}),
220
- ...(edge.animated !== undefined ? { animated: edge.animated } : {}),
221
- })),
222
- };
223
- }
224
-
225
178
  function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
226
179
  return {
227
180
  ...serializeCanvasLayoutForAgent(layout),
@@ -856,6 +856,14 @@ export function scheduleCodeGraphRecompute(onComplete?: () => void): void {
856
856
  }, 300);
857
857
  }
858
858
 
859
+ /** Clear a pending recompute so the timer cannot outlive its server (start/stop cycles, tests). */
860
+ export function cancelCodeGraphRecompute(): void {
861
+ if (codeGraphTimer) {
862
+ clearTimeout(codeGraphTimer);
863
+ codeGraphTimer = null;
864
+ }
865
+ }
866
+
859
867
  /**
860
868
  * Resolve an html-node `html` field that may be a path to a local .html/.htm file.
861
869
  *
@@ -41,6 +41,20 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
41
41
  url: EXCALIDRAW_MCP_URL,
42
42
  };
43
43
 
44
+ /**
45
+ * The diagram preset targets the hosted Excalidraw MCP server. Tests set
46
+ * PMX_CANVAS_EXCALIDRAW_MCP_COMMAND (a stdio command line, e.g.
47
+ * "bun run tests/fixtures/mcp-app-fixture.ts") so the unit suite never does a
48
+ * real network round-trip — the hosted dependency made diagram tests time out
49
+ * under parallel load (plan-009 M8).
50
+ */
51
+ export function resolveExcalidrawTransport(): ExternalMcpTransportConfig {
52
+ const override = process.env.PMX_CANVAS_EXCALIDRAW_MCP_COMMAND?.trim();
53
+ if (!override) return EXCALIDRAW_MCP_TRANSPORT;
54
+ const [command = override, ...args] = override.split(/\s+/);
55
+ return { type: 'stdio', command, args };
56
+ }
57
+
44
58
  export interface DiagramPresetOpenInput {
45
59
  elements: unknown;
46
60
  nodeId?: string;
@@ -379,7 +393,7 @@ export function ensureExcalidrawCheckpointId(
379
393
  export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): ExcalidrawOpenMcpAppInput {
380
394
  const elements = normalizeExcalidrawElementsForToolInput(input.elements);
381
395
  const out: ExcalidrawOpenMcpAppInput = {
382
- transport: EXCALIDRAW_MCP_TRANSPORT,
396
+ transport: resolveExcalidrawTransport(),
383
397
  toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
384
398
  serverName: EXCALIDRAW_SERVER_NAME,
385
399
  toolArguments: { elements },
@@ -15,6 +15,11 @@
15
15
  * postMessage required.
16
16
  */
17
17
 
18
+ import {
19
+ AX_SURFACE_ACK_SOURCE,
20
+ AX_SURFACE_EMIT_SOURCE,
21
+ HTML_SURFACE_PUSH_SOURCE,
22
+ } from '../shared/ax-surface-protocol.js';
18
23
  import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
19
24
 
20
25
  export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
@@ -54,7 +59,7 @@ function buildThemeBridge(themeToken: string): string {
54
59
  const PMX_CANVAS_THEME_TOKEN = ${token};
55
60
  window.addEventListener('message', (event) => {
56
61
  const message = event.data;
57
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
62
+ if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
58
63
  if (typeof message.theme !== 'string') return;
59
64
  document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
60
65
  document.documentElement.setAttribute('data-theme', message.theme);
@@ -74,12 +79,12 @@ function buildPresentationEscapeBridge(exitToken: string): string {
74
79
  const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
75
80
  document.addEventListener('keydown', (event) => {
76
81
  if (event.key === 'Escape') {
77
- window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
82
+ window.parent.postMessage({ source: '${HTML_SURFACE_PUSH_SOURCE}', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
78
83
  }
79
84
  }, true);
80
85
  window.addEventListener('message', (event) => {
81
86
  const message = event.data;
82
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
87
+ if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
83
88
  if (typeof message.key !== 'string') return;
84
89
  if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
85
90
  window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
@@ -131,7 +136,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
131
136
  seq += 1;
132
137
  const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
133
138
  window.parent.postMessage({
134
- source: 'pmx-canvas-ax',
139
+ source: '${AX_SURFACE_EMIT_SOURCE}',
135
140
  token: PMX_AX_TOKEN,
136
141
  nodeId: PMX_AX_NODE_ID,
137
142
  correlationId: correlationId,
@@ -152,7 +157,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
152
157
  };
153
158
  window.addEventListener('message', function (event) {
154
159
  const m = event.data;
155
- if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
160
+ if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
156
161
  const result = m.result || { ok: false };
157
162
  const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
158
163
  if (resolver) { pending.delete(m.correlationId); resolver(result); }
@@ -182,7 +187,7 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
182
187
  window.PMX_AX.state = ${snapshotJson};
183
188
  window.addEventListener('message', function (event) {
184
189
  const m = event.data;
185
- if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
190
+ if (!m || m.source !== '${HTML_SURFACE_PUSH_SOURCE}' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
186
191
  window.PMX_AX.state = m.state;
187
192
  try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
188
193
  });