pmx-canvas 0.3.2 → 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 (70) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/Readme.md +2 -1
  3. package/dist/canvas/index.js +85 -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/daemon.d.ts +17 -0
  18. package/dist/types/cli/shared.d.ts +73 -0
  19. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
  20. package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -0
  21. package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
  22. package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
  23. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
  24. package/dist/types/server/canvas-operations.d.ts +2 -0
  25. package/dist/types/server/diagram-presets.d.ts +8 -0
  26. package/dist/types/server/operations/composites.d.ts +0 -9
  27. package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
  28. package/docs/RELEASE.md +11 -0
  29. package/docs/bun-webview-integration.md +5 -0
  30. package/docs/environment.md +48 -0
  31. package/docs/node-types.md +5 -0
  32. package/docs/plans/plan-009-tech-debt-backlog.md +27 -9
  33. package/docs/screenshot.png +0 -0
  34. package/package.json +8 -5
  35. package/skills/pmx-canvas/SKILL.md +11 -8
  36. package/skills/pmx-canvas/evals/evals.json +5 -5
  37. package/skills/pmx-canvas/references/full-reference.md +8 -1
  38. package/src/cli/agent.ts +22 -3512
  39. package/src/cli/commands/apps.ts +165 -0
  40. package/src/cli/commands/ax.ts +604 -0
  41. package/src/cli/commands/copilot.ts +43 -0
  42. package/src/cli/commands/edges.ts +73 -0
  43. package/src/cli/commands/groups.ts +81 -0
  44. package/src/cli/commands/history.ts +220 -0
  45. package/src/cli/commands/nodes.ts +954 -0
  46. package/src/cli/commands/pins.ts +42 -0
  47. package/src/cli/commands/query.ts +223 -0
  48. package/src/cli/commands/view.ts +186 -0
  49. package/src/cli/commands/webview.ts +227 -0
  50. package/src/cli/daemon.ts +39 -7
  51. package/src/cli/shared.ts +860 -0
  52. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
  53. package/src/client/nodes/ExtAppFrame.tsx +166 -52
  54. package/src/client/nodes/HtmlNode.tsx +8 -51
  55. package/src/client/nodes/McpAppNode.tsx +12 -47
  56. package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
  57. package/src/json-render/renderer/index.tsx +3 -2
  58. package/src/mcp/server.ts +0 -47
  59. package/src/server/bundled-skills.ts +5 -1
  60. package/src/server/canvas-operations.ts +8 -0
  61. package/src/server/diagram-presets.ts +15 -1
  62. package/src/server/html-surface.ts +11 -6
  63. package/src/server/operations/composites.ts +1 -13
  64. package/src/server/operations/invoker.ts +10 -3
  65. package/src/server/operations/mcp.ts +51 -1
  66. package/src/server/operations/ops/app.ts +5 -0
  67. package/src/server/server.ts +61 -3
  68. package/src/shared/ax-surface-protocol.ts +22 -0
  69. package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
  70. 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
 
@@ -92,6 +96,33 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
92
96
  const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
93
97
  host.__PMX_EXTAPP_LOG ??= [];
94
98
  if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
99
+ queueExtAppRecoveryReport({ t: Date.now(), nodeId, event });
100
+ }
101
+
102
+ // Mirror the recovery trail to the daemon (batched, fire-and-forget) so a host
103
+ // panel without devtools access — the Copilot WKWebView, exactly where the
104
+ // black tiles happen — still yields an actionable trace: agents read it back
105
+ // via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
106
+ let extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
107
+ let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
108
+ function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
109
+ extAppReportQueue.push({
110
+ ...entry,
111
+ ...(typeof document !== 'undefined' ? { visibility: document.visibilityState } : {}),
112
+ });
113
+ if (extAppReportTimer) return;
114
+ extAppReportTimer = setTimeout(() => {
115
+ extAppReportTimer = null;
116
+ const entries = extAppReportQueue.splice(0);
117
+ if (entries.length === 0) return;
118
+ void fetch('/api/canvas/debug/ext-app-recovery', {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ entries }),
122
+ }).catch(() => {
123
+ // Diagnostics only — never let reporting failures affect recovery.
124
+ });
125
+ }, 1500);
95
126
  }
96
127
 
97
128
  let webkitRemountChain: Promise<void> = Promise.resolve();
@@ -178,7 +209,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
178
209
  }, 10000);
179
210
  pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
