pmx-canvas 0.1.23 → 0.1.24

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 (51) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +36 -5
  4. package/dist/canvas/global.css +36 -3
  5. package/dist/canvas/index.js +54 -54
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
  7. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  8. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  9. package/dist/types/client/types.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +1 -1
  11. package/dist/types/mcp/canvas-access.d.ts +9 -0
  12. package/dist/types/server/ax-context.d.ts +3 -0
  13. package/dist/types/server/ax-state.d.ts +43 -0
  14. package/dist/types/server/canvas-db.d.ts +5 -0
  15. package/dist/types/server/canvas-state.d.ts +19 -3
  16. package/dist/types/server/index.d.ts +6 -0
  17. package/dist/types/server/mutation-history.d.ts +1 -1
  18. package/docs/cli.md +13 -0
  19. package/docs/http-api.md +24 -0
  20. package/docs/mcp.md +20 -2
  21. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  22. package/docs/screenshot.png +0 -0
  23. package/docs/sdk.md +5 -0
  24. package/package.json +2 -1
  25. package/skills/pmx-canvas/SKILL.md +14 -0
  26. package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
  27. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
  28. package/src/cli/agent.ts +34 -0
  29. package/src/cli/index.ts +2 -1
  30. package/src/client/App.tsx +2 -0
  31. package/src/client/canvas/CanvasNode.tsx +7 -0
  32. package/src/client/canvas/CommandPalette.tsx +2 -1
  33. package/src/client/canvas/use-node-drag.ts +29 -7
  34. package/src/client/canvas/use-node-resize.ts +27 -7
  35. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  36. package/src/client/nodes/HtmlNode.tsx +5 -2
  37. package/src/client/nodes/iframe-document-url.ts +58 -0
  38. package/src/client/state/intent-bridge.ts +8 -0
  39. package/src/client/state/sse-bridge.ts +2 -2
  40. package/src/client/theme/global.css +36 -3
  41. package/src/client/types.ts +1 -0
  42. package/src/mcp/canvas-access.ts +38 -0
  43. package/src/mcp/server.ts +113 -4
  44. package/src/server/ax-context.ts +38 -0
  45. package/src/server/ax-state.ts +130 -0
  46. package/src/server/canvas-db.ts +36 -1
  47. package/src/server/canvas-operations.ts +79 -0
  48. package/src/server/canvas-state.ts +113 -2
  49. package/src/server/index.ts +18 -0
  50. package/src/server/mutation-history.ts +1 -0
  51. package/src/server/server.ts +193 -8
@@ -30,7 +30,9 @@ import {
30
30
  checkpointCanvasDb,
31
31
  finalizeCanvasDbForClose,
32
32
  type PersistedCanvasState,
33
+ type CanvasTheme,
33
34
  } from './canvas-db.js';
