pmx-canvas 0.1.31 → 0.1.32

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 (38) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/canvas/global.css +18 -3
  3. package/dist/canvas/index.js +57 -57
  4. package/dist/json-render/index.js +97 -97
  5. package/dist/types/client/nodes/surface-url.d.ts +7 -0
  6. package/dist/types/client/state/canvas-store.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +7 -0
  8. package/dist/types/json-render/renderer/index.d.ts +1 -0
  9. package/dist/types/json-render/server.d.ts +1 -0
  10. package/dist/types/server/ax-context.d.ts +24 -1
  11. package/dist/types/server/html-surface.d.ts +23 -0
  12. package/dist/types/server/index.d.ts +6 -0
  13. package/package.json +1 -1
  14. package/skills/pmx-canvas/SKILL.md +96 -1
  15. package/src/cli/agent.ts +18 -1
  16. package/src/client/App.tsx +3 -3
  17. package/src/client/canvas/CanvasNode.tsx +16 -1
  18. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
  19. package/src/client/nodes/ContextNode.tsx +1 -1
  20. package/src/client/nodes/HtmlNode.tsx +26 -1
  21. package/src/client/nodes/McpAppNode.tsx +35 -8
  22. package/src/client/nodes/StatusNode.tsx +0 -20
  23. package/src/client/nodes/surface-url.ts +12 -0
  24. package/src/client/state/canvas-store.ts +4 -0
  25. package/src/client/state/intent-bridge.ts +19 -0
  26. package/src/client/state/sse-bridge.ts +17 -0
  27. package/src/client/theme/global.css +18 -3
  28. package/src/json-render/renderer/index.tsx +31 -2
  29. package/src/json-render/server.ts +3 -0
  30. package/src/mcp/canvas-access.ts +3 -0
  31. package/src/mcp/server.ts +23 -1
  32. package/src/server/ax-context.ts +49 -1
  33. package/src/server/ax-interaction.ts +3 -0
  34. package/src/server/ax-state.ts +3 -1
  35. package/src/server/canvas-state.ts +6 -1
  36. package/src/server/html-surface.ts +48 -11
  37. package/src/server/index.ts +8 -0
  38. package/src/server/server.ts +62 -4
