pmx-canvas 0.1.28 → 0.1.30

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 (67) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/Readme.md +20 -10
  3. package/dist/canvas/global.css +13 -0
  4. package/dist/canvas/index.js +80 -163
  5. package/dist/canvas/surface-theme.css +142 -0
  6. package/dist/json-render/index.js +103 -103
  7. package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
  8. package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
  9. package/dist/types/client/nodes/surface-url.d.ts +22 -0
  10. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +17 -0
  12. package/dist/types/json-render/renderer/index.d.ts +2 -0
  13. package/dist/types/json-render/schema.d.ts +2 -0
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/mcp/canvas-access.d.ts +47 -0
  16. package/dist/types/server/ax-interaction.d.ts +210 -0
  17. package/dist/types/server/ax-state.d.ts +67 -1
  18. package/dist/types/server/canvas-db.d.ts +4 -0
  19. package/dist/types/server/canvas-serialization.d.ts +2 -0
  20. package/dist/types/server/canvas-state.d.ts +47 -2
  21. package/dist/types/server/html-surface.d.ts +40 -0
  22. package/dist/types/server/index.d.ts +56 -2
  23. package/dist/types/server/mutation-history.d.ts +1 -1
  24. package/dist/types/server/placement.d.ts +1 -1
  25. package/dist/types/shared/surface.d.ts +19 -0
  26. package/docs/cli.md +30 -0
  27. package/docs/http-api.md +55 -0
  28. package/docs/mcp.md +40 -2
  29. package/docs/node-types.md +26 -0
  30. package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
  31. package/docs/sdk.md +20 -0
  32. package/package.json +2 -2
  33. package/skills/pmx-canvas/SKILL.md +107 -9
  34. package/src/cli/agent.ts +190 -0
  35. package/src/client/canvas/CanvasNode.tsx +8 -4
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
  37. package/src/client/nodes/ContextNode.tsx +17 -0
  38. package/src/client/nodes/ExtAppFrame.tsx +40 -3
  39. package/src/client/nodes/FileNode.tsx +26 -0
  40. package/src/client/nodes/HtmlNode.tsx +60 -188
  41. package/src/client/nodes/LedgerNode.tsx +39 -5
  42. package/src/client/nodes/McpAppNode.tsx +47 -2
  43. package/src/client/nodes/StatusNode.tsx +20 -0
  44. package/src/client/nodes/ax-node-actions.ts +39 -0
  45. package/src/client/nodes/surface-url.ts +48 -0
  46. package/src/client/state/attention-bridge.ts +5 -0
  47. package/src/client/state/intent-bridge.ts +33 -0
  48. package/src/client/theme/global.css +13 -0
  49. package/src/client/theme/surface-theme.css +142 -0
  50. package/src/json-render/renderer/index.tsx +31 -0
  51. package/src/json-render/schema.ts +4 -0
  52. package/src/json-render/server.ts +31 -1
  53. package/src/mcp/canvas-access.ts +212 -1
  54. package/src/mcp/server.ts +238 -5
  55. package/src/server/ax-context.ts +3 -0
  56. package/src/server/ax-interaction.ts +549 -0
  57. package/src/server/ax-state.ts +188 -2
  58. package/src/server/canvas-db.ts +20 -0
  59. package/src/server/canvas-operations.ts +11 -0
  60. package/src/server/canvas-serialization.ts +9 -0
  61. package/src/server/canvas-state.ts +177 -16
  62. package/src/server/html-surface.ts +170 -0
  63. package/src/server/index.ts +105 -1
  64. package/src/server/mutation-history.ts +5 -0
  65. package/src/server/placement.ts +5 -1
  66. package/src/server/server.ts +305 -0
  67. package/src/shared/surface.ts +38 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Server-side builder for an `html` node's standalone surface document.
