pmx-canvas 0.1.30 → 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 (43) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/dist/canvas/global.css +56 -59
  3. package/dist/canvas/index.js +59 -59
  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/canvas-state.d.ts +7 -0
  12. package/dist/types/server/html-surface.d.ts +29 -0
  13. package/dist/types/server/index.d.ts +19 -3
  14. package/dist/types/server/server.d.ts +12 -0
  15. package/docs/sdk.md +3 -1
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +96 -1
  18. package/src/cli/agent.ts +18 -1
  19. package/src/cli/index.ts +8 -1
  20. package/src/client/App.tsx +3 -3
  21. package/src/client/canvas/CanvasNode.tsx +16 -1
  22. package/src/client/canvas/DockedNode.tsx +38 -38
  23. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
  24. package/src/client/nodes/ContextNode.tsx +1 -1
  25. package/src/client/nodes/HtmlNode.tsx +26 -1
  26. package/src/client/nodes/McpAppNode.tsx +35 -8
  27. package/src/client/nodes/StatusNode.tsx +0 -20
  28. package/src/client/nodes/surface-url.ts +12 -0
  29. package/src/client/state/canvas-store.ts +4 -0
  30. package/src/client/state/intent-bridge.ts +19 -0
  31. package/src/client/state/sse-bridge.ts +17 -0
  32. package/src/client/theme/global.css +56 -59
  33. package/src/json-render/renderer/index.tsx +31 -2
  34. package/src/json-render/server.ts +3 -0
  35. package/src/mcp/canvas-access.ts +6 -1
  36. package/src/mcp/server.ts +23 -1
  37. package/src/server/ax-context.ts +49 -1
  38. package/src/server/ax-interaction.ts +3 -0
  39. package/src/server/ax-state.ts +3 -1
  40. package/src/server/canvas-state.ts +30 -11
  41. package/src/server/html-surface.ts +70 -13
  42. package/src/server/index.ts +32 -7
  43. package/src/server/server.ts +117 -4
@@ -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',
@@ -442,18 +442,14 @@ class CanvasStateManager {
442
442
  }
443
443
 
444
444
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
445
- const normalized: CanvasNodeState = {
445
+ // Context nodes default to a right-docked, collapsed pill (see DockedNode.tsx),
446
+ // but that default is applied at CREATE time only — it must not be re-forced on
447
+ // every write, or the node could never be undocked. Undocking (dockPosition →
448
+ // null) is a deliberate user action and is respected here.
449
+ return {
446
450
  ...node,
447
451
  data: normalizeCanvasNodeData(node.type, node.data),
448
452
  };
449
- // Context nodes are always docked to the right side as a pill/panel widget
450
- // (see DockedNode.tsx). They start collapsed so the user sees the slim
451
- // pill first; expanding reveals the full context overview panel.
452
- if (normalized.type === 'context' && normalized.dockPosition !== 'right') {
453
- normalized.dockPosition = 'right';
454
- normalized.collapsed = true;
455
- }
456
- return normalized;
457
453
  }
458
454
 