@@ -6,6 +6,7 @@ import {
6
6
  addEdge,
7
7
  addNode,
8
8
  applyServerCanvasLayout,
9
+ axSurfaceState,
9
10
  bringToFront,
10
11
  cancelViewportAnimation,
11
12
  canvasTheme,
@@ -24,6 +25,7 @@ import {
24
25
  updateNode,
25
26
  updateNodeData,
26
27
  } from './canvas-store';
28
+ import { fetchAxSurfaceState } from './intent-bridge';
27
29
  import { invalidateTokenCache } from '../theme/tokens';
28
30
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
29
31
 
@@ -926,6 +928,19 @@ function handleContextPinsChanged(data: Record<string, unknown>): void {
926
928
  syncAttentionFromSse({ event: 'context-pins-changed', data });
927
929
  }
928
930
 
931
+ // AX state changes arrive as per-primitive deltas; rather than reduce them, treat
932
+ // the event as a "something changed" signal and re-fetch the full compact snapshot
933
+ // (debounced). The snapshot feeds AX-enabled surfaces (HtmlNode/McpAppNode push it
934
+ // into their iframes), so authored boards reflect the live work queue / focus.
935
+ let axRefreshTimer: ReturnType<typeof setTimeout> | null = null;
936
+ function handleAxStateChanged(): void {
937
+ if (axRefreshTimer) clearTimeout(axRefreshTimer);
938
+ axRefreshTimer = setTimeout(() => {
939
+ axRefreshTimer = null;
940
+ void fetchAxSurfaceState().then((state) => { axSurfaceState.value = state; });
941
+ }, 150);
942
+ }
943
+
929
944
  // ── SSE connection ────────────────────────────────────────────
930
945
  /** @internal — exported for testing */
931
946
  export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => void> = {
@@ -959,6 +974,8 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
959
974
  'canvas-response-start': handleCanvasResponseStart,
960
975
  'canvas-response-delta': handleCanvasResponseDelta,
961
976
  'canvas-response-complete': handleCanvasResponseComplete,
977
+ 'ax-state-changed': handleAxStateChanged,
978
+ 'ax-event-created': handleAxStateChanged,
962
979
  };
963
980
 
964
981
  export function connectSSE(): () => void {
@@ -457,13 +457,15 @@ body,
457
457
  font-weight: 600;
458
458
  }
459
459
 
460
- /* HUD layer — fixed row: [left-dock] [toolbar] [right-dock] */
460
+ /* HUD layer — [left-dock] [toolbar] [right-dock]. Wraps onto multiple rows in a
461
+ narrow embedding panel (e.g. the Copilot side panel) instead of clipping. */
461
462
  .hud-layer {
462
463
  position: fixed;
463
464
  top: 12px;
464
465
  left: 12px;
465
466
  right: 12px;
466
467
  display: flex;
468
+ flex-wrap: wrap;
467
469
  align-items: flex-start;
468
470
  justify-content: center;
469
471
  gap: 8px;
@@ -476,22 +478,24 @@ body,
476
478
  .hud-left,
477
479
  .hud-right {
478
480
  display: flex;
481
+ flex-wrap: wrap;
479
482
  gap: 8px;
480
483
  }
481
484
 
482
485
  /* Toolbar */
483
486
  .canvas-toolbar {
484
487
  display: flex;
488
+ flex-wrap: wrap;
485
489
  align-items: center;
486
490
  gap: 6px;
487
491
  padding: 6px 10px;
488
492
  min-height: var(--hud-bar-height);
493
+ max-width: 100%;
489
494
  box-sizing: border-box;
490
495
  background: var(--c-panel-glass);
491
496
  backdrop-filter: blur(12px);
492
497
  border: 1px solid var(--c-line);
493
498
  border-radius: var(--radius);
494
- flex-shrink: 0;
495
499
  }
496
500
 
497
501
  .toolbar-tooltip-anchor {
@@ -666,9 +670,11 @@ body,
666
670
 
667
671
  .toolbar-group {
668
672
  display: flex;
673
+ flex-wrap: wrap;
669
674
  align-items: center;
670
675
  gap: 6px;
671
- flex-shrink: 0;
676
+ min-width: 0;
677
+ max-width: 100%;
672
678
  }
673
679
 
674
680
  .canvas-toolbar button svg {
@@ -692,6 +698,15 @@ body,
692
698
  }
693
699
  }
694
700
 
701
+ /* Narrow embedding panels: drop low-value text from the HUD so the icon controls
702
+ fit in fewer rows. Buttons keep their aria-labels + tooltips, so nothing is
703
+ lost for a11y or discovery. */
704
+ @media (max-width: 720px) {
705
+ .hud-collapsible-text {
706
+ display: none;
707
+ }
708
+ }
709
+
695
710
  /* Raw markdown source editor */
696
711
  .md-editor-split {
697
712
  display: flex;
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import type { Spec } from '@json-render/core';
11
+ import { useEffect } from 'react';
11
12
  import { createRoot } from 'react-dom/client';
12
- import { defineRegistry, JSONUIProvider, Renderer } from '@json-render/react';
13
+ import { defineRegistry, JSONUIProvider, Renderer, useStateBinding } from '@json-render/react';
13
14
  import { shadcnComponents } from '@json-render/shadcn';
14
15
  import { catalog } from '../catalog';
15
16
  import { chartComponents } from '../charts/components';
@@ -81,9 +82,30 @@ declare global {
81
82
  __PMX_CANVAS_JSON_RENDER_DEVTOOLS__?: boolean;
82
83
  __PMX_CANVAS_JSON_RENDER_NODE_ID__?: string;
83
84
  __PMX_CANVAS_AX_TOKEN__?: string;
85
+ __PMX_CANVAS_AX_STATE__?: unknown;
84
86
  }
85
87
  }
86
88
 
89
+ // Read-side AX bridge for json-render: keeps the spec-bound `/ax` state live as
90
+ // the parent canvas pushes nonce-validated `ax-update` messages, so a declarative
91
+ // board ({ "$state": "/ax/workItems" }) reflects the work queue in real time.
92
+ function AxStateSync() {
93
+ const [, setAx] = useStateBinding<unknown>('ax');
94
+ useEffect(() => {
95
+ const token = window.__PMX_CANVAS_AX_TOKEN__;
96
+ if (!token) return undefined;
97
+ function onMessage(event: MessageEvent) {
98
+ const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
99
+ if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== token) return;
100
+ window.__PMX_CANVAS_AX_STATE__ = m.state;
101
+ setAx(m.state);
102
+ }
103
+ window.addEventListener('message', onMessage);
104
+ return () => window.removeEventListener('message', onMessage);
105
+ }, [setAx]);
106
+ return null;
107
+ }
108
+
87
109
  // AX interaction types a json-render spec can bind actions to. When an action
88
110
  // named like one of these fires, we forward it to the parent canvas (which
89
111
  // validates + submits through the capability-gated endpoint). Convention-based
@@ -149,14 +171,21 @@ function App() {
149
171
  );
150
172
  }
