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.
- package/CHANGELOG.md +46 -0
- package/Readme.md +2 -1
- package/dist/canvas/index.js +83 -83
- package/dist/json-render/index.js +47 -47
- package/dist/types/cli/agent.d.ts +13 -8
- package/dist/types/cli/commands/apps.d.ts +1 -0
- package/dist/types/cli/commands/ax.d.ts +1 -0
- package/dist/types/cli/commands/copilot.d.ts +1 -0
- package/dist/types/cli/commands/edges.d.ts +1 -0
- package/dist/types/cli/commands/groups.d.ts +1 -0
- package/dist/types/cli/commands/history.d.ts +1 -0
- package/dist/types/cli/commands/nodes.d.ts +1 -0
- package/dist/types/cli/commands/pins.d.ts +1 -0
- package/dist/types/cli/commands/query.d.ts +1 -0
- package/dist/types/cli/commands/view.d.ts +1 -0
- package/dist/types/cli/commands/webview.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +73 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
- package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
- package/dist/types/server/canvas-operations.d.ts +2 -0
- package/dist/types/server/diagram-presets.d.ts +8 -0
- package/dist/types/server/operations/composites.d.ts +0 -9
- package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
- package/docs/bun-webview-integration.md +5 -0
- package/docs/environment.md +48 -0
- package/docs/node-types.md +5 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +27 -9
- package/docs/screenshot.png +0 -0
- package/package.json +8 -5
- package/skills/pmx-canvas/evals/evals.json +5 -5
- package/skills/pmx-canvas/references/full-reference.md +8 -1
- package/src/cli/agent.ts +22 -3512
- package/src/cli/commands/apps.ts +165 -0
- package/src/cli/commands/ax.ts +604 -0
- package/src/cli/commands/copilot.ts +43 -0
- package/src/cli/commands/edges.ts +73 -0
- package/src/cli/commands/groups.ts +81 -0
- package/src/cli/commands/history.ts +220 -0
- package/src/cli/commands/nodes.ts +954 -0
- package/src/cli/commands/pins.ts +42 -0
- package/src/cli/commands/query.ts +223 -0
- package/src/cli/commands/view.ts +186 -0
- package/src/cli/commands/webview.ts +227 -0
- package/src/cli/shared.ts +860 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
- package/src/client/nodes/ExtAppFrame.tsx +53 -49
- package/src/client/nodes/HtmlNode.tsx +8 -51
- package/src/client/nodes/McpAppNode.tsx +12 -47
- package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
- package/src/json-render/renderer/index.tsx +3 -2
- package/src/mcp/server.ts +0 -47
- package/src/server/canvas-operations.ts +8 -0
- package/src/server/diagram-presets.ts +15 -1
- package/src/server/html-surface.ts +11 -6
- package/src/server/operations/composites.ts +1 -13
- package/src/server/operations/invoker.ts +10 -3
- package/src/server/operations/mcp.ts +51 -1
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/server.ts +2 -0
- package/src/shared/ax-surface-protocol.ts +22 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
- 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 ===
|
|
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:
|
|
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: '
|
|
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 !== '
|
|
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: '
|
|
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 !==
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
68
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
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 !==
|
|
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:
|
|
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:
|
|
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 !== '
|
|
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: '
|
|
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 !== '
|
|
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: '
|
|
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 !== '
|
|
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 !== '
|
|
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
|
});
|