459
455
  private nodeForRead(node: CanvasNodeState): CanvasNodeState {
@@ -930,6 +926,16 @@ class CanvasStateManager {
930
926
  return false;
931
927
  }
932
928
 
929
+ /**
930
+ * Whether this workspace's canvas DB already holds saved state. Used to gate
931
+ * brand-new-workspace seeding (e.g. the default docked status/context widgets)
932
+ * so we never add nodes to a canvas that already has content. Reflects the
933
+ * pre-run persisted flag until the next save.
934
+ */
935
+ hasPersistedState(): boolean {
936
+ return this._db ? isDbPopulated(this._db) : false;
937
+ }
938
+
933
939
  /** Debounced save — coalesces rapid mutations into a single write. */
934
940
  private scheduleSave(): void {
935
941
  if (!this._db) return;
@@ -1343,7 +1349,15 @@ class CanvasStateManager {
1343
1349
  }
1344
1350
 
1345
1351
  addNode(node: CanvasNodeState): void {
1346
- const cloned = structuredClone(this.normalizeNode(node));
1352
+ // Context nodes default to a right-docked, collapsed pill when created without
1353
+ // an explicit dock position. CREATE-time default only — once placed, updates
1354
+ // (including undock → dockPosition null) are respected (see normalizeNode).
1355
+ // Skip during suppressed replay (undo/redo re-add) so a deliberately-undocked
1356
+ // context node is restored verbatim instead of being snapped back to the dock.
1357
+ const seeded = node.type === 'context' && node.dockPosition == null && this._suppressRecordingDepth === 0
1358
+ ? { ...node, dockPosition: 'right' as const, collapsed: true }
1359
+ : node;
1360
+ const cloned = structuredClone(this.normalizeNode(seeded));
1347
1361
  this.nodes.set(node.id, cloned);
1348
1362
  this.scheduleSave();
1349
1363
  this.notifyChange('nodes');
@@ -1874,7 +1888,12 @@ class CanvasStateManager {
1874
1888
  // normalizeAxForCurrentNodes after apply, yet still returned as a phantom
1875
1889
  // success object — false success / silent data loss. Reject instead so the
1876
1890
  // HTTP/MCP layers surface ok:false / 4xx.
1877
- 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.
1878
1897
  if (anchorType === 'node' && (typeof input.nodeId !== 'string' || !this.currentNodeIdSet().has(input.nodeId))) {
1879
1898
  return null;
1880
1899
  }
@@ -107,27 +107,62 @@ 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
+
153
+ /** Escape a string for safe interpolation into element text (e.g. `<title>`). */
154
+ function escapeSurfaceHtml(value: string): string {
155
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
156
+ }
157
+
129
158
  export interface HtmlSurfaceOptions {
130
159
  theme: SurfaceTheme;
160
+ /**
161
+ * Tab/document title. Injected as `<title>` only when the author HTML does not
162
+ * already declare one, so a standalone "Open as site" tab shows the node title
163
+ * instead of falling back to the raw URL.
164
+ */
165
+ title?: string;
131
166
  /** Client nonce that authorizes parent → iframe theme-update messages. */
132
167
  themeToken?: string;
133
168
  presentation?: boolean;
@@ -138,6 +173,11 @@ export interface HtmlSurfaceOptions {
138
173
  axToken?: string;
139
174
  /** Node id stamped on emitted interactions. */
140
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;
141
181
  }
142
182
 
143
183
  /**
@@ -154,17 +194,34 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
154
194
  const axBridge = options.axBridge
155
195
  ? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId))
156
196
  : '';
157
- 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}`;
158
206
  const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
159
207
  const trimmed = userHtml.trim();
160
208
  const isFullDoc = /<html[\s>]/i.test(trimmed);
209
+ // Only supply a fallback <title> when the author HTML does not already set a
210
+ // DOCUMENT title. Strip inline <svg>/<math> first so a nested accessibility
211
+ // <title> (e.g. <svg><title>…</title></svg>) doesn't suppress the fallback.
212
+ const withoutNestedTitles = trimmed
213
+ .replace(/<svg[\s\S]*?<\/svg>/gi, '')
214
+ .replace(/<math[\s\S]*?<\/math>/gi, '');
215
+ const titleTag = options.title && !/<title[\s>]/i.test(withoutNestedTitles)
216
+ ? `<title>${escapeSurfaceHtml(options.title)}</title>`
217
+ : '';
161
218
  if (isFullDoc) {
162
219
  const withTheme = trimmed.replace(
163
220
  /<html([^>]*)>/i,
164
221
  `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`,
165
222
  );
166
- return injectIntoHead(withTheme, injectedHeadContent);
223
+ return injectIntoHead(withTheme, `${titleTag}${injectedHeadContent}`);
167
224
  }
168
225
  // 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>`;
226
+ return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${titleTag}${injectedHeadContent}</head><body>${userHtml}</body></html>`;
170
227
  }
@@ -74,6 +74,7 @@ import {
74
74
  } from './canvas-operations.js';
75
75
  import { validateCanvasLayout } from './canvas-validation.js';
76
76
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
77
+ import { serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
77
78
  import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
78
79
  import type { HtmlPrimitiveKind } from './html-primitives.js';
79
80
  import {
@@ -124,6 +125,19 @@ import type {
124
125
  PrimaryWorkbenchIntent,
125
126
  } from './server.js';
126
127
 
128
+ /**
129
+ * Node object returned by the SDK's create/get methods. It is the fully
130
+ * serialized node (adds `surfaceUrl`, `kind`, `title`, `content`, …) plus a
131
+ * `nodeId` alias for `id`, so the SDK return shape matches the HTTP/CLI
132
+ * `node`-create responses field-for-field.
133
+ */
134
+ export type SdkCanvasNode = SerializedCanvasNode & { nodeId: string };
135
+
136
+ /** Enrich a raw canvas node into the SDK return shape (surfaceUrl + nodeId). */
137
+ function toSdkNode(node: CanvasNodeState): SdkCanvasNode {
138
+ return { ...serializeCanvasNode(node), nodeId: node.id };
139
+ }
140
+
127
141
  export class PmxCanvas extends EventEmitter {
128
142
  private _port: number;
129
143
  private _server: string | null = null;
@@ -224,7 +238,7 @@ export class PmxCanvas extends EventEmitter {
224
238
  width?: number;
225
239
  height?: number;
226
240
  strictSize?: boolean;
227
- }): CanvasNodeState {
241
+ }): SdkCanvasNode {
228
242
  if (input.type === 'webpage') {
229
243
  throw new Error('Use addWebpageNode for webpage nodes so page content is fetched and cached on the server.');
230
244
  }
@@ -241,7 +255,7 @@ export class PmxCanvas extends EventEmitter {
241
255
  });
242
256
  const groupNode = canvasState.getNode(groupId);
243
257
  if (!groupNode) throw new Error(`Group node "${groupId}" was not created.`);
244
- return groupNode;
258
+ return toSdkNode(groupNode);
245
259
  }
246
260
  const { id, needsCodeGraphRecompute } = addCanvasNode({
247
261
  ...input,
@@ -277,7 +291,7 @@ export class PmxCanvas extends EventEmitter {
277
291
 
278
292
  const node = canvasState.getNode(id);
279
293
  if (!node) throw new Error(`Node "${id}" was not created.`);
280
- return node;
294
+ return toSdkNode(node);
281
295
  }
282
296
 
283
297
  async addWebpageNode(input: {
@@ -735,8 +749,9 @@ export class PmxCanvas extends EventEmitter {
735
749
  return canvasState.getLayout();
736
750
  }
737
751
 
738
- getNode(id: string): CanvasNodeState | undefined {
739
- return canvasState.getNode(id);
752
+ getNode(id: string): SdkCanvasNode | undefined {
753
+ const node = canvasState.getNode(id);
754
+ return node ? toSdkNode(node) : undefined;
740
755
  }
741
756
 
742
757
  search(query: string): ReturnType<typeof searchNodes> {
@@ -753,6 +768,9 @@ export class PmxCanvas extends EventEmitter {
753
768
  if (!entry) return { ok: false, description: 'Nothing to undo' };
754
769
  await syncCanvasRuntimeBackends();
755
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', {});
756
774
  return { ok: true, description: `Undid: ${entry.description}` };
757
775
  }
758
776
 
@@ -761,6 +779,7 @@ export class PmxCanvas extends EventEmitter {
761
779
  if (!entry) return { ok: false, description: 'Nothing to redo' };
762
780
  await syncCanvasRuntimeBackends();
763
781
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
782
+ emitPrimaryWorkbenchEvent('ax-state-changed', {});
764
783
  return { ok: true, description: `Redid: ${entry.description}` };
765
784
  }
766
785
 
@@ -1027,7 +1046,10 @@ export class PmxCanvas extends EventEmitter {
1027
1046
  width?: number;
1028
1047
  height?: number;
1029
1048
  strictSize?: boolean;
1030
- }): string {
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[] };
1052
+ }): SdkCanvasNode {
1031
1053
  const { id } = addCanvasNode({
1032
1054
  type: 'html',
1033
1055
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
@@ -1040,6 +1062,7 @@ export class PmxCanvas extends EventEmitter {
1040
1062
  ...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
1041
1063
  ...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
1042
1064
  ...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
1065
+ ...(input.axCapabilities ? { axCapabilities: input.axCapabilities } : {}),
1043
1066
  },
1044
1067
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
1045
1068
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
@@ -1050,7 +1073,9 @@ export class PmxCanvas extends EventEmitter {
1050
1073
  defaultHeight: 640,
1051
1074
  });
1052
1075
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1053
- return id;
1076
+ const node = canvasState.getNode(id);
1077
+ if (!node) throw new Error(`HTML node "${id}" was not created.`);
1078
+ return toSdkNode(node);
1054
1079
  }
1055
1080
 
1056
1081
  addHtmlPrimitive(input: {
@@ -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,14 +1432,21 @@ 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;
1436
+ const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim()
1437
+ ? node.data.title
1438
+ : node.id;
1435
1439
  const doc = buildHtmlSurfaceDocument(html, {
1436
1440
  theme,
1441
+ title: surfaceTitle,
1437
1442
  themeToken: url.searchParams.get('themeToken') ?? undefined,
1438
1443
  presentation: present,
1439
1444
  presentationExitToken: url.searchParams.get('presentToken') ?? undefined,
1440
- axBridge: axCaps.enabled && axCaps.allowed.length > 0,
1445
+ axBridge: axEnabled,
1441
1446
  axToken: url.searchParams.get('axToken') ?? undefined,
1442
1447
  nodeId: node.id,
1448
+ // Seed the read-side bridge with the current AX state (only for AX surfaces).
1449
+ ...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1443
1450
  });
1444
1451
  return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1445
1452
  }
@@ -2532,6 +2539,7 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2532
2539
  process.env.PMX_CANVAS_JSON_RENDER_DEVTOOLS === '1' &&
2533
2540
  url.searchParams.get('devtools') === '1';
2534
2541
  const axToken = url.searchParams.get('axToken');
2542
+ const axEnabled = resolveNodeAxCapabilities(node).enabled;
2535
2543
  const html = await buildJsonRenderViewerHtml({
2536
2544
  title,
2537
2545
  spec,
@@ -2539,6 +2547,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2539
2547
  ...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
2540
2548
  ...(devtoolsEnabled ? { devtools: true } : {}),
2541
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() } : {}),
2542
2552
  });
2543
2553
  return new Response(html, {
2544
2554
  headers: {
@@ -2596,7 +2606,27 @@ function handleArtifactView(url: URL): Response {
2596
2606
  }
2597
2607
 
2598
2608
  if (ext === '.html' || ext === '.htm') {
2599
- 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
+ }
2600
2630
  return new Response(content, {
2601
2631
  headers: {
2602
2632
  'Content-Type': 'text/html; charset=utf-8',
@@ -3823,6 +3853,30 @@ function handleGetAxContext(): Response {
3823
3853
  return responseJson(buildCanvasAxContext());
3824
3854
  }
3825
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
+
3826
3880
  async function handleAxInteraction(req: Request): Promise<Response> {
3827
3881
  const body = await readJson(req);
3828
3882
  const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
@@ -4482,6 +4536,57 @@ function syncContextNodeToCanvasState(
4482
4536
  canvasState.updateNode(id, { data: mergedData });
4483
4537
  }
4484
4538
 
4539
+ /**
4540
+ * Seed the docked status (left) + context (right) widgets so a freshly opened
4541
+ * canvas shows them by default — the same nodes the agent-event path creates on
4542
+ * demand (`status-main`, `context-main`), just present from the start.
4543
+ *
4544
+ * First-run only: we bail if the workspace canvas already has persisted state,
4545
+ * so we never add them to a board with content, and — because first-run state is
4546
+ * persisted on save — deleting or undocking them later is respected (they are
4547
+ * not re-seeded). Create-if-missing keeps it idempotent if the agent path
4548
+ * already made one. Returns true if anything was seeded.
4549
+ */
4550
+ export function ensureDefaultDockedNodes(): boolean {
4551
+ if (canvasState.hasPersistedState()) return false;
4552
+ let seeded = false;
4553
+ // NOTE: these node specs mirror the agent-event create paths below
4554
+ // (`canvas-status` for status-main, `syncContextNodeToCanvasState` for
4555
+ // context-main) — keep geometry/dock defaults in sync if you change them.
4556
+ if (!canvasState.getNode('status-main')) {
4557
+ canvasState.addNode({
4558
+ id: 'status-main',
4559
+ type: 'status',
4560
+ position: { x: 40, y: 80 },
4561
+ size: { width: 300, height: 120 },
4562
+ zIndex: 0,
4563
+ collapsed: true,
4564
+ pinned: false,
4565
+ dockPosition: 'left',
4566
+ data: { phase: 'idle', message: '', elapsed: 0 },
4567
+ });
4568
+ seeded = true;
4569
+ }
4570
+ if (!canvasState.getNode('context-main')) {
4571
+ canvasState.addNode({
4572
+ id: 'context-main',
4573
+ type: 'context',
4574
+ position: { x: 1130, y: 80 },
4575
+ size: { width: 320, height: 400 },
4576
+ zIndex: 1,
4577
+ collapsed: true,
4578
+ pinned: false,
4579
+ dockPosition: 'right',
4580
+ data: { cards: [], auxTabs: [] },
4581
+ });
4582
+ seeded = true;
4583
+ }
4584
+ if (seeded) {
4585
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
4586
+ }
4587
+ return seeded;
4588
+ }
4589
+
4485
4590
  // Maps responseNodeId -> thread prompt node ID for O(1) routing of response events
4486
4591
  const serverResponseToThreadMap = new Map<string, string>();
4487
4592
 
@@ -5249,6 +5354,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5249
5354
  return handleGetAxContext();
5250
5355
  }
5251
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
+
5252
5365
  if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
5253
5366
  return handleAxFocusUpdate(req);
5254
5367
  }