pmx-canvas 0.2.0 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +260 -0
  4. package/dist/canvas/index.js +76 -76
  5. package/dist/json-render/index.js +2 -2
  6. package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +10 -0
  8. package/dist/types/client/state/intent-store.d.ts +25 -0
  9. package/dist/types/json-render/server.d.ts +1 -1
  10. package/dist/types/server/ax-state-manager.d.ts +11 -0
  11. package/dist/types/server/ax-state.d.ts +2 -0
  12. package/dist/types/server/canvas-db.d.ts +13 -0
  13. package/dist/types/server/canvas-state.d.ts +5 -0
  14. package/dist/types/server/index.d.ts +34 -4
  15. package/dist/types/server/intent-registry.d.ts +45 -0
  16. package/dist/types/server/operations/ops/intent.d.ts +2 -0
  17. package/dist/types/shared/ax-intent.d.ts +58 -0
  18. package/docs/ax-host-adapter-contract.md +19 -1
  19. package/docs/http-api.md +4 -0
  20. package/docs/mcp.md +22 -3
  21. package/docs/screenshot.png +0 -0
  22. package/package.json +1 -1
  23. package/skills/pmx-canvas/SKILL.md +197 -1283
  24. package/skills/pmx-canvas/evals/evals.json +199 -0
  25. package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
  26. package/skills/pmx-canvas/references/full-reference.md +1441 -0
  27. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +23 -7
  28. package/src/cli/index.ts +21 -4
  29. package/src/client/canvas/CanvasNode.tsx +13 -13
  30. package/src/client/canvas/CanvasViewport.tsx +2 -0
  31. package/src/client/canvas/ContextMenu.tsx +25 -19
  32. package/src/client/canvas/IntentLayer.tsx +278 -0
  33. package/src/client/nodes/ExtAppFrame.tsx +31 -22
  34. package/src/client/state/intent-bridge.ts +31 -0
  35. package/src/client/state/intent-store.ts +107 -0
  36. package/src/client/state/sse-bridge.ts +31 -0
  37. package/src/client/theme/global.css +260 -0
  38. package/src/json-render/charts/components.tsx +18 -4
  39. package/src/json-render/renderer/index.tsx +11 -2
  40. package/src/json-render/server.ts +1 -1
  41. package/src/server/ax-context.ts +8 -1
  42. package/src/server/ax-state-manager.ts +18 -0
  43. package/src/server/ax-state.ts +8 -0
  44. package/src/server/canvas-db.ts +35 -0
  45. package/src/server/canvas-state.ts +8 -0
  46. package/src/server/index.ts +240 -158
  47. package/src/server/intent-registry.ts +324 -0
  48. package/src/server/operations/composites.ts +11 -0
  49. package/src/server/operations/index.ts +2 -0
  50. package/src/server/operations/ops/edges.ts +1 -0
  51. package/src/server/operations/ops/groups.ts +3 -0
  52. package/src/server/operations/ops/intent.ts +132 -0
  53. package/src/server/operations/ops/json-render.ts +3 -0
  54. package/src/server/operations/ops/nodes.ts +3 -0
  55. package/src/server/operations/registry.ts +68 -3
  56. package/src/server/server.ts +40 -12
  57. package/src/shared/ax-intent.ts +64 -0
  58. package/src/shared/surface.ts +5 -1
@@ -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
- if (display === 'expanded') params.set('display', 'expanded');
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
- return responseText('MCP app node has no openable surface', 404);
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 fitContent = url.searchParams.get('fit') === 'content';
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
- ...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
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;
@@ -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;