pmx-canvas 0.2.1 → 0.2.2
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 +86 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +260 -0
- package/dist/canvas/index.js +76 -76
- package/dist/json-render/index.js +2 -2
- package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +10 -0
- package/dist/types/client/state/intent-store.d.ts +25 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/server/index.d.ts +34 -4
- package/dist/types/server/intent-registry.d.ts +45 -0
- package/dist/types/server/operations/ops/intent.d.ts +2 -0
- package/dist/types/shared/ax-intent.d.ts +58 -0
- package/docs/mcp.md +21 -2
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +197 -1305
- package/skills/pmx-canvas/evals/evals.json +199 -0
- package/skills/pmx-canvas/references/full-reference.md +1441 -0
- package/src/cli/index.ts +21 -4
- package/src/client/canvas/CanvasNode.tsx +13 -13
- package/src/client/canvas/CanvasViewport.tsx +2 -0
- package/src/client/canvas/ContextMenu.tsx +25 -19
- package/src/client/canvas/IntentLayer.tsx +278 -0
- package/src/client/nodes/ExtAppFrame.tsx +31 -22
- package/src/client/state/intent-bridge.ts +31 -0
- package/src/client/state/intent-store.ts +107 -0
- package/src/client/state/sse-bridge.ts +31 -0
- package/src/client/theme/global.css +260 -0
- package/src/json-render/charts/components.tsx +18 -4
- package/src/json-render/renderer/index.tsx +11 -2
- package/src/json-render/server.ts +1 -1
- package/src/server/index.ts +240 -158
- package/src/server/intent-registry.ts +324 -0
- package/src/server/operations/composites.ts +11 -0
- package/src/server/operations/index.ts +2 -0
- package/src/server/operations/ops/edges.ts +1 -0
- package/src/server/operations/ops/groups.ts +3 -0
- package/src/server/operations/ops/intent.ts +132 -0
- package/src/server/operations/ops/json-render.ts +3 -0
- package/src/server/operations/ops/nodes.ts +3 -0
- package/src/server/operations/registry.ts +68 -3
- package/src/server/server.ts +40 -12
- package/src/shared/ax-intent.ts +64 -0
- package/src/shared/surface.ts +5 -1
package/src/server/server.ts
CHANGED
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
syncCanvasRuntimeBackends,
|
|
90
90
|
} from './canvas-operations.js';
|
|
91
91
|
import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
|
|
92
|
+
import { intentRegistry } from './intent-registry.js';
|
|
92
93
|
import { setWebviewRunner } from './operations/webview-runner.js';
|
|
93
94
|
import {
|
|
94
95
|
closeNodeAppSession,
|
|
@@ -135,6 +136,15 @@ setOperationEventEmitter((event, payload) => {
|
|
|
135
136
|
emitPrimaryWorkbenchEvent(event, payload);
|
|
136
137
|
});
|
|
137
138
|
|
|
139
|
+
// Ghost Cursor of Intent SSE wiring: the IntentRegistry never imports this
|
|
140
|
+
// module — its `ax-intent` / `ax-intent-clear` frames (including the autonomous
|
|
141
|
+
// TTL-expiry sweeper) are emitted through the injected workbench emitter, same
|
|
142
|
+
// pattern as setOperationEventEmitter. Wired at module top level so in-process
|
|
143
|
+
// MCP/SDK intent signals reach the browser without startCanvasServer().
|
|
144
|
+
intentRegistry.setEmitter((event, payload) => {
|
|
145
|
+
emitPrimaryWorkbenchEvent(event, payload);
|
|
146
|
+
});
|
|
147
|
+
|
|
138
148
|
// Webview-runner wiring (plan-008 Wave 3): the webview ops never import this
|
|
139
149
|
// module — the Bun.WebView automation runner is injected here, mirroring the
|
|
140
150
|
// setOperationEventEmitter pattern. The closures call the real automation
|
|
@@ -182,6 +192,7 @@ function normalizeGraphViewerSpec(
|
|
|
182
192
|
const graphConfig = node.data.graphConfig;
|
|
183
193
|
if (
|
|
184
194
|
display !== 'expanded' &&
|
|
195
|
+
display !== 'site' &&
|
|
185
196
|
graphConfig &&
|
|
186
197
|
typeof graphConfig === 'object' &&
|
|
187
198
|
typeof (graphConfig as Record<string, unknown>).height === 'number'
|
|
@@ -1319,8 +1330,11 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1319
1330
|
|
|
1320
1331
|
if (node.type === 'json-render' || node.type === 'graph') {
|
|
1321
1332
|
const params = new URLSearchParams({ nodeId, theme });
|
|
1333
|
+
// "Open as site" is a standalone browser tab, so the viewer should fill the
|
|
1334
|
+
// viewport (report #65) — distinct from the in-canvas expand overlay, which hits
|
|
1335
|
+
// the viewer route directly with display=expanded.
|
|
1322
1336
|
const display = url.searchParams.get('display');
|
|
1323
|
-
|
|
1337
|
+
params.set('display', display === 'expanded' ? 'expanded' : 'site');
|
|
1324
1338
|
return surfaceRedirect(`/api/canvas/json-render/view?${params.toString()}`);
|
|
1325
1339
|
}
|
|
1326
1340
|
|
|
@@ -1329,19 +1343,16 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1329
1343
|
if (node.data.viewerType === 'web-artifact' && typeof node.data.path === 'string' && node.data.path) {
|
|
1330
1344
|
return surfaceRedirect(`/artifact?path=${encodeURIComponent(node.data.path)}`);
|
|
1331
1345
|
}
|
|
1332
|
-
// Hosted ext-app — serve the same prepared HTML the in-canvas frame receives.
|
|
1333
|
-
// The app's host bridge has no peer in a standalone tab, so interactive
|
|
1334
|
-
// tool-calls won't function there; the UI still renders. Served TOP-LEVEL with
|
|
1335
|
-
// a tighter sandbox than the in-canvas iframe (no allow-popups-to-escape-sandbox)
|
|
1336
|
-
// since this is untrusted third-party HTML opened as its own page.
|
|
1337
|
-
if (node.data.mode === 'ext-app' && typeof node.data.html === 'string' && node.data.html) {
|
|
1338
|
-
return surfaceHtmlResponse(node.data.html, HTML_SURFACE_SANDBOX);
|
|
1339
|
-
}
|
|
1340
1346
|
// URL-backed viewer — hand off to its own origin.
|
|
1341
1347
|
if (typeof node.data.url === 'string' && isSafeSurfaceRedirect(node.data.url)) {
|
|
1342
1348
|
return surfaceRedirect(node.data.url);
|
|
1343
1349
|
}
|
|
1344
|
-
|
|
1350
|
+
// Hosted ext-app (e.g. Excalidraw) is a LIVE MCP-app shell that only renders with
|
|
1351
|
+
// the in-canvas AppBridge host; served to a standalone tab it errored with
|
|
1352
|
+
// `-32601` (report #61). It is intentionally NOT openable as a site — open such
|
|
1353
|
+
// apps externally through their own app, or view them in the canvas. (Kept in sync
|
|
1354
|
+
// with canOpenNodeAsSurface in src/shared/surface.ts, which hides the UI control.)
|
|
1355
|
+
return responseText('This MCP app renders in the canvas and cannot be opened as a standalone site.', 404);
|
|
1345
1356
|
}
|
|
1346
1357
|
|
|
1347
1358
|
if (node.type === 'webpage') {
|
|
@@ -1572,12 +1583,18 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
1572
1583
|
const axToken = url.searchParams.get('axToken');
|
|
1573
1584
|
const axEnabled = resolveNodeAxCapabilities(node).enabled;
|
|
1574
1585
|
const frameToken = url.searchParams.get('frameToken');
|
|
1575
|
-
const
|
|
1586
|
+
const displayParam = url.searchParams.get('display');
|
|
1587
|
+
const display = displayParam === 'expanded' ? 'expanded' as const
|
|
1588
|
+
: displayParam === 'site' ? 'site' as const
|
|
1589
|
+
: undefined;
|
|
1590
|
+
// A standalone "site" tab fills the viewport; content-fit (grow-to-content for the
|
|
1591
|
+
// in-canvas iframe) would fight that, so it's ignored in site mode (#65).
|
|
1592
|
+
const fitContent = url.searchParams.get('fit') === 'content' && display !== 'site';
|
|
1576
1593
|
const html = await buildJsonRenderViewerHtml({
|
|
1577
1594
|
title,
|
|
1578
1595
|
spec,
|
|
1579
1596
|
...(theme ? { theme } : {}),
|
|
1580
|
-
...(
|
|
1597
|
+
...(display ? { display } : {}),
|
|
1581
1598
|
...(devtoolsEnabled ? { devtools: true } : {}),
|
|
1582
1599
|
...(axToken ? { nodeId, axToken } : {}),
|
|
1583
1600
|
// Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
|
|
@@ -2164,6 +2181,16 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
2164
2181
|
timestamp: new Date().toISOString(),
|
|
2165
2182
|
}),
|
|
2166
2183
|
);
|
|
2184
|
+
for (const intent of intentRegistry.list()) {
|
|
2185
|
+
controller.enqueue(
|
|
2186
|
+
toSseFrame('ax-intent', {
|
|
2187
|
+
intent,
|
|
2188
|
+
reason: 'connect-snapshot',
|
|
2189
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2190
|
+
timestamp: new Date().toISOString(),
|
|
2191
|
+
}),
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2167
2194
|
pingTimer = setInterval(() => {
|
|
2168
2195
|
try {
|
|
2169
2196
|
controller.enqueue(
|
|
@@ -3799,6 +3826,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3799
3826
|
}
|
|
3800
3827
|
|
|
3801
3828
|
export function stopCanvasServer(): void {
|
|
3829
|
+
intentRegistry.reset();
|
|
3802
3830
|
canvasState.close();
|
|
3803
3831
|
closeAllMcpAppSessions();
|
|
3804
3832
|
setCanvasLayoutUpdateEmitter(null);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghost Cursor of Intent — the shared pre-commit "intent" envelope.
|
|
3
|
+
*
|
|
4
|
+
* An intent is EPHEMERAL PRESENCE, not canvas state: it describes a move the
|
|
5
|
+
* agent is ABOUT to make (create / move / connect / remove / edit) so the canvas
|
|
6
|
+
* can paint a faint placeholder before the real mutation lands. Like a
|
|
7
|
+
* multiplayer cursor, it auto-expires, is count-capped, and never enters
|
|
8
|
+
* `canvas_get_layout`, `state.json`, or snapshots.
|
|
9
|
+
*
|
|
10
|
+
* This module is import-shared by the server (IntentRegistry + the intent ops)
|
|
11
|
+
* and the client (intent-store + IntentLayer); it must stay free of any
|
|
12
|
+
* server-only or client-only imports.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type PmxAxIntentKind = 'create' | 'move' | 'connect' | 'remove' | 'edit';
|
|
16
|
+
|
|
17
|
+
export type PmxAxIntentEdgeType = 'relation' | 'depends-on' | 'flow' | 'references';
|
|
18
|
+
|
|
19
|
+
export interface PmxAxIntentEdge {
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
type: PmxAxIntentEdgeType;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PmxAxIntent {
|
|
26
|
+
/** Stable id → update / clear / veto. Auto-generated when a signal omits it. */
|
|
27
|
+
id: string;
|
|
28
|
+
kind: PmxAxIntentKind;
|
|
29
|
+
/** create: where the new node forms. move: the destination. */
|
|
30
|
+
position?: { x: number; y: number };
|
|
31
|
+
/** move / edit / remove: the existing node the intent targets. */
|
|
32
|
+
nodeId?: string;
|
|
33
|
+
/** connect: the edge about to be drawn. */
|
|
34
|
+
edge?: PmxAxIntentEdge;
|
|
35
|
+
/** Node type the ghost renders (icon + type badge). Defaults to a neutral box. */
|
|
36
|
+
nodeType?: string;
|
|
37
|
+
/** Short action label shown on the ghost chip ("Add evidence"). */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** WHY — shown beneath the ghost. The legibility payoff. */
|
|
40
|
+
reason?: string;
|
|
41
|
+
/** 0..1 → ghost opacity/solidity. */
|
|
42
|
+
confidence?: number;
|
|
43
|
+
/** Ordering hint for staged-batch ghosts (the numbered previsualization rail). */
|
|
44
|
+
seq?: number;
|
|
45
|
+
/** Source label of the surface that signalled the intent (mcp/api/sdk/...). */
|
|
46
|
+
source?: string;
|
|
47
|
+
/** Epoch ms when the intent was first signalled. */
|
|
48
|
+
createdAt: number;
|
|
49
|
+
/** Epoch ms when the intent auto-dissolves if not settled/cleared first. */
|
|
50
|
+
expiresAt: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const INTENT_KINDS: PmxAxIntentKind[] = ['create', 'move', 'connect', 'remove', 'edit'];
|
|
54
|
+
|
|
55
|
+
export const INTENT_EDGE_TYPES: PmxAxIntentEdgeType[] = ['relation', 'depends-on', 'flow', 'references'];
|
|
56
|
+
|
|
57
|
+
/** Default lifetime of an unsettled ghost. */
|
|
58
|
+
export const DEFAULT_INTENT_TTL_MS = 8000;
|
|
59
|
+
|
|
60
|
+
/** Hard ceiling on TTL so a stuck ghost can never linger. */
|
|
61
|
+
export const MAX_INTENT_TTL_MS = 60000;
|
|
62
|
+
|
|
63
|
+
/** Live-intent cap — oldest is evicted past this (presence, not a queue). */
|
|
64
|
+
export const MAX_LIVE_INTENTS = 12;
|
package/src/shared/surface.ts
CHANGED
|
@@ -19,8 +19,12 @@ export function canOpenNodeAsSurface(type: string, data: Record<string, unknown>
|
|
|
19
19
|
case 'graph':
|
|
20
20
|
return true;
|
|
21
21
|
case 'mcp-app':
|
|
22
|
+
// Hosted ext-app nodes (e.g. Excalidraw) are a LIVE MCP-app shell that needs the
|
|
23
|
+
// in-canvas AppBridge host to render; a standalone browser tab has no host, so
|
|
24
|
+
// "Open as site" only produced a broken `-32601` page (report #61). Those apps
|
|
25
|
+
// open externally through their own app, not PMX. Only a bundled web-artifact
|
|
26
|
+
// (static) or a real url-backed viewer can open as a standalone site.
|
|
22
27
|
return (data.viewerType === 'web-artifact' && typeof data.path === 'string' && data.path.length > 0)
|
|
23
|
-
|| (data.mode === 'ext-app' && typeof data.html === 'string' && data.html.length > 0)
|
|
24
28
|
|| (typeof data.url === 'string' && data.url.length > 0);
|
|
25
29
|
case 'webpage':
|
|
26
30
|
return typeof data.url === 'string' && data.url.length > 0;
|