151
173
 
174
+ // Seed AX state under a reserved `/ax` key so specs can bind { "$state": "/ax/workItems" }.
175
+ const axState = window.__PMX_CANVAS_AX_STATE__;
176
+ const initialState = axState !== undefined && axState !== null
177
+ ? { ...(spec.state ?? {}), ax: axState }
178
+ : spec.state ?? undefined;
179
+
152
180
  return (
153
181
  <div style={{ minHeight: '100vh', padding: 16, boxSizing: 'border-box' }}>
154
182
  <JSONUIProvider
155
183
  registry={registry}
156
- initialState={spec.state ?? undefined}
184
+ initialState={initialState}
157
185
  directives={pmxCanvasDirectives}
158
186
  handlers={buildAxHandlers()}
159
187
  >
188
+ <AxStateSync />
160
189
  <Renderer spec={spec} registry={registry} loading={false} />
161
190
  {window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
162
191
  <JsonRenderDevtools position="right" />
@@ -943,6 +943,7 @@ export async function buildJsonRenderViewerHtml(options: {
943
943
  devtools?: boolean;
944
944
  nodeId?: string;
945
945
  axToken?: string;
946
+ axState?: unknown;
946
947
  }): Promise<string> {
947
948
  const sanitizeAxValue = (v?: string): string => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '');
948
949
  try {
@@ -962,6 +963,8 @@ export async function buildJsonRenderViewerHtml(options: {
962
963
  ...(options.nodeId && options.axToken ? [
963
964
  `window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`,
964
965
  `window.__PMX_CANVAS_AX_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.axToken))};`,
966
+ // Read-side AX state: seed for initial render + bound under /ax for specs.
967
+ `window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
965
968
  ] : []),
966
969
  jsBundle,
967
970
  ].join('\n');
@@ -685,6 +685,7 @@ class RemoteCanvasAccess implements CanvasAccess {
685
685
  slideTitles,
686
686
  embeddedNodeIds,
687
687
  embeddedUrls,
688
+ axCapabilities,
688
689
  ...rest
689
690
  } = input as AddHtmlNodeInput & {
690
691
  summary?: string;
@@ -694,6 +695,7 @@ class RemoteCanvasAccess implements CanvasAccess {
694
695
  slideTitles?: string[];
695
696
  embeddedNodeIds?: string[];
696
697
  embeddedUrls?: string[];
698
+ axCapabilities?: { enabled?: boolean; allowed?: string[] };
697
699
  };
698
700
  return await this.requestNodeId('POST', '/api/canvas/node', {
699
701
  type: 'html',
@@ -706,6 +708,7 @@ class RemoteCanvasAccess implements CanvasAccess {
706
708
  ...(Array.isArray(slideTitles) ? { slideTitles } : {}),
707
709
  ...(Array.isArray(embeddedNodeIds) ? { embeddedNodeIds } : {}),
708
710
  ...(Array.isArray(embeddedUrls) ? { embeddedUrls } : {}),
711
+ ...(axCapabilities ? { axCapabilities } : {}),
709
712
  },
710
713
  });
711
714
  }
package/src/mcp/server.ts CHANGED
@@ -447,6 +447,10 @@ export async function startMcpServer(): Promise<void> {
447
447
  width: z.number().optional().describe('Width in pixels (default: 720).'),
448
448
  height: z.number().optional().describe('Height in pixels (default: 640).'),
449
449
  strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
450
+ axCapabilities: z.object({
451
+ enabled: z.boolean().optional(),
452
+ allowed: z.array(z.string()).optional().describe('AX interaction types this node may emit (e.g. ax.work.create, ax.work.update, ax.steer, ax.focus.set, ax.evidence.add, ax.event.record). Clamped to the html capability ceiling server-side; cannot escalate.'),
453
+ }).optional().describe('Opt this html node into AX interactions so its sandboxed UI can emit ax.* via window.PMX_AX.emit(type, payload) (and reflect live AX state). html nodes are AX-disabled by default; set { enabled: true, allowed: [...] } to turn the bridge on. Build interactive boards (work queues, review boards, inboxes) this way.'),
450
454
  full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
451
455
  verbose: z.boolean().optional().describe('Alias for full:true.'),
452
456
  },
@@ -455,6 +459,7 @@ export async function startMcpServer(): Promise<void> {
455
459
  const id = await c.addHtmlNode({
456
460
  html: input.html,
457
461
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
462
+ ...(input.axCapabilities ? { axCapabilities: input.axCapabilities } : {}),
458
463
  ...(typeof input.summary === 'string' ? { summary: input.summary } : {}),
459
464
  ...(typeof input.agentSummary === 'string' ? { agentSummary: input.agentSummary } : {}),
460
465
  ...(typeof input.description === 'string' ? { description: input.description } : {}),
@@ -1079,11 +1084,15 @@ export async function startMcpServer(): Promise<void> {
1079
1084
  dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
1080
1085
  pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
1081
1086
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
1087
+ axCapabilities: z.object({
1088
+ enabled: z.boolean().optional(),
1089
+ allowed: z.array(z.string()).optional(),
1090
+ }).optional().describe('Enable/disable AX interactions on an existing node (e.g. flip an html node on with { enabled: true, allowed: ["ax.work.create"] }). Merged into the node data; clamped to the node-type ceiling server-side.'),
1082
1091
  full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
1083
1092
  verbose: z.boolean().optional().describe('Alias for full:true.'),
1084
1093
  },
1085
1094
  async (input) => {
1086
- const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
1095
+ const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, axCapabilities, toolName, category, status, duration, resultSummary, error } = input;
1087
1096
  const c = await ensureCanvas();
1088
1097
  const node = await c.getNode(id);
1089
1098
  if (!node) {
@@ -1125,6 +1134,19 @@ export async function startMcpServer(): Promise<void> {
1125
1134
  if (arrangeLocked !== undefined) {
1126
1135
  patch.arrangeLocked = arrangeLocked;
1127
1136
  }
1137
+ if (axCapabilities !== undefined) {
1138
+ // A graph dataset update (`data` array) and an axCapabilities toggle collide
1139
+ // on patch.data (array vs object) — reject rather than silently dropping the
1140
+ // dataset. Otherwise merge into existing node data so enabling AX doesn't
1141
+ // clobber html/spec/etc. The server re-clamps axCapabilities to the ceiling.
1142
+ if (Array.isArray(patch.data)) {
1143
+ return {
1144
+ content: [{ type: 'text', text: 'Update the graph dataset and axCapabilities in separate canvas_update_node calls.' }],
1145
+ isError: true,
1146
+ };
1147
+ }
1148
+ patch.data = { ...(node.data as Record<string, unknown>), axCapabilities };
1149
+ }
1128
1150
  await c.updateNode(id, patch);
1129
1151
  const updated = await c.getNode(id);
1130
1152
  return {
@@ -1,7 +1,55 @@
1
1
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
2
- import { buildAxContext, type PmxAxContext, type PmxAxPinnedContext } from './ax-state.js';
2
+ import {
3
+ buildAxContext,
4
+ type PmxAxContext,
5
+ type PmxAxPinnedContext,
6
+ type PmxAxWorkItem,
7
+ type PmxAxApprovalGate,
8
+ type PmxAxReviewAnnotation,
9
+ type PmxAxElicitation,
10
+ type PmxAxModeRequest,
11
+ type PmxAxPolicy,
12
+ } from './ax-state.js';
3
13
  import { canvasState, type CanvasNodeState } from './canvas-state.js';
4
14
 
15
+ /**
16
+ * Compact, surface-safe view of the canvas-bound AX state, injected into (and
17
+ * pushed to) AX-enabled surfaces so authored boards can RENDER the work queue /
18
+ * focus, not just emit interactions. Deliberately excludes the timeline, pinned
19
+ * preamble, and serialized node bodies to keep the payload small.
20
+ */
21
+ export interface PmxAxSurfaceSnapshot {
22
+ focus: string[];
23
+ workItems: PmxAxWorkItem[];
24
+ approvalGates: PmxAxApprovalGate[];
25
+ // Free-text human fields (`body`, `author`) are redacted — a surface gets review
26
+ // status/severity/anchor for a review board, but not raw human comment text.
27
+ reviewAnnotations: Array<Omit<PmxAxReviewAnnotation, 'body' | 'author'>>;
28
+ elicitations: PmxAxElicitation[];
29
+ modeRequests: PmxAxModeRequest[];
30
+ policy: PmxAxPolicy;
31
+ }
32
+
33
+ /**
34
+ * NOTE: this is whole-canvas AX state (every work item, etc.), exposed to ANY
35
+ * AX-enabled surface — reads are board-wide while emits are node-scoped. Acceptable
36
+ * under the single-workspace local-trust model, but author surfaces accordingly
37
+ * (don't embed untrusted third-party scripts in an AX-enabled surface). Sensitive
38
+ * human review text is redacted below.
39
+ */
40
+ export function buildCanvasAxSurfaceSnapshot(): PmxAxSurfaceSnapshot {
41
+ const ax = canvasState.getAxState();
42
+ return {
43
+ focus: ax.focus.nodeIds,
44
+ workItems: ax.workItems,
45
+ approvalGates: ax.approvalGates,
46
+ reviewAnnotations: ax.reviewAnnotations.map(({ body: _body, author: _author, ...rest }) => rest),
47
+ elicitations: ax.elicitations,
48
+ modeRequests: ax.modeRequests,
49
+ policy: ax.policy,
50
+ };
51
+ }
52
+
5
53
  function serializeNodes(nodes: CanvasNodeState[]) {
6
54
  return nodes.map((node) => serializeNodeForAgentContext(node, {
7
55
  defaultTextLength: 700,
@@ -500,6 +500,9 @@ export function applyAxInteraction(
500
500
  const p = payloadParsed.data as { body: string; kind?: PmxAxReviewKind; severity?: PmxAxReviewSeverity; anchorType?: PmxAxReviewAnchorType; nodeId?: string; file?: string; author?: string };
501
501
  // Sandboxed surfaces may only review their own node; trusted surfaces may
502
502
  // anchor to a file/region or another node.
503
+ // A node-interaction review carries a sourceNodeId, so it defaults to a node
504
+ // anchor on that source (see nodeId resolution below). Body-only/unanchored
505
+ // is the adapter/HTTP/MCP path (addReviewAnnotation's context-aware default).
503
506
  const anchorType: PmxAxReviewAnchorType = scoped ? 'node' : (p.anchorType ?? 'node');
504
507
  const reviewAnnotation = manager.addReviewAnnotation(
505
508
  {
@@ -636,7 +636,9 @@ export function createAxReviewAnnotation(
636
636
  source: PmxAxSource | null,
637
637
  ): PmxAxReviewAnnotation {
638
638
  const now = nowIso();
639
- const anchorType = input.anchorType ?? 'node';
639
+ // Mirror addReviewAnnotation's context-aware default so a body-only annotation
640
+ // (no anchorType, no nodeId) becomes an unanchored note instead of a node anchor.
641
+ const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
640
642
  return {
641
643
  id: axId('rev'),
642
644
  kind: input.kind ?? 'comment',
@@ -1888,7 +1888,12 @@ class CanvasStateManager {
1888
1888
  // normalizeAxForCurrentNodes after apply, yet still returned as a phantom
1889
1889
  // success object — false success / silent data loss. Reject instead so the
1890
1890
  // HTTP/MCP layers surface ok:false / 4xx.
1891
- const anchorType = input.anchorType ?? 'node';
1891
+ // Context-aware default: only fall back to a node anchor when a usable nodeId
1892
+ // is present; otherwise treat it as an unanchored (body-only) note so a
1893
+ // `{ body }`-only annotation succeeds (anchorType is documented optional).
1894
+ const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
1895
+ // An EXPLICIT node anchor still requires a real nodeId — reject a phantom
1896
+ // node-anchored review rather than silently dropping it post-apply.
1892
1897
  if (anchorType === 'node' && (typeof input.nodeId !== 'string' || !this.currentNodeIdSet().has(input.nodeId))) {
1893
1898
  return null;
1894
1899
  }
@@ -107,25 +107,49 @@ function injectIntoHead(html: string, content: string): string {
107
107
  * the server re-validates every interaction — so this is a convenience surface,
108
108
  * not a trust boundary.
109
109
  */
110
- function buildAxBridge(axToken: string, nodeId: string): string {
110
+ export function buildAxBridge(axToken: string, nodeId: string): string {
111
111
  const token = JSON.stringify(axToken);
112
112
  const node = JSON.stringify(nodeId);
113
113
  return `<script data-pmx-canvas-ax-bridge>
114
114
  const PMX_AX_TOKEN = ${token};
115
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
- },
116
+ window.PMX_AX = window.PMX_AX || {};
117
+ window.PMX_AX.emit = function (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
+ }, '*');
125
124
  };
126
125
  </script>`;
127
126
  }
128
127
 
128
+ /**
129
+ * Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX
130
+ * state and keeps it live via nonce-validated `ax-update` messages from the parent
131
+ * canvas. Author HTML can read `window.PMX_AX.state` and subscribe to the
132
+ * `pmx-ax-update` CustomEvent to render a live work queue / focus. Injected only
133
+ * alongside the emit bridge (AX-enabled nodes). Read-only — no capability beyond
134
+ * the existing AX-enabled gate.
135
+ */
136
+ export function buildAxStateBridge(axToken: string, snapshotJson: string): string {
137
+ const token = JSON.stringify(axToken);
138
+ return `<script data-pmx-canvas-ax-state-bridge>
139
+ (function () {
140
+ const PMX_AX_STATE_TOKEN = ${token};
141
+ window.PMX_AX = window.PMX_AX || {};
142
+ window.PMX_AX.state = ${snapshotJson};
143
+ window.addEventListener('message', function (event) {
144
+ const m = event.data;
145
+ if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
146
+ window.PMX_AX.state = m.state;
147
+ try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
148
+ });
149
+ })();
150
+ </script>`;
151
+ }
152
+
129
153
  /** Escape a string for safe interpolation into element text (e.g. `<title>`). */
130
154
  function escapeSurfaceHtml(value: string): string {
131
155
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -149,6 +173,11 @@ export interface HtmlSurfaceOptions {
149
173
  axToken?: string;
150
174
  /** Node id stamped on emitted interactions. */
151
175
  nodeId?: string;
176
+ /**
177
+ * Initial AX state snapshot to seed `window.PMX_AX.state` (only used when
178
+ * axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
179
+ */
180
+ axState?: unknown;
152
181
  }
153
182
 
154
183
  /**
@@ -165,7 +194,15 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
165
194
  const axBridge = options.axBridge
166
195
  ? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId))
167
196
  : '';
168
- const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}`;
197
+ // Read-side AX state bridge (seed + live push). `</` is escaped so a work-item
198
+ // title containing "</script>" can't break out of the inline script.
199
+ const axStateBridge = options.axBridge
200
+ ? buildAxStateBridge(
201
+ sanitizeToken(options.axToken),
202
+ options.axState !== undefined ? JSON.stringify(options.axState).replace(/</g, '\\u003c') : 'null',
203
+ )
204
+ : '';
205
+ const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}`;
169
206
  const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
170
207
  const trimmed = userHtml.trim();
171
208
  const isFullDoc = /<html[\s>]/i.test(trimmed);
@@ -768,6 +768,9 @@ export class PmxCanvas extends EventEmitter {
768
768
  if (!entry) return { ok: false, description: 'Nothing to undo' };
769
769
  await syncCanvasRuntimeBackends();
770
770
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
771
+ // Undo can reverse an AX mutation (work item, focus, …); nudge AX surfaces to
772
+ // re-fetch so a live board reflects the reversal (debounced client-side).
773
+ emitPrimaryWorkbenchEvent('ax-state-changed', {});
771
774
  return { ok: true, description: `Undid: ${entry.description}` };
772
775
  }
773
776
 
@@ -776,6 +779,7 @@ export class PmxCanvas extends EventEmitter {
776
779
  if (!entry) return { ok: false, description: 'Nothing to redo' };
777
780
  await syncCanvasRuntimeBackends();
778
781
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
782
+ emitPrimaryWorkbenchEvent('ax-state-changed', {});
779
783
  return { ok: true, description: `Redid: ${entry.description}` };
780
784
  }
781
785
 
@@ -1042,6 +1046,9 @@ export class PmxCanvas extends EventEmitter {
1042
1046
  width?: number;
1043
1047
  height?: number;
1044
1048
  strictSize?: boolean;
1049
+ /** Opt this html node into AX interactions (window.PMX_AX.emit). Clamped to
1050
+ * the html capability ceiling server-side; cannot escalate. */
1051
+ axCapabilities?: { enabled?: boolean; allowed?: string[] };
1045
1052
  }): SdkCanvasNode {
1046
1053
  const { id } = addCanvasNode({
1047
1054
  type: 'html',
@@ -1055,6 +1062,7 @@ export class PmxCanvas extends EventEmitter {
1055
1062
  ...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
1056
1063
  ...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
1057
1064
  ...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
1065
+ ...(input.axCapabilities ? { axCapabilities: input.axCapabilities } : {}),
1058
1066
  },
1059
1067
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
1060
1068
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
@@ -48,7 +48,7 @@ import type {
48
48
  ListToolsResult,
49
49
  } from '@modelcontextprotocol/sdk/types.js';
50
50
  import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
51
- import { buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
51
+ import { buildAxBridge, buildAxStateBridge, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
52
52
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
53
53
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
54
54
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
@@ -77,7 +77,7 @@ import {
77
77
  } from './canvas-serialization.js';
78
78
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
79
79
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
80
- import { buildCanvasAxContext } from './ax-context.js';
80
+ import { buildCanvasAxContext, buildCanvasAxSurfaceSnapshot } from './ax-context.js';
81
81
  import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
82
82
  import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
83
83
  import type {
@@ -1432,6 +1432,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1432
1432
  if (!html) return responseText('HTML node has no content', 404);
1433
1433
  const present = url.searchParams.get('present') === '1';
1434
1434
  const axCaps = resolveNodeAxCapabilities(node);
1435
+ const axEnabled = axCaps.enabled && axCaps.allowed.length > 0;
1435
1436
  const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim()
1436
1437
  ? node.data.title
1437
1438
  : node.id;
@@ -1441,9 +1442,11 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1441
1442
  themeToken: url.searchParams.get('themeToken') ?? undefined,
1442
1443
  presentation: present,
1443
1444
  presentationExitToken: url.searchParams.get('presentToken') ?? undefined,
1444
- axBridge: axCaps.enabled && axCaps.allowed.length > 0,
1445
+ axBridge: axEnabled,
1445
1446
  axToken: url.searchParams.get('axToken') ?? undefined,
1446
1447
  nodeId: node.id,
1448
+ // Seed the read-side bridge with the current AX state (only for AX surfaces).
1449
+ ...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1447
1450
  });
1448
1451
  return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1449
1452
  }
@@ -2536,6 +2539,7 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2536
2539
  process.env.PMX_CANVAS_JSON_RENDER_DEVTOOLS === '1' &&
2537
2540
  url.searchParams.get('devtools') === '1';
2538
2541
  const axToken = url.searchParams.get('axToken');
2542
+ const axEnabled = resolveNodeAxCapabilities(node).enabled;
2539
2543
  const html = await buildJsonRenderViewerHtml({
2540
2544
  title,
2541
2545
  spec,
@@ -2543,6 +2547,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2543
2547
  ...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
2544
2548
  ...(devtoolsEnabled ? { devtools: true } : {}),
2545
2549
  ...(axToken ? { nodeId, axToken } : {}),
2550
+ // Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
2551
+ ...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
2546
2552
  });
2547
2553
  return new Response(html, {
2548
2554
  headers: {
@@ -2600,7 +2606,27 @@ function handleArtifactView(url: URL): Response {
2600
2606
  }
2601
2607
 
2602
2608
  if (ext === '.html' || ext === '.htm') {
2603
- const content = readFileSync(safePath, 'utf-8');
2609
+ let content = readFileSync(safePath, 'utf-8');
2610
+ // AX bridge for web-artifacts (same opaque-origin postMessage bridge as html
2611
+ // surfaces — a sandboxed artifact can't fetch the API directly). The viewer
2612
+ // appends axToken + axNodeId only for AX-enabled artifacts; the server still
2613
+ // re-validates every interaction.
2614
+ const axToken = url.searchParams.get('axToken');
2615
+ const axNodeId = url.searchParams.get('axNodeId');
2616
+ if (axToken && axNodeId) {
2617
+ const node = canvasState.getNode(axNodeId);
2618
+ if (node && resolveNodeAxCapabilities(node).enabled) {
2619
+ const safeToken = axToken.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
2620
+ // Use the canonical node.id (server-generated [a-z0-9-]) rather than the raw
2621
+ // query param so nothing untrusted reaches the inline bridge script.
2622
+ const safeNodeId = node.id.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
2623
+ const stateJson = JSON.stringify(buildCanvasAxSurfaceSnapshot()).replace(/</g, '\\u003c');
2624
+ const bridge = `${buildAxBridge(safeToken, safeNodeId)}${buildAxStateBridge(safeToken, stateJson)}`;
2625
+ content = content.includes('</head>')
2626
+ ? content.replace('</head>', `${bridge}</head>`)
2627
+ : `${bridge}${content}`;
2628
+ }
2629
+ }
2604
2630
  return new Response(content, {
2605
2631
  headers: {
2606
2632
  'Content-Type': 'text/html; charset=utf-8',
@@ -3827,6 +3853,30 @@ function handleGetAxContext(): Response {
3827
3853
  return responseJson(buildCanvasAxContext());
3828
3854
  }
3829
3855
 
3856
+ // Compact AX state for surfaces (the same shape seeded into AX-enabled iframes).
3857
+ // The client fetches this and pushes it to surfaces over the ax-update channel.
3858
+ function handleGetAxSurfaceSnapshot(): Response {
3859
+ return responseJson(buildCanvasAxSurfaceSnapshot());
3860
+ }
3861
+
3862
+ // Open a node's surface in the user's real system browser (for hosts whose
3863
+ // embedded browser makes window.open('_blank') feel in-place, e.g. Codex).
3864
+ // Accepts ONLY { nodeId } and opens this server's own surface URL — never an
3865
+ // arbitrary URL — so it can't be used to launch external sites (no SSRF). Honors
3866
+ // the PMX_CANVAS_DISABLE_BROWSER_OPEN kill switch via openUrlInExternalBrowser.
3867
+ async function handleOpenExternalSurface(req: Request): Promise<Response> {
3868
+ const body = await readJson(req);
3869
+ const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
3870
+ if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
3871
+ const node = canvasState.getNode(nodeId);
3872
+ if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
3873
+ const port = getCanvasServerPort();
3874
+ if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
3875
+ const surfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
3876
+ const opened = openUrlInExternalBrowser(`http://localhost:${port}${surfacePath}`);
3877
+ return responseJson({ ok: true, opened, url: surfacePath });
3878
+ }
3879
+
3830
3880
  async function handleAxInteraction(req: Request): Promise<Response> {
3831
3881
  const body = await readJson(req);
3832
3882
  const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
@@ -5304,6 +5354,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5304
5354
  return handleGetAxContext();
5305
5355
  }
5306
5356
 
5357
+ if (url.pathname === '/api/canvas/ax/surface-snapshot' && req.method === 'GET') {
5358
+ return handleGetAxSurfaceSnapshot();
5359
+ }
5360
+
5361
+ if (url.pathname === '/api/canvas/open-external' && req.method === 'POST') {
5362
+ return handleOpenExternalSurface(req);
5363
+ }
5364
+
5307
5365
  if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
5308
5366
  return handleAxFocusUpdate(req);
5309
5367
  }