35
+ import { normalizeCanvasTheme } from './canvas-db.js';
34
36
  import {
35
37
  type CanvasPlacementRect,
36
38
  computeGroupBounds,
@@ -39,6 +41,13 @@ import {
39
41
  GROUP_TITLEBAR_HEIGHT,
40
42
  resolveGroupCollision,
41
43
  } from './placement.js';
44
+ import {
45
+ createEmptyAxState,
46
+ normalizeAxState,
47
+ type PmxAxFocusState,
48
+ type PmxAxSource,
49
+ type PmxAxState,
50
+ } from './ax-state.js';
42
51
 
43
52
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
44
53
  console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
@@ -186,6 +195,7 @@ export interface CanvasAnnotation {
186
195
 
187
196
  export interface CanvasLayout {
188
197
  viewport: ViewportState;
198
+ theme: CanvasTheme;
189
199
  nodes: CanvasNodeState[];
190
200
  edges: CanvasEdge[];
191
201
  annotations: CanvasAnnotation[];
@@ -199,10 +209,10 @@ export interface CanvasNodeUpdate {
199
209
  dockPosition?: 'left' | 'right' | null;
200
210
  }
201
211
 
202
- export type CanvasChangeType = 'pins' | 'nodes';
212
+ export type CanvasChangeType = 'pins' | 'nodes' | 'ax';
203
213
 
204
214
  export interface MutationRecordInfo {
205
- operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
215
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
206
216
  description: string;
207
217
  forward: () => void;
208
218
  inverse: () => void;
@@ -260,7 +270,9 @@ class CanvasStateManager {
260
270
  private edges = new Map<string, CanvasEdge>();
261
271
  private annotations = new Map<string, CanvasAnnotation>();
262
272
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
273
+ private _theme: CanvasTheme = 'dark';
263
274
  private _contextPinnedNodeIds = new Set<string>();
275
+ private _axState: PmxAxState = createEmptyAxState();
264
276
  private _workspaceRoot = process.cwd();
265
277
 
266
278
  // ── Change listeners (for MCP resource notifications) ──────
@@ -310,6 +322,18 @@ class CanvasStateManager {
310
322
  }
311
323
  }
312
324
 
325
+ private currentNodeIdSet(): Set<string> {
326
+ return new Set(this.nodes.keys());
327
+ }
328
+
329
+ private normalizeAxForCurrentNodes(state: unknown): PmxAxState {
330
+ return normalizeAxState(state, this.currentNodeIdSet());
331
+ }
332
+
333
+ private applyAxState(state: PmxAxState): void {
334
+ this._axState = this.normalizeAxForCurrentNodes(state);
335
+ }
336
+
313
337
  private applyResolvedGroupBounds(
314
338
  group: CanvasNodeState,
315
339
  groupId: string,
@@ -788,11 +812,13 @@ class CanvasStateManager {
788
812
  private emptyPersistedState(): PersistedCanvasState {
789
813
  return {
790
814
  version: 1,
815
+ theme: this._theme,
791
816
  viewport: { x: 0, y: 0, scale: 1 },
792
817
  nodes: [],
793
818
  edges: [],
794
819
  annotations: [],
795
820
  contextPins: [],
821
+ ax: createEmptyAxState(),
796
822
  };
797
823
  }
798
824
 
@@ -863,11 +889,13 @@ class CanvasStateManager {
863
889
  try {
864
890
  const payload = this.externalizePersistedStateBlobs({
865
891
  version: 1,
892
+ theme: this._theme,
866
893
  viewport: this._viewport,
867
894
  nodes: Array.from(this.nodes.values()),
868
895
  edges: Array.from(this.edges.values()),
869
896
  annotations: Array.from(this.annotations.values()),
870
897
  contextPins: Array.from(this._contextPinnedNodeIds),
898
+ ax: this.getAxState(),
871
899
  });
872
900
  saveStateToDB(this._db, payload);
873
901
  } catch (error) {
@@ -909,12 +937,14 @@ class CanvasStateManager {
909
937
  this.edges.clear();
910
938
  this.annotations.clear();
911
939
  this._contextPinnedNodeIds.clear();
940
+ this._axState = createEmptyAxState();
912
941
 
913
942
  this._viewport = {
914
943
  x: state.viewport?.x ?? 0,
915
944
  y: state.viewport?.y ?? 0,
916
945
  scale: state.viewport?.scale ?? 1,
917
946
  };
947
+ this._theme = normalizeCanvasTheme(state.theme, this._theme);
918
948
 
919
949
  if (Array.isArray(state.nodes)) {
920
950
  for (const node of state.nodes) {
@@ -938,6 +968,7 @@ class CanvasStateManager {
938
968
  if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
939
969
  }
940
970
  }
971
+ this._axState = this.normalizeAxForCurrentNodes(state.ax);
941
972
  }
942
973
 
943
974
  private readResolvedSnapshot(idOrName: string): {
@@ -1028,11 +1059,13 @@ class CanvasStateManager {
1028
1059
  try {
1029
1060
  const payload = this.externalizePersistedStateBlobs({
1030
1061
  version: 1,
1062
+ theme: this._theme,
1031
1063
  viewport: this._viewport,
1032
1064
  nodes: Array.from(this.nodes.values()),
1033
1065
  edges: Array.from(this.edges.values()),
1034
1066
  annotations: Array.from(this.annotations.values()),
1035
1067
  contextPins: Array.from(this._contextPinnedNodeIds),
1068
+ ax: this.getAxState(),
1036
1069
  });
1037
1070
  saveSnapshotToDB(this._db, snapshot, payload);
1038
1071
  snapshot.nodeCount = payload.nodes.length;
@@ -1117,19 +1150,23 @@ class CanvasStateManager {
1117
1150
 
1118
1151
  const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
1119
1152
  version: 1,
1153
+ theme: this._theme,
1120
1154
  viewport: structuredClone(this._viewport),
1121
1155
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1122
1156
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1123
1157
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
1124
1158
  contextPins: Array.from(this._contextPinnedNodeIds),
1159
+ ax: this.getAxState(),
1125
1160
  });
1126
1161
  const nextState: PersistedCanvasState = {
1127
1162
  version: 1,
1163
+ theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
1128
1164
  viewport: structuredClone(resolved.state.viewport),
1129
1165
  nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
1130
1166
  edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
1131
1167
  annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
1132
1168
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1169
+ ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
1133
1170
  };
1134
1171
 
1135
1172
  try {
@@ -1137,6 +1174,7 @@ class CanvasStateManager {
1137
1174
  this.scheduleSave();
1138
1175
  this.notifyChange('nodes');
1139
1176
  this.notifyChange('pins');
1177
+ this.notifyChange('ax');
1140
1178
  this.recordMutation({
1141
1179
  operationType: 'restoreSnapshot',
1142
1180
  description: `Restored snapshot "${resolved.snapshot.name}"`,
@@ -1145,12 +1183,14 @@ class CanvasStateManager {
1145
1183
  this.scheduleSave();
1146
1184
  this.notifyChange('nodes');
1147
1185
  this.notifyChange('pins');
1186
+ this.notifyChange('ax');
1148
1187
  }),
1149
1188
  inverse: this.suppressed(() => {
1150
1189
  this.applyPersistedState(previousState);
1151
1190
  this.scheduleSave();
1152
1191
  this.notifyChange('nodes');
1153
1192
  this.notifyChange('pins');
1193
+ this.notifyChange('ax');
1154
1194
  }),
1155
1195
  });
1156
1196
  return true;
@@ -1287,6 +1327,7 @@ class CanvasStateManager {
1287
1327
  const existing = this.nodes.get(id);
1288
1328
  const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
1289
1329
  const cloned = existing ? structuredClone(existing) : null;
1330
+ const oldAxState = this.getAxState();
1290
1331
 
1291
1332
  // Prune from parent group's children list
1292
1333
  if (existing) {
@@ -1315,9 +1356,11 @@ class CanvasStateManager {
1315
1356
  this.nodes.delete(id);
1316
1357
  this.removeEdgesForNode(id);
1317
1358
  this._contextPinnedNodeIds.delete(id);
1359
+ this.applyAxState(this._axState);
1318
1360
  this.scheduleSave();
1319
1361
  this.notifyChange('nodes');
1320
1362
  this.notifyChange('pins');
1363
+ this.notifyChange('ax');
1321
1364
  if (cloned) {
1322
1365
  this.recordMutation({
1323
1366
  operationType: 'removeNode',
@@ -1326,6 +1369,9 @@ class CanvasStateManager {
1326
1369
  inverse: this.suppressed(() => {
1327
1370
  this.addNode(structuredClone(cloned));
1328
1371
  for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
1372
+ this.applyAxState(oldAxState);
1373
+ this.scheduleSave();
1374
+ this.notifyChange('ax');
1329
1375
  }),
1330
1376
  });
1331
1377
  }
@@ -1435,6 +1481,7 @@ class CanvasStateManager {
1435
1481
  getLayout(): CanvasLayout {
1436
1482
  return {
1437
1483
  viewport: structuredClone(this._viewport),
1484
+ theme: this._theme,
1438
1485
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1439
1486
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1440
1487
  annotations: this.getAnnotations(),
@@ -1444,6 +1491,7 @@ class CanvasStateManager {
1444
1491
  getLayoutForPersistence(): CanvasLayout {
1445
1492
  return {
1446
1493
  viewport: structuredClone(this._viewport),
1494
+ theme: this._theme,
1447
1495
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
1448
1496
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1449
1497
  annotations: this.getAnnotations(),
@@ -1563,12 +1611,70 @@ class CanvasStateManager {
1563
1611
  });
1564
1612
  }
1565
1613
 
1614
+ get theme(): CanvasTheme {
1615
+ return this._theme;
1616
+ }
1617
+
1618
+ setTheme(theme: CanvasTheme): CanvasTheme {
1619
+ const next = normalizeCanvasTheme(theme, this._theme);
1620
+ if (next === this._theme) return this._theme;
1621
+ this._theme = next;
1622
+ this.scheduleSave();
1623
+ this.notifyChange('nodes');
1624
+ return this._theme;
1625
+ }
1626
+
1566
1627
  // ── Context pins ─────────────────────────────────────────────
1567
1628
 
1568
1629
  get contextPinnedNodeIds(): Set<string> {
1569
1630
  return new Set(this._contextPinnedNodeIds);
1570
1631
  }
1571
1632
 
1633
+ getAxState(): PmxAxState {
1634
+ return structuredClone(this.normalizeAxForCurrentNodes(this._axState));
1635
+ }
1636
+
1637
+ getAxFocus(): PmxAxFocusState {
1638
+ return this.getAxState().focus;
1639
+ }
1640
+
1641
+ setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
1642
+ const oldAxState = this.getAxState();
1643
+ const nextAxState: PmxAxState = {
1644
+ ...oldAxState,
1645
+ focus: {
1646
+ nodeIds,
1647
+ primaryNodeId: nodeIds[0] ?? null,
1648
+ updatedAt: new Date().toISOString(),
1649
+ source: options.source ?? 'api',
1650
+ },
1651
+ };
1652
+ this.applyAxState(nextAxState);
1653
+ const appliedAxState = this.getAxState();
1654
+ this.scheduleSave();
1655
+ this.notifyChange('ax');
1656
+ if (options.recordHistory === false) return appliedAxState.focus;
1657
+ this.recordMutation({
1658
+ operationType: 'setAxFocus',
1659
+ description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
1660
+ forward: this.suppressed(() => {
1661
+ this.applyAxState(appliedAxState);
1662
+ this.scheduleSave();
1663
+ this.notifyChange('ax');
1664
+ }),
1665
+ inverse: this.suppressed(() => {
1666
+ this.applyAxState(oldAxState);
1667
+ this.scheduleSave();
1668
+ this.notifyChange('ax');
1669
+ }),
1670
+ });
1671
+ return appliedAxState.focus;
1672
+ }
1673
+
1674
+ clearAxFocus(): PmxAxFocusState {
1675
+ return this.setAxFocus([], { source: 'system' });
1676
+ }
1677
+
1572
1678
  setContextPins(nodeIds: string[]): void {
1573
1679
  const oldPins = Array.from(this._contextPinnedNodeIds);
1574
1680
  this._contextPinnedNodeIds.clear();
@@ -1694,15 +1800,18 @@ class CanvasStateManager {
1694
1800
  const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
1695
1801
  const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
1696
1802
  const oldPins = Array.from(this._contextPinnedNodeIds);
1803
+ const oldAxState = this.getAxState();
1697
1804
  const oldViewport = { ...this._viewport };
1698
1805
  this.nodes.clear();
1699
1806
  this.edges.clear();
1700
1807
  this.annotations.clear();
1701
1808
  this._contextPinnedNodeIds.clear();
1809
+ this._axState = createEmptyAxState();
1702
1810
  this._viewport = { x: 0, y: 0, scale: 1 };
1703
1811
  this.scheduleSave();
1704
1812
  this.notifyChange('nodes');
1705
1813
  this.notifyChange('pins');
1814
+ this.notifyChange('ax');
1706
1815
  this.recordMutation({
1707
1816
  operationType: 'clear',
1708
1817
  description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
@@ -1712,7 +1821,9 @@ class CanvasStateManager {
1712
1821
  for (const e of oldEdges) this.addEdge(structuredClone(e));
1713
1822
  for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
1714
1823
  this.setContextPins(oldPins);
1824
+ this.applyAxState(oldAxState);
1715
1825
  this.setViewport(oldViewport);
1826
+ this.notifyChange('ax');
1716
1827
  }),
1717
1828
  });
1718
1829
  }
@@ -1,6 +1,8 @@
1
1
  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
+ import { buildCanvasAxContext } from './ax-context.js';
5
+ import type { PmxAxContext, PmxAxFocusState, PmxAxSource, PmxAxState } from './ax-state.js';
4
6
  import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
5
7
  import { onFileNodeChanged } from './file-watcher.js';
6
8
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
@@ -402,7 +404,9 @@ export class PmxCanvas extends EventEmitter {
402
404
  y: node.position.y - 100,
403
405
  });
404
406
  }
407
+ const focus = canvasState.setAxFocus([id], { source: 'sdk', recordHistory: false });
405
408
  emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
409
+ emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
406
410
  if (!noPan) {
407
411
  emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
408
412
  }
@@ -410,6 +414,20 @@ export class PmxCanvas extends EventEmitter {
410
414
  return { focused: id, panned: !noPan };
411
415
  }
412
416
 
417
+ getAxState(): PmxAxState {
418
+ return canvasState.getAxState();
419
+ }
420
+
421
+ getAxContext(): PmxAxContext {
422
+ return buildCanvasAxContext();
423
+ }
424
+
425
+ setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): PmxAxFocusState {
426
+ const focus = canvasState.setAxFocus(nodeIds, { source: options?.source ?? 'sdk' });
427
+ emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
428
+ return focus;
429
+ }
430
+
413
431
  fitView(options?: {
414
432
  width?: number;
415
433
  height?: number;
@@ -28,6 +28,7 @@ export type MutationOp =
28
28
  | 'arrange'
29
29
  | 'restoreSnapshot'
30
30
  | 'setPins'
31
+ | 'setAxFocus'
31
32
  | 'batch'
32
33
  | 'viewport'
33
34
  | 'groupNodes'
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { spawnSync } from 'node:child_process';
38
+ import { randomUUID } from 'node:crypto';
38
39
  import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
39
40
  import { readFile } from 'node:fs/promises';
40
41
  import { basename, extname, join, relative, resolve } from 'node:path';
@@ -75,6 +76,9 @@ import {
75
76
  } from './canvas-serialization.js';
76
77
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
77
78
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
79
+ import { buildCanvasAxContext } from './ax-context.js';
80
+ import type { PmxAxSource } from './ax-state.js';
81
+ import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
78
82
  import { validateLocalImageFile } from './image-source.js';
79
83
  import {
80
84
  addCanvasNode,
@@ -145,9 +149,7 @@ let nextWorkbenchSubscriberId = 1;
145
149
  const workbenchSubscribers = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
146
150
  const textEncoder = new TextEncoder();
147
151
  let primaryWorkbenchAutoOpenEnabled = true;
148
- const canvasThemeSetting = (['dark', 'light', 'high-contrast'].includes(process.env.PMX_CANVAS_THEME ?? '')
149
- ? process.env.PMX_CANVAS_THEME!
150
- : 'dark');
152
+ const initialCanvasThemeSetting = normalizeCanvasTheme(process.env.PMX_CANVAS_THEME);
151
153
  let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
152
154
 
153
155
  function normalizeGraphViewerSpec(
@@ -1038,6 +1040,24 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
1038
1040
 
1039
1041
  // ── Canvas SPA HTML ────────────────────────────────────────────
1040
1042
 
1043
+ const CANVAS_ASSET_VERSION = Date.now().toString(36);
1044
+ const MAX_FRAME_DOCUMENTS = 128;
1045
+ const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
1046
+ const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
1047
+ const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
1048
+ 'allow-downloads',
1049
+ 'allow-forms',
1050
+ 'allow-modals',
1051
+ 'allow-orientation-lock',
1052
+ 'allow-pointer-lock',
1053
+ 'allow-popups',
1054
+ 'allow-popups-to-escape-sandbox',
1055
+ 'allow-presentation',
1056
+ 'allow-scripts',
1057
+ 'allow-storage-access-by-user-activation',
1058
+ ]);
1059
+ const frameDocuments = new Map<string, { html: string; sandbox: string }>();
1060
+
1041
1061
  function canvasSpaHtml(): string {
1042
1062
  return `<!doctype html>
1043
1063
  <html lang="en">
@@ -1106,7 +1126,7 @@ function canvasSpaHtml(): string {
1106
1126
  color: #eef4ff;
1107
1127
  }
1108
1128
  </style>
1109
- <link rel="stylesheet" href="/canvas/global.css" />
1129
+ <link rel="stylesheet" href="/canvas/global.css?v=${CANVAS_ASSET_VERSION}" />
1110
1130
  </head>
1111
1131
  <body>
1112
1132
  <div id="canvasBootstrap">
@@ -1143,7 +1163,7 @@ function canvasSpaHtml(): string {
1143
1163
  }, 4000);
1144
1164
  })();
1145
1165
  </script>
1146
- <script type="module" src="/canvas/index.js"></script>
1166
+ <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1147
1167
  </body>
1148
1168
  </html>`;
1149
1169
  }
@@ -1229,6 +1249,61 @@ function serveCanvasFavicon(): Response {
1229
1249
 
1230
1250
  // ── Canvas REST handlers ──────────────────────────────────────
1231
1251
 
1252
+ function normalizeFrameDocumentSandbox(value: unknown): string | null {
1253
+ if (value === undefined || value === null) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
1254
+ if (typeof value !== 'string') return null;
1255
+ const tokens = value.trim().split(/\s+/).filter(Boolean);
1256
+ if (tokens.length === 0) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
1257
+ const uniqueTokens: string[] = [];
1258
+ for (const token of tokens) {
1259
+ if (!SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS.has(token)) return null;
1260
+ if (!uniqueTokens.includes(token)) uniqueTokens.push(token);
1261
+ }
1262
+ return uniqueTokens.join(' ');
1263
+ }
1264
+
1265
+ function addFrameDocument(html: string, sandbox: string): string {
1266
+ const id = randomUUID();
1267
+ frameDocuments.set(id, { html, sandbox });
1268
+ while (frameDocuments.size > MAX_FRAME_DOCUMENTS) {
1269
+ const firstKey = frameDocuments.keys().next().value;
1270
+ if (typeof firstKey !== 'string') break;
1271
+ frameDocuments.delete(firstKey);
1272
+ }
1273
+ return `/api/canvas/frame-documents/${id}`;
1274
+ }
1275
+
1276
+ async function handleCreateFrameDocument(req: Request): Promise<Response> {
1277
+ const body = await readJson(req);
1278
+ const html = body.html;
1279
+ if (typeof html !== 'string' || !html) {
1280
+ return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
1281
+ }
1282
+ if (new TextEncoder().encode(html).byteLength > MAX_FRAME_DOCUMENT_BYTES) {
1283
+ return responseJson({ ok: false, error: 'Frame document is too large.' }, 413);
1284
+ }
1285
+ const sandbox = normalizeFrameDocumentSandbox(body.sandbox);
1286
+ if (!sandbox) {
1287
+ return responseJson({ ok: false, error: 'Frame document sandbox contains unsupported tokens.' }, 400);
1288
+ }
1289
+ return responseJson({ ok: true, url: addFrameDocument(html, sandbox) });
1290
+ }
1291
+
1292
+ function handleFrameDocument(pathname: string): Response {
1293
+ const id = decodeURIComponent(pathname.slice('/api/canvas/frame-documents/'.length));
1294
+ const document = frameDocuments.get(id);
1295
+ if (!document) return responseText('Frame document not found.', 404);
1296
+ return new Response(document.html, {
1297
+ headers: {
1298
+ 'Content-Type': 'text/html; charset=utf-8',
1299
+ 'Cache-Control': 'no-store',
1300
+ 'Content-Security-Policy': `sandbox ${document.sandbox}`,
1301
+ 'Referrer-Policy': 'no-referrer',
1302
+ 'X-Content-Type-Options': 'nosniff',
1303
+ },
1304
+ });
1305
+ }
1306
+
1232
1307
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1233
1308
  const body = await readJson(req);
1234
1309
  const updates = Array.isArray(body.updates) ? body.updates : [];
@@ -1817,10 +1892,16 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1817
1892
  const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
1818
1893
  canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
1819
1894
  }
1895
+ const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
1896
+ broadcastWorkbenchEvent('ax-state-changed', {
1897
+ focus,
1898
+ sessionId: primaryWorkbenchSessionId,
1899
+ timestamp: new Date().toISOString(),
1900
+ });
1820
1901
  emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
1821
1902
  if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1822
1903
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1823
- return responseJson({ ok: true, focused: nodeId, panned: !noPan });
1904
+ return responseJson({ ok: true, focused: nodeId, panned: !noPan, axFocus: focus });
1824
1905
  }
1825
1906
 
1826
1907
  async function handleCanvasFit(req: Request): Promise<Response> {
@@ -2105,6 +2186,18 @@ function handleCanvasValidate(): Response {
2105
2186
  return responseJson(validateCanvasLayout(canvasState.getLayout()));
2106
2187
  }
2107
2188
 
2189
+ async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
2190
+ const body = await readJson(req);
2191
+ const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
2192
+ const next = canvasState.setTheme(theme);
2193
+ broadcastWorkbenchEvent('theme-changed', {
2194
+ theme: next,
2195
+ sessionId: primaryWorkbenchSessionId,
2196
+ timestamp: new Date().toISOString(),
2197
+ });
2198
+ return responseJson({ ok: true, theme: next });
2199
+ }
2200
+
2108
2201
  async function handleJsonRenderView(url: URL): Promise<Response> {
2109
2202
  const nodeId = url.searchParams.get('nodeId') ?? '';
2110
2203
  if (!nodeId) return responseText('Missing nodeId', 400);
@@ -2973,7 +3066,7 @@ function handleWorkbenchEvents(req: Request): Response {
2973
3066
  requestedSessionId: requestedSessionId || null,
2974
3067
  continuity,
2975
3068
  path: primaryWorkbenchPath,
2976
- theme: canvasThemeSetting,
3069
+ theme: canvasState.theme,
2977
3070
  timestamp: new Date().toISOString(),
2978
3071
  }),
2979
3072
  );
@@ -3391,6 +3484,63 @@ function handleGetPinnedContext(): Response {
3391
3484
  return responseJson({ preamble, nodeIds: pinnedIds, count: pinnedIds.length, nodes });
3392
3485
  }
3393
3486
 
3487
+ function normalizeAxNodeIds(value: unknown): string[] {
3488
+ if (!Array.isArray(value)) return [];
3489
+ return value.filter((id): id is string => typeof id === 'string');
3490
+ }
3491
+
3492
+ function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
3493
+ return value === 'agent' ||
3494
+ value === 'api' ||
3495
+ value === 'browser' ||
3496
+ value === 'cli' ||
3497
+ value === 'codex' ||
3498
+ value === 'copilot' ||
3499
+ value === 'mcp' ||
3500
+ value === 'sdk' ||
3501
+ value === 'system'
3502
+ ? value
3503
+ : fallback;
3504
+ }
3505
+
3506
+ function handleGetAxState(): Response {
3507
+ return responseJson({ ok: true, state: canvasState.getAxState() });
3508
+ }
3509
+
3510
+ function handleGetAxContext(): Response {
3511
+ return responseJson(buildCanvasAxContext());
3512
+ }
3513
+
3514
+ async function handleAxFocusUpdate(req: Request): Promise<Response> {
3515
+ const body = await readJson(req);
3516
+ const nodeIds = normalizeAxNodeIds(body.nodeIds);
3517
+ const source = normalizeAxSource(body.source, 'api');
3518
+ const focus = canvasState.setAxFocus(nodeIds, { source });
3519
+ broadcastWorkbenchEvent('ax-state-changed', {
3520
+ focus,
3521
+ sessionId: primaryWorkbenchSessionId,
3522
+ timestamp: new Date().toISOString(),
3523
+ });
3524
+ return responseJson({ ok: true, focus });
3525
+ }
3526
+
3527
+ async function handleAxStatePatch(req: Request): Promise<Response> {
3528
+ const body = await readJson(req);
3529
+ if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
3530
+ return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
3531
+ }
3532
+ const focusInput = body.focus as Record<string, unknown>;
3533
+ const focus = canvasState.setAxFocus(normalizeAxNodeIds(focusInput.nodeIds), {
3534
+ source: normalizeAxSource(focusInput.source, 'api'),
3535
+ });
3536
+ broadcastWorkbenchEvent('ax-state-changed', {
3537
+ focus,
3538
+ sessionId: primaryWorkbenchSessionId,
3539
+ timestamp: new Date().toISOString(),
3540
+ });
3541
+ return responseJson({ ok: true, state: canvasState.getAxState() });
3542
+ }
3543
+
3394
3544
  // ── Port resolution ───────────────────────────────────────────
3395
3545
 
3396
3546
  function buildPortCandidates(preferredPort: number): number[] {
@@ -4075,6 +4225,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4075
4225
 
4076
4226
  // ── Canvas persistence: set workspace root and load saved state ──
4077
4227
  canvasState.setWorkspaceRoot(activeWorkspaceRoot);
4228
+ canvasState.setTheme(initialCanvasThemeSetting as CanvasTheme);
4078
4229
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
4079
4230
  setCanvasLayoutUpdateEmitter(() => {
4080
4231
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -4090,7 +4241,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4090
4241
  rotatePrimaryWorkbenchSessionIfNeeded();
4091
4242
 
4092
4243
  const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
4093
- const portCandidates = options.allowPortFallback === false
4244
+ const portCandidates = options.port === 0
4245
+ ? [0]
4246
+ : options.allowPortFallback === false
4094
4247
  ? [preferredPort > 0 ? Math.floor(preferredPort) : DEFAULT_PORT]
4095
4248
  : buildPortCandidates(preferredPort);
4096
4249
 
@@ -4119,6 +4272,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4119
4272
  return handleJsonRenderView(url);
4120
4273
  }
4121
4274
 
4275
+ if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
4276
+ return handleCreateFrameDocument(req);
4277
+ }
4278
+
4279
+ if (url.pathname.startsWith('/api/canvas/frame-documents/') && req.method === 'GET') {
4280
+ return handleFrameDocument(url.pathname);
4281
+ }
4282
+
4122
4283
  if (url.pathname === '/' || url.pathname === '/workbench' || url.pathname === '/artifact') {
4123
4284
  return new Response(canvasSpaHtml(), {
4124
4285
  headers: {
@@ -4192,6 +4353,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4192
4353
  return responseJson(buildCanvasSummary());
4193
4354
  }
4194
4355
 
4356
+ if (url.pathname === '/api/canvas/theme' && req.method === 'GET') {
4357
+ return responseJson({ ok: true, theme: canvasState.theme });
4358
+ }
4359
+
4360
+ if (url.pathname === '/api/canvas/theme' && req.method === 'POST') {
4361
+ return handleCanvasThemeUpdate(req);
4362
+ }
4363
+
4195
4364
  if (url.pathname === '/api/canvas/update' && req.method === 'POST') {
4196
4365
  return handleCanvasUpdate(req);
4197
4366
  }
@@ -4334,6 +4503,22 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4334
4503
  return handleGetPinnedContext();
4335
4504
  }
4336
4505
 
4506
+ if (url.pathname === '/api/canvas/ax' && req.method === 'GET') {
4507
+ return handleGetAxState();
4508
+ }
4509
+
4510
+ if (url.pathname === '/api/canvas/ax' && req.method === 'PATCH') {
4511
+ return handleAxStatePatch(req);
4512
+ }
4513
+
4514
+ if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
4515
+ return handleGetAxContext();
4516
+ }
4517
+
4518
+ if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
4519
+ return handleAxFocusUpdate(req);
4520
+ }
4521
+
4337
4522
  // Spatial context API
4338
4523
  if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
4339
4524
  const layout = canvasState.getLayout();