3
+ *
4
+ * This is the canonical wrapper that used to live in the client (HtmlNode's
5
+ * `buildSrcDoc`). It now lives on the server so a single document definition
6
+ * backs BOTH the in-canvas iframe and the "Open as site" tab — the iframe and
7
+ * the standalone tab load the exact same URL (/api/canvas/surface/:nodeId), so
8
+ * there is one render path and no content fork.
9
+ *
10
+ * Theming: instead of inlining a token `<style>` block, the document links the
11
+ * same-origin `/canvas/surface-theme.css` stylesheet and selects a palette via
12
+ * the `<html data-theme="...">` attribute. A sandboxed (opaque-origin) document
13
+ * can still load this same-origin stylesheet, and live theme switching works by
14
+ * toggling the attribute (the theme bridge below) — no CSS payload over
15
+ * postMessage required.
16
+ */
17
+
18
+ export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
19
+
20
+ /** Path the surface document links for its theme tokens (served from dist/canvas). */
21
+ export const SURFACE_THEME_STYLESHEET = '/canvas/surface-theme.css';
22
+
23
+ /** CSP sandbox tokens for an `html`/`html-primitive` surface — scripts only, opaque origin. */
24
+ export const HTML_SURFACE_SANDBOX = 'allow-scripts';
25
+
26
+ export function normalizeSurfaceTheme(value: string | null | undefined): SurfaceTheme {
27
+ return value === 'light' || value === 'high-contrast' ? value : 'dark';
28
+ }
29
+
30
+ /**
31
+ * Restrict a caller-supplied token to a safe charset before it is embedded
32
+ * inside an inline `<script>` string. The token is a CSRF-style nonce minted by
33
+ * the client (shape `theme-<base36>-<base36>` / `presentation-<...>`), but it
34
+ * arrives as a query parameter, so it must never be trusted verbatim — anything
35
+ * outside `[A-Za-z0-9_-]` (notably `<`, `"`, backtick) could break out of the
36
+ * script context.
37
+ */
38
+ function sanitizeToken(value: string | null | undefined): string {
39
+ if (typeof value !== 'string') return '';
40
+ return value.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
41
+ }
42
+
43
+ /**
44
+ * Bridge that lets the parent canvas live-switch the surface theme by toggling
45
+ * the `data-theme` attribute. Validates source + type + nonce so unrelated
46
+ * windows cannot drive the attribute. No-op in a standalone tab (no parent
47
+ * posts to it), which is exactly what we want there.
48
+ */
49
+ function buildThemeBridge(themeToken: string): string {
50
+ const token = JSON.stringify(themeToken);
51
+ return `<script data-pmx-canvas-theme-bridge>
52
+ const PMX_CANVAS_THEME_TOKEN = ${token};
53
+ window.addEventListener('message', (event) => {
54
+ const message = event.data;
55
+ if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
56
+ if (typeof message.theme !== 'string') return;
57
+ document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
58
+ document.documentElement.setAttribute('data-theme', message.theme);
59
+ });
60
+ </script>`;
61
+ }
62
+
63
+ /**
64
+ * Presentation bridge (deck mode). Identical contract to the previous client
65
+ * version: Escape posts an exit message to the parent overlay, and the parent
66
+ * can forward slide keys back in. Only relevant when the surface is embedded in
67
+ * the in-canvas presentation overlay; harmless (inert) in a standalone tab.
68
+ */
69
+ function buildPresentationEscapeBridge(exitToken: string): string {
70
+ const token = JSON.stringify(exitToken);
71
+ return `<script data-pmx-canvas-presentation-bridge>
72
+ const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
73
+ document.addEventListener('keydown', (event) => {
74
+ if (event.key === 'Escape') {
75
+ window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
76
+ }
77
+ }, true);
78
+ window.addEventListener('message', (event) => {
79
+ const message = event.data;
80
+ if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
81
+ if (typeof message.key !== 'string') return;
82
+ if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
83
+ window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
84
+ return;
85
+ }
86
+ document.dispatchEvent(new CustomEvent('pmx-presentation-key', { detail: { key: message.key }, bubbles: true, cancelable: true }));
87
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: message.key, bubbles: true, cancelable: true }));
88
+ });
89
+ </script>`;
90
+ }
91
+
92
+ function injectIntoHead(html: string, content: string): string {
93
+ if (/<head[\s>]/i.test(html)) {
94
+ return html.replace(/<head([^>]*)>/i, `<head$1>${content}`);
95
+ }
96
+ if (/<html[\s>]/i.test(html)) {
97
+ return html.replace(/<html([^>]*)>/i, `<html$1><head>${content}</head>`);
98
+ }
99
+ return html;
100
+ }
101
+
102
+ /**
103
+ * Bridge that exposes `window.PMX_AX.emit(type, payload)` to author HTML. Calls
104
+ * post a nonce-tagged message to the parent canvas, which validates the nonce +
105
+ * node id and submits the interaction through the capability-gated endpoint. Only
106
+ * injected when the node's AX capabilities are enabled (opt-in for `html`), and
107
+ * the server re-validates every interaction — so this is a convenience surface,
108
+ * not a trust boundary.
109
+ */
110
+ function buildAxBridge(axToken: string, nodeId: string): string {
111
+ const token = JSON.stringify(axToken);
112
+ const node = JSON.stringify(nodeId);
113
+ return `<script data-pmx-canvas-ax-bridge>
114
+ const PMX_AX_TOKEN = ${token};
115
+ const PMX_AX_NODE_ID = ${node};
116
+ window.PMX_AX = {
117
+ emit(type, payload) {
118
+ window.parent.postMessage({
119
+ source: 'pmx-canvas-ax',
120
+ token: PMX_AX_TOKEN,
121
+ nodeId: PMX_AX_NODE_ID,
122
+ interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
123
+ }, '*');
124
+ },
125
+ };
126
+ </script>`;
127
+ }
128
+
129
+ export interface HtmlSurfaceOptions {
130
+ theme: SurfaceTheme;
131
+ /** Client nonce that authorizes parent → iframe theme-update messages. */
132
+ themeToken?: string;
133
+ presentation?: boolean;
134
+ presentationExitToken?: string;
135
+ /** Inject window.PMX_AX.emit (only when the node's AX capabilities are enabled). */
136
+ axBridge?: boolean;
137
+ /** Nonce authorizing iframe → parent AX emits; embedded in the bridge. */
138
+ axToken?: string;
139
+ /** Node id stamped on emitted interactions. */
140
+ nodeId?: string;
141
+ }
142
+
143
+ /**
144
+ * Wrap author HTML into a complete, themed standalone document. Accepts either a
145
+ * full HTML document (injects into its `<head>`) or a fragment (wraps it).
146
+ */
147
+ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceOptions): string {
148
+ const themeToken = sanitizeToken(options.themeToken);
149
+ const link = `<link rel="stylesheet" href="${SURFACE_THEME_STYLESHEET}">`;
150
+ const themeBridge = buildThemeBridge(themeToken);
151
+ const presentationBridge = options.presentation
152
+ ? buildPresentationEscapeBridge(sanitizeToken(options.presentationExitToken))
153
+ : '';
154
+ const axBridge = options.axBridge
155
+ ? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId))
156
+ : '';
157
+ const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}`;
158
+ const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
159
+ const trimmed = userHtml.trim();
160
+ const isFullDoc = /<html[\s>]/i.test(trimmed);
161
+ if (isFullDoc) {
162
+ const withTheme = trimmed.replace(
163
+ /<html([^>]*)>/i,
164
+ `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`,
165
+ );
166
+ return injectIntoHead(withTheme, injectedHeadContent);
167
+ }
168
+ // Fragment — wrap in a full document.
169
+ return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
170
+ }
@@ -2,14 +2,20 @@ import { EventEmitter } from 'node:events';
2
2
  import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
3
3
  import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
4
4
  import { buildCanvasAxContext } from './ax-context.js';
5
+ import { applyAxInteraction, type AxInteractionInput, type AxInteractionPublicResult } from './ax-interaction.js';
5
6
  import type {
6
7
  PmxAxApprovalGate,
8
+ PmxAxCommandDescriptor,
7
9
  PmxAxContext,
10
+ PmxAxElicitation,
8
11
  PmxAxEvent,
9
12
  PmxAxEvidence,
10
13
  PmxAxEvidenceKind,
11
14
  PmxAxFocusState,
12
15
  PmxAxHostCapability,
16
+ PmxAxMode,
17
+ PmxAxModeRequest,
18
+ PmxAxPolicy,
13
19
  PmxAxReviewAnchorType,
14
20
  PmxAxReviewAnnotation,
15
21
  PmxAxReviewKind,
@@ -130,8 +136,14 @@ export class PmxCanvas extends EventEmitter {
130
136
  async start(options?: {
131
137
  open?: boolean;
132
138
  automationWebView?: boolean | CanvasAutomationWebViewOptions;
139
+ /**
140
+ * Bind a nearby free port when the preferred one is taken instead of
141
+ * failing. Default false (an explicit SDK port is honored exactly); the
142
+ * MCP auto-start opts in so a daemon already on the port can't crash it.
143
+ */
144
+ allowPortFallback?: boolean;
133
145
  }): Promise<void> {
134
- const base = startCanvasServer({ port: this._port, allowPortFallback: false });
146
+ const base = startCanvasServer({ port: this._port, allowPortFallback: options?.allowPortFallback ?? false });
135
147
  if (!base) {
136
148
  throw new Error(`Failed to start canvas server on port ${this._port}`);
137
149
  }
@@ -517,6 +529,22 @@ export class PmxCanvas extends EventEmitter {
517
529
  return ok;
518
530
  }
519
531
 
532
+ /** Undelivered steering for a consumer (loop-safe; excludes consumer-originated). */
533
+ getPendingSteering(options?: { consumer?: string; limit?: number }): PmxAxSteeringMessage[] {
534
+ return canvasState.getPendingSteering(options ?? {});
535
+ }
536
+
537
+ /**
538
+ * Submit a node-originated AX interaction (plan-004 Phase 1). Validates the
539
+ * envelope + node capabilities, maps the interaction onto the matching AX
540
+ * operation, and emits the outcome + state SSE events.
541
+ */
542
+ submitAxInteraction(input: AxInteractionInput, options?: { source?: PmxAxSource }): AxInteractionPublicResult {
543
+ const { result, events } = applyAxInteraction(canvasState, input, options?.source ?? 'sdk');
544
+ for (const e of events) emitPrimaryWorkbenchEvent(e.event, e.payload);
545
+ return result;
546
+ }
547
+
520
548
  getAxTimeline(query?: AxTimelineQuery): ReturnType<typeof canvasState.getAxTimeline> {
521
549
  return canvasState.getAxTimeline(query);
522
550
  }
@@ -622,6 +650,75 @@ export class PmxCanvas extends EventEmitter {
622
650
  return host;
623
651
  }
624
652
 
653
+ listElicitations(): PmxAxElicitation[] {
654
+ return canvasState.getElicitations();
655
+ }
656
+
657
+ requestElicitation(
658
+ input: { prompt: string; fields?: string[]; nodeIds?: string[] },
659
+ options?: { source?: PmxAxSource },
660
+ ): PmxAxElicitation {
661
+ const elicitation = canvasState.requestElicitation(input, { source: options?.source ?? 'sdk' });
662
+ emitPrimaryWorkbenchEvent('ax-state-changed', { elicitation });
663
+ return elicitation;
664
+ }
665
+
666
+ respondElicitation(
667
+ id: string,
668
+ response: Record<string, unknown>,
669
+ options?: { source?: PmxAxSource },
670
+ ): PmxAxElicitation | null {
671
+ const elicitation = canvasState.respondElicitation(id, response, { source: options?.source ?? 'sdk' });
672
+ if (elicitation) emitPrimaryWorkbenchEvent('ax-state-changed', { elicitation });
673
+ return elicitation;
674
+ }
675
+
676
+ listModeRequests(): PmxAxModeRequest[] {
677
+ return canvasState.getModeRequests();
678
+ }
679
+
680
+ requestMode(
681
+ input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
682
+ options?: { source?: PmxAxSource },
683
+ ): PmxAxModeRequest {
684
+ const modeRequest = canvasState.requestMode(input, { source: options?.source ?? 'sdk' });
685
+ emitPrimaryWorkbenchEvent('ax-state-changed', { modeRequest });
686
+ return modeRequest;
687
+ }
688
+
689
+ resolveModeRequest(
690
+ id: string,
691
+ decision: 'approved' | 'rejected',
692
+ options?: { resolution?: string; source?: PmxAxSource },
693
+ ): PmxAxModeRequest | null {
694
+ const modeRequest = canvasState.resolveModeRequest(id, decision, { ...(options ?? {}), source: options?.source ?? 'sdk' });
695
+ if (modeRequest) emitPrimaryWorkbenchEvent('ax-state-changed', { modeRequest });
696
+ return modeRequest;
697
+ }
698
+
699
+ getCommandRegistry(): PmxAxCommandDescriptor[] {
700
+ return canvasState.getCommandRegistry();
701
+ }
702
+
703
+ invokeCommand(name: string, args?: Record<string, unknown> | null, options?: { source?: PmxAxSource }): PmxAxEvent | null {
704
+ const event = canvasState.invokeCommand(name, args ?? null, { source: options?.source ?? 'sdk' });
705
+ if (event) emitPrimaryWorkbenchEvent('ax-event-created', { event });
706
+ return event;
707
+ }
708
+
709
+ getPolicy(): PmxAxPolicy {
710
+ return canvasState.getPolicy();
711
+ }
712
+
713
+ setPolicy(
714
+ patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> },
715
+ options?: { source?: PmxAxSource },
716
+ ): PmxAxPolicy {
717
+ const policy = canvasState.setPolicy(patch, { source: options?.source ?? 'sdk' });
718
+ emitPrimaryWorkbenchEvent('ax-state-changed', { policy });
719
+ return policy;
720
+ }
721
+
625
722
  fitView(options?: {
626
723
  width?: number;
627
724
  height?: number;
@@ -1106,13 +1203,20 @@ export { traceManager } from './trace-manager.js';
1106
1203
  export type {
1107
1204
  PmxAxApprovalGate,
1108
1205
  PmxAxApprovalStatus,
1206
+ PmxAxCommandDescriptor,
1109
1207
  PmxAxContext,
1110
1208
  PmxAxEvent,
1209
+ PmxAxElicitation,
1210
+ PmxAxElicitationStatus,
1111
1211
  PmxAxEventKind,
1112
1212
  PmxAxEvidence,
1113
1213
  PmxAxEvidenceKind,
1114
1214
  PmxAxFocusState,
1115
1215
  PmxAxHostCapability,
1216
+ PmxAxMode,
1217
+ PmxAxModeRequest,
1218
+ PmxAxModeRequestStatus,
1219
+ PmxAxPolicy,
1116
1220
  PmxAxReviewAnchorType,
1117
1221
  PmxAxReviewAnnotation,
1118
1222
  PmxAxReviewKind,
@@ -35,6 +35,11 @@ export type MutationOp =
35
35
  | 'resolveApproval'
36
36
  | 'addReviewAnnotation'
37
37
  | 'updateReviewAnnotation'
38
+ | 'requestElicitation'
39
+ | 'respondElicitation'
40
+ | 'requestMode'
41
+ | 'resolveModeRequest'
42
+ | 'setPolicy'
38
43
  | 'batch'
39
44
  | 'viewport'
40
45
  | 'groupNodes'
@@ -7,7 +7,11 @@ import {
7
7
 
8
8
  export { findOpenCanvasPosition, type CanvasPlacementRect } from '../shared/placement.js';
9
9
 
10
- export const GROUP_PAD = 40;
10
+ // Margin between a group frame and the children it contains. Kept generous so
11
+ // the auto-fit frame stays visibly larger than its children — leaving room for
12
+ // the node-count/type badge in the header that would otherwise sit under the
13
+ // top-left child.
14
+ export const GROUP_PAD = 56;
11
15
  export const GROUP_TITLEBAR_HEIGHT = 32;
12
16
  const GROUP_LAYOUT_GAP_X = 32;
13
17
  const GROUP_LAYOUT_GAP_Y = 32;