180
211
  window.parent.postMessage({
181
- source: 'pmx-canvas-ax',
212
+ source: '${AX_SURFACE_EMIT_SOURCE}',
182
213
  token: PMX_AX_TOKEN,
183
214
  nodeId: PMX_AX_NODE_ID,
184
215
  correlationId: correlationId,
@@ -191,7 +222,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
191
222
  };
192
223
  window.addEventListener('message', function (event) {
193
224
  const m = event.data;
194
- 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;
195
226
  const result = m.result || { ok: false };
196
227
  const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
197
228
  if (resolver) { pending.delete(m.correlationId); resolver(result); }
@@ -204,6 +235,24 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
204
235
  </script>`;
205
236
  }
206
237
 
238
+ /**
239
+ * Boot beacon injected into EVERY ext-app document (not gated on AX): posts one
240
+ * parent message the moment the iframe's scripts execute. This is the liveness
241
+ * signal the WebKit never-booted watchdog keys on — it distinguishes an app
242
+ * that is alive but boots via the 1200ms bootstrap fallback (e.g. a non-SDK
243
+ * widget that never sends ui/notifications/initialized) from a dead window
244
+ * whose scripts never ran. Without it, the watchdog treated every
245
+ * fallback-booted app as never-booted and remounted it up to the attempt cap —
246
+ * a reboot/flicker loop for exactly the apps the fallback exists to support
247
+ * (0.3.2 pre-release review). The token scopes the message to this component;
248
+ * the host also checks event.source against the live iframe.
249
+ */
250
+ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
251
+ return `<script data-pmx-canvas-boot-beacon>
252
+ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
253
+ </script>`;
254
+ }
255
+
207
256
  export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
208
257
  if (!axBridgeScript) return html;
209
258
  const headMatch = /<head\b[^>]*>/i.exec(html);
@@ -262,8 +311,15 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
262
311
  // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
263
312
  // which flips status via notifications that resolve even into a dead iframe.
264
313
  const appInitializedRef = useRef(false);
314
+ // Liveness signal from the injected boot beacon: the CURRENT frame's scripts
315
+ // executed. Proves "alive" for apps that boot via the fallback path without
316
+ // ever sending initialized; a dead window never beacons.
317
+ const appScriptsRanRef = useRef(false);
265
318
  const bootWaitersRef = useRef<Array<() => void>>([]);
266
319
  const remountQueuedRef = useRef(false);
320
+ // Recovery ran while the document was hidden (compositing suppressed) — re-arm
321
+ // one fresh remount round when the panel becomes visible (Finding N).
322
+ const bootedWhileHiddenRef = useRef(false);
267
323
  const unmountedRef = useRef(false);
268
324
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
269
325
  const [error, setError] = useState<string | null>(null);
@@ -298,48 +354,29 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
298
354
  const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
299
355
  const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
300
356
  const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
301
- const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
357
+ // The boot beacon precedes the AX bridge so it runs first at parse time; it
358
+ // shares the per-component token (used purely to authenticate the message).
359
+ const bootBeaconScript =
360
+ typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
361
+ const iframeDocument = useIframeDocument(
362
+ injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
363
+ iframeSandbox,
364
+ );
302
365
 
303
366
  useEffect(() => {
304
- if (!axEnabled) return;
305
- function onAxMessage(event: MessageEvent) {
367
+ function onBootBeacon(event: MessageEvent) {
306
368
  if (event.source !== iframeRef.current?.contentWindow) return;
307
- const data = event.data as {
308
- source?: string;
309
- token?: string;
310
- nodeId?: string;
311
- correlationId?: string;
312
- interaction?: { type?: unknown; payload?: unknown };
313
- } | null;
314
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
315
- const interaction = data.interaction;
316
- if (!interaction || typeof interaction.type !== 'string') return;
317
- const interactionType = interaction.type;
318
- void submitAxInteractionFromClient({
319
- type: interactionType,
320
- sourceNodeId: nodeId,
321
- sourceSurface: 'mcp-app',
322
- ...(interaction.payload && typeof interaction.payload === 'object'
323
- ? { payload: interaction.payload as Record<string, unknown> }
324
- : {}),
325
- }).then((res) => {
326
- if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
327
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
328
- iframeRef.current?.contentWindow?.postMessage(
329
- {
330
- source: 'pmx-canvas-ax-ack',
331
- token: axToken,
332
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
333
- interaction: { type: interactionType },
334
- result: res,
335
- },
336
- '*',
337
- );
338
- });
369
+ const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
370
+ if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
371
+ return;
372
+ appScriptsRanRef.current = true;
339
373
  }
340
- window.addEventListener('message', onAxMessage);
341
- return () => window.removeEventListener('message', onAxMessage);
342
- }, [axEnabled, axToken, nodeId]);
374
+ window.addEventListener('message', onBootBeacon);
375
+ return () => window.removeEventListener('message', onBootBeacon);
376
+ }, [axToken, nodeId]);
377
+
378
+ // Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
379
+ useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
343
380
 
344
381
  // Enqueue one serialized remount attempt for this node (Finding F recovery).
345
382
  // Attempts are capped so a persistently-failing app degrades to the manual
@@ -361,7 +398,18 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
361
398
  extAppRecoveryLog(nodeId, 'remount-skipped');
362
399
  return false;
363
400
  }
401
+ // Stamp visibility at RUN time, not schedule time: the serialized queue
402
+ // can hold this slot for many seconds, and what decides whether the
403
+ // repaint can composite is the document's visibility when the remount
404
+ // actually executes (the Finding N re-arm keys on this flag). Covers
405
+ // every scheduling path (post-boot repaint, boot watchdog, re-arm).
406
+ bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
364
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');
365
413
  setRetryKey((k) => k + 1);
366
414
  return true;
367
415
  },
@@ -401,11 +449,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
401
449
  // eslint-disable-next-line react-hooks/exhaustive-deps
402
450
  }, [status, hasReplayToolResult]);
403
451
 
404
- // Never-booted watchdog (0.3.1): an iframe whose scripts never ran in the burst
452
+ // Finding N (0.3.2 report): the Copilot panel can load the canvas while the
453
+ // WKWebView document is HIDDEN (panel backgrounded/occluded). WebKit suppresses
454
+ // compositing for hidden documents, so the whole serialized recovery above can
455
+ // run to completion without ever producing a visible paint — and nothing
456
+ // retried once the panel was shown, which is why tiles stayed black until a
457
+ // manual expand+close (necessarily performed while visible). When the document
458
+ // becomes visible again, re-arm ONE fresh serialized remount round for frames
459
+ // whose recovery ran while hidden.
460
+ useEffect(() => {
461
+ if (expanded) return;
462
+ if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
463
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
464
+ function onVisibilityChange() {
465
+ if (document.visibilityState !== 'visible') return;
466
+ if (!bootedWhileHiddenRef.current || unmountedRef.current) return;
467
+ bootedWhileHiddenRef.current = false;
468
+ // Fresh visibility = fresh recovery budget: the hidden-run attempts were
469
+ // spent without a chance of compositing.
470
+ webkitRemountAttemptsRef.current = 0;
471
+ extAppRecoveryLog(nodeId, 'visible-again-rearm');
472
+ scheduleWebkitRemount('visible-after-hidden-boot');
473
+ }
474
+ document.addEventListener('visibilitychange', onVisibilityChange);
475
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
476
+ // eslint-disable-next-line react-hooks/exhaustive-deps
477
+ }, [expanded, nodeId]);
478
+
479
+ // Never-booted watchdog (0.3.2): an iframe whose scripts never ran in the burst
405
480
  // shows no error — the bootstrap fallback flips status via notifications that
406
- // resolve into a dead window, so the tile just stays black. If the CURRENT frame
407
- // has not completed the genuine initialize handshake within the watchdog window,
408
- // retry it through the same serialized queue (bounded by the shared attempt cap).
481
+ // resolve into a dead window, so the tile just stays black. "Alive" is either
482
+ // boot signal from the CURRENT frame: the genuine initialize handshake, or the
483
+ // injected boot beacon (which covers apps that boot via the fallback without
484
+ // ever sending initialized — remounting those was a flicker loop, and checking
485
+ // bridgeReadyRef instead would be wrong: the fallback sets it blindly, dead
486
+ // window or not). Only a frame with NEITHER signal is retried through the
487
+ // serialized queue (bounded by the shared attempt cap).
409
488
  const WEBKIT_BOOT_WATCHDOG_MS = 6000;
410
489
  useEffect(() => {
411
490
  if (expanded) return;
@@ -413,7 +492,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
413
492
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
414
493
  if (!iframeDocument.ready) return;
415
494
  const timer = window.setTimeout(() => {
416
- if (appInitializedRef.current || unmountedRef.current) return;
495
+ if (appInitializedRef.current || appScriptsRanRef.current || unmountedRef.current) return;
417
496
  scheduleWebkitRemount('boot-watchdog');
418
497
  }, WEBKIT_BOOT_WATCHDOG_MS);
419
498
  return () => window.clearTimeout(timer);
@@ -494,9 +573,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
494
573
  lastSentToolResultRef.current = undefined;
495
574
  toolResultSendingRef.current = null;
496
575
  bridgeReadyRef.current = false;
497
- // New frame = new boot attempt: the genuine-initialized signal belongs to the
498
- // CURRENT iframe. The queue's awaitBoot waits on this frame's handshake.
576
+ // New frame = new boot attempt: both boot signals belong to the CURRENT
577
+ // iframe (the beacon listener also pins event.source to the live element).
578
+ // The queue's awaitBoot waits on this frame's handshake.
499
579
  appInitializedRef.current = false;
580
+ appScriptsRanRef.current = false;
500
581
 
501
582
  const clearFallbackTimer = (): void => {
502
583
  if (!fallbackTimer) return;
@@ -967,9 +1048,6 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
967
1048
  </button>
968
1049
  </div>
969
1050
  )}
970
- {status === 'loading' && (
971
- <div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
972
- )}
973
1051
  {/* Iframe stack: the widget renders a preview; when not expanded, a
974
1052
  transparent click-catcher sits on top so the first click always
975
1053
  expands the node. Without this, widgets like Excalidraw show their
@@ -999,6 +1077,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
999
1077
  }}
1000
1078
  title={`Ext App: ${toolName}`}
1001
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
+ )}
1002
1116
  {!isExpanded && (
1003
1117
  <button
1004
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 : {} },