pmx-canvas 0.1.23 → 0.1.25

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 (54) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +123 -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/McpAppNode.d.ts +1 -0
  8. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  9. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  10. package/dist/types/client/types.d.ts +1 -0
  11. package/dist/types/json-render/catalog.d.ts +1 -1
  12. package/dist/types/mcp/canvas-access.d.ts +9 -0
  13. package/dist/types/server/ax-context.d.ts +3 -0
  14. package/dist/types/server/ax-state.d.ts +43 -0
  15. package/dist/types/server/canvas-db.d.ts +5 -0
  16. package/dist/types/server/canvas-operations.d.ts +4 -0
  17. package/dist/types/server/canvas-state.d.ts +20 -3
  18. package/dist/types/server/index.d.ts +6 -0
  19. package/dist/types/server/mutation-history.d.ts +1 -1
  20. package/docs/cli.md +13 -0
  21. package/docs/http-api.md +24 -0
  22. package/docs/mcp.md +20 -2
  23. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  24. package/docs/screenshot.png +0 -0
  25. package/docs/sdk.md +5 -0
  26. package/package.json +2 -1
  27. package/skills/pmx-canvas/SKILL.md +14 -0
  28. package/skills/pmx-canvas/references/codex-app-adapter.md +110 -0
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +125 -0
  30. package/src/cli/agent.ts +34 -0
  31. package/src/cli/index.ts +2 -1
  32. package/src/client/App.tsx +2 -0
  33. package/src/client/canvas/CanvasNode.tsx +7 -0
  34. package/src/client/canvas/CommandPalette.tsx +2 -1
  35. package/src/client/canvas/use-node-drag.ts +29 -7
  36. package/src/client/canvas/use-node-resize.ts +27 -7
  37. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  38. package/src/client/nodes/HtmlNode.tsx +5 -2
  39. package/src/client/nodes/McpAppNode.tsx +13 -1
  40. package/src/client/nodes/iframe-document-url.ts +58 -0
  41. package/src/client/state/intent-bridge.ts +8 -0
  42. package/src/client/state/sse-bridge.ts +3 -3
  43. package/src/client/theme/global.css +36 -3
  44. package/src/client/types.ts +1 -0
  45. package/src/mcp/canvas-access.ts +38 -0
  46. package/src/mcp/server.ts +113 -4
  47. package/src/server/ax-context.ts +38 -0
  48. package/src/server/ax-state.ts +130 -0
  49. package/src/server/canvas-db.ts +36 -1
  50. package/src/server/canvas-operations.ts +96 -4
  51. package/src/server/canvas-state.ts +123 -4
  52. package/src/server/index.ts +29 -2
  53. package/src/server/mutation-history.ts +12 -0
  54. package/src/server/server.ts +312 -14
@@ -103,7 +103,8 @@ interface CanvasAddNodeInput {
103
103
  strictSize?: boolean;
104
104
  }
105
105
 
106
- export const MARKDOWN_NODE_DEFAULT_SIZE = { width: 520, height: 360 };
106
+ export const MARKDOWN_NODE_DEFAULT_SIZE = { width: 640, height: 420 };
107
+ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
107
108
 
108
109
  interface CanvasCreateGroupInput {
109
110
  title?: string;
@@ -1099,8 +1100,84 @@ function collectGridArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string
1099
1100
  return excluded;
1100
1101
  }
1101
1102
 
1103
+ interface ArrangeObstacleRect {
1104
+ id: string;
1105
+ position: { x: number; y: number };
1106
+ size: { width: number; height: number };
1107
+ }
1108
+
1109
+ const GRID_OBSTACLE_GAP_Y = 72;
1110
+
1111
+ function rectsOverlap(a: ArrangeObstacleRect, b: ArrangeObstacleRect): boolean {
1112
+ return (
1113
+ a.position.x < b.position.x + b.size.width &&
1114
+ a.position.x + a.size.width > b.position.x &&
1115
+ a.position.y < b.position.y + b.size.height &&
1116
+ a.position.y + a.size.height > b.position.y
1117
+ );
1118
+ }
1119
+
1120
+ function collectGridArrangeObstacles(nodes: CanvasNodeState[], excludedIds: Set<string>): ArrangeObstacleRect[] {
1121
+ return nodes
1122
+ .filter((node) => excludedIds.has(node.id) && node.dockPosition === null)
1123
+ .map((node) => ({
1124
+ id: node.id,
1125
+ position: { ...node.position },
1126
+ size: { ...node.size },
1127
+ }));
1128
+ }
1129
+
1130
+ function buildUpdatedArrangeRect(
1131
+ update: CanvasNodeUpdate,
1132
+ nodesById: Map<string, CanvasNodeState>,
1133
+ ): ArrangeObstacleRect | null {
1134
+ const node = nodesById.get(update.id);
1135
+ if (!node) return null;
1136
+ return {
1137
+ id: update.id,
1138
+ position: update.position ? { ...update.position } : { ...node.position },
1139
+ size: update.size ? { ...update.size } : { ...node.size },
1140
+ };
1141
+ }
1142
+
1143
+ function shiftGridUpdatesBelowObstacles(
1144
+ updates: CanvasNodeUpdate[],
1145
+ nodes: CanvasNodeState[],
1146
+ obstacles: ArrangeObstacleRect[],
1147
+ ): CanvasNodeUpdate[] {
1148
+ if (updates.length === 0 || obstacles.length === 0) return updates;
1149
+
1150
+ // Grid arrange only sees movable nodes, so preserved locked/docked-group frames
1151
+ // need a separate obstacle pass before applying the planned positions.
1152
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
1153
+ let shifted = updates;
1154
+
1155
+ for (let attempt = 0; attempt <= obstacles.length; attempt++) {
1156
+ const plannedRects = shifted
1157
+ .map((update) => buildUpdatedArrangeRect(update, nodesById))
1158
+ .filter((rect): rect is ArrangeObstacleRect => rect !== null);
1159
+ if (plannedRects.length === 0) return shifted;
1160
+
1161
+ const blockers = obstacles.filter((obstacle) =>
1162
+ plannedRects.some((rect) => rect.id !== obstacle.id && rectsOverlap(rect, obstacle)),
1163
+ );
1164
+ if (blockers.length === 0) return shifted;
1165
+
1166
+ const minPlannedY = Math.min(...plannedRects.map((rect) => rect.position.y));
1167
+ const blockerBottom = Math.max(...blockers.map((rect) => rect.position.y + rect.size.height));
1168
+ const deltaY = blockerBottom + GRID_OBSTACLE_GAP_Y - minPlannedY;
1169
+ if (deltaY <= 0) return shifted;
1170
+
1171
+ shifted = shifted.map((update) => update.position
1172
+ ? { ...update, position: { x: update.position.x, y: update.position.y + deltaY } }
1173
+ : update);
1174
+ }
1175
+
1176
+ return shifted;
1177
+ }
1178
+
1102
1179
  export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: number; layout: CanvasArrangeMode } {
1103
- const nodes = canvasState.getLayout().nodes;
1180
+ const nodes = canvasState.getLayoutForPersistence().nodes;
1104
1181
  const excludedIds = layout === 'grid'
1105
1182
  ? collectGridArrangeExcludedNodeIds(nodes)
1106
1183
  : collectArrangeExcludedNodeIds(nodes);
@@ -1135,6 +1212,9 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
1135
1212
  size: { width: bounds.width, height: bounds.height },
1136
1213
  });
1137
1214
  }
1215
+ const obstacles = collectGridArrangeObstacles(nodes, excludedIds);
1216
+ const shiftedUpdates = shiftGridUpdatesBelowObstacles(updates, nodes, obstacles);
1217
+ updates.splice(0, updates.length, ...shiftedUpdates);
1138
1218
  }
1139
1219
 
1140
1220
  canvasState.withSuppressedRecording(() => {
@@ -1578,8 +1658,20 @@ export async function executeCanvasBatch(
1578
1658
  ...(typeof args.width === 'number' ? { width: args.width } : {}),
1579
1659
  ...(typeof args.height === 'number' ? { height: args.height } : {}),
1580
1660
  ...(args.strictSize === true ? { strictSize: true } : {}),
1581
- defaultWidth: type === 'html' ? 720 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
1582
- defaultHeight: type === 'html' ? 640 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.height : 200,
1661
+ defaultWidth: type === 'html'
1662
+ ? 720
1663
+ : type === 'markdown'
1664
+ ? MARKDOWN_NODE_DEFAULT_SIZE.width
1665
+ : type === 'mcp-app'
1666
+ ? MCP_APP_NODE_DEFAULT_SIZE.width
1667
+ : 360,
1668
+ defaultHeight: type === 'html'
1669
+ ? 640
1670
+ : type === 'markdown'
1671
+ ? MARKDOWN_NODE_DEFAULT_SIZE.height
1672
+ : type === 'mcp-app'
1673
+ ? MCP_APP_NODE_DEFAULT_SIZE.height
1674
+ : 200,
1583
1675
  fileMode: 'auto',
1584
1676
  });
1585
1677
  result = { ok: true, ...serializeCreatedNode(created.node) };
@@ -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,
@@ -376,6 +400,14 @@ class CanvasStateManager {
376
400
  return normalized;
377
401
  }
378
402
 
403
+ private nodeForRead(node: CanvasNodeState): CanvasNodeState {
404
+ const resolved = this.resolveNodeDataBlobs(node);
405
+ return {
406
+ ...resolved,
407
+ pinned: resolved.pinned || this._contextPinnedNodeIds.has(resolved.id),
408
+ };
409
+ }
410
+
379
411
  private reflowAllGroups(): void {
380
412
  const groups = Array.from(this.nodes.values())
381
413
  .filter((node): node is CanvasNodeState => node.type === 'group')
@@ -788,11 +820,13 @@ class CanvasStateManager {
788
820
  private emptyPersistedState(): PersistedCanvasState {
789
821
  return {
790
822
  version: 1,
823
+ theme: this._theme,
791
824
  viewport: { x: 0, y: 0, scale: 1 },
792
825
  nodes: [],
793
826
  edges: [],
794
827
  annotations: [],
795
828
  contextPins: [],
829
+ ax: createEmptyAxState(),
796
830
  };
797
831
  }
798
832
 
@@ -863,11 +897,13 @@ class CanvasStateManager {
863
897
  try {
864
898
  const payload = this.externalizePersistedStateBlobs({
865
899
  version: 1,
900
+ theme: this._theme,
866
901
  viewport: this._viewport,
867
902
  nodes: Array.from(this.nodes.values()),
868
903
  edges: Array.from(this.edges.values()),
869
904
  annotations: Array.from(this.annotations.values()),
870
905
  contextPins: Array.from(this._contextPinnedNodeIds),
906
+ ax: this.getAxState(),
871
907
  });
872
908
  saveStateToDB(this._db, payload);
873
909
  } catch (error) {
@@ -909,12 +945,14 @@ class CanvasStateManager {
909
945
  this.edges.clear();
910
946
  this.annotations.clear();
911
947
  this._contextPinnedNodeIds.clear();
948
+ this._axState = createEmptyAxState();
912
949
 
913
950
  this._viewport = {
914
951
  x: state.viewport?.x ?? 0,
915
952
  y: state.viewport?.y ?? 0,
916
953
  scale: state.viewport?.scale ?? 1,
917
954
  };
955
+ this._theme = normalizeCanvasTheme(state.theme, this._theme);
918
956
 
919
957
  if (Array.isArray(state.nodes)) {
920
958
  for (const node of state.nodes) {
@@ -938,6 +976,7 @@ class CanvasStateManager {
938
976
  if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
939
977
  }
940
978
  }
979
+ this._axState = this.normalizeAxForCurrentNodes(state.ax);
941
980
  }
942
981
 
943
982
  private readResolvedSnapshot(idOrName: string): {
@@ -1028,11 +1067,13 @@ class CanvasStateManager {
1028
1067
  try {
1029
1068
  const payload = this.externalizePersistedStateBlobs({
1030
1069
  version: 1,
1070
+ theme: this._theme,
1031
1071
  viewport: this._viewport,
1032
1072
  nodes: Array.from(this.nodes.values()),
1033
1073
  edges: Array.from(this.edges.values()),
1034
1074
  annotations: Array.from(this.annotations.values()),
1035
1075
  contextPins: Array.from(this._contextPinnedNodeIds),
1076
+ ax: this.getAxState(),
1036
1077
  });
1037
1078
  saveSnapshotToDB(this._db, snapshot, payload);
1038
1079
  snapshot.nodeCount = payload.nodes.length;
@@ -1117,19 +1158,23 @@ class CanvasStateManager {
1117
1158
 
1118
1159
  const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
1119
1160
  version: 1,
1161
+ theme: this._theme,
1120
1162
  viewport: structuredClone(this._viewport),
1121
1163
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1122
1164
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1123
1165
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
1124
1166
  contextPins: Array.from(this._contextPinnedNodeIds),
1167
+ ax: this.getAxState(),
1125
1168
  });
1126
1169
  const nextState: PersistedCanvasState = {
1127
1170
  version: 1,
1171
+ theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
1128
1172
  viewport: structuredClone(resolved.state.viewport),
1129
1173
  nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
1130
1174
  edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
1131
1175
  annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
1132
1176
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1177
+ ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
1133
1178
  };
1134
1179
 
1135
1180
  try {
@@ -1137,6 +1182,7 @@ class CanvasStateManager {
1137
1182
  this.scheduleSave();
1138
1183
  this.notifyChange('nodes');
1139
1184
  this.notifyChange('pins');
1185
+ this.notifyChange('ax');
1140
1186
  this.recordMutation({
1141
1187
  operationType: 'restoreSnapshot',
1142
1188
  description: `Restored snapshot "${resolved.snapshot.name}"`,
@@ -1145,12 +1191,14 @@ class CanvasStateManager {
1145
1191
  this.scheduleSave();
1146
1192
  this.notifyChange('nodes');
1147
1193
  this.notifyChange('pins');
1194
+ this.notifyChange('ax');
1148
1195
  }),
1149
1196
  inverse: this.suppressed(() => {
1150
1197
  this.applyPersistedState(previousState);
1151
1198
  this.scheduleSave();
1152
1199
  this.notifyChange('nodes');
1153
1200
  this.notifyChange('pins');
1201
+ this.notifyChange('ax');
1154
1202
  }),
1155
1203
  });
1156
1204
  return true;
@@ -1287,6 +1335,7 @@ class CanvasStateManager {
1287
1335
  const existing = this.nodes.get(id);
1288
1336
  const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
1289
1337
  const cloned = existing ? structuredClone(existing) : null;
1338
+ const oldAxState = this.getAxState();
1290
1339
 
1291
1340
  // Prune from parent group's children list
1292
1341
  if (existing) {
@@ -1315,9 +1364,11 @@ class CanvasStateManager {
1315
1364
  this.nodes.delete(id);
1316
1365
  this.removeEdgesForNode(id);
1317
1366
  this._contextPinnedNodeIds.delete(id);
1367
+ this.applyAxState(this._axState);
1318
1368
  this.scheduleSave();
1319
1369
  this.notifyChange('nodes');
1320
1370
  this.notifyChange('pins');
1371
+ this.notifyChange('ax');
1321
1372
  if (cloned) {
1322
1373
  this.recordMutation({
1323
1374
  operationType: 'removeNode',
@@ -1326,6 +1377,9 @@ class CanvasStateManager {
1326
1377
  inverse: this.suppressed(() => {
1327
1378
  this.addNode(structuredClone(cloned));
1328
1379
  for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
1380
+ this.applyAxState(oldAxState);
1381
+ this.scheduleSave();
1382
+ this.notifyChange('ax');
1329
1383
  }),
1330
1384
  });
1331
1385
  }
@@ -1333,7 +1387,7 @@ class CanvasStateManager {
1333
1387
 
1334
1388
  getNode(id: string): CanvasNodeState | undefined {
1335
1389
  const node = this.nodes.get(id);
1336
- return node ? structuredClone(this.resolveNodeDataBlobs(node)) : undefined;
1390
+ return node ? structuredClone(this.nodeForRead(node)) : undefined;
1337
1391
  }
1338
1392
 
1339
1393
  getNodeForPersistence(id: string): CanvasNodeState | undefined {
@@ -1435,7 +1489,8 @@ class CanvasStateManager {
1435
1489
  getLayout(): CanvasLayout {
1436
1490
  return {
1437
1491
  viewport: structuredClone(this._viewport),
1438
- nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1492
+ theme: this._theme,
1493
+ nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
1439
1494
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1440
1495
  annotations: this.getAnnotations(),
1441
1496
  };
@@ -1444,6 +1499,7 @@ class CanvasStateManager {
1444
1499
  getLayoutForPersistence(): CanvasLayout {
1445
1500
  return {
1446
1501
  viewport: structuredClone(this._viewport),
1502
+ theme: this._theme,
1447
1503
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
1448
1504
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1449
1505
  annotations: this.getAnnotations(),
@@ -1563,12 +1619,70 @@ class CanvasStateManager {
1563
1619
  });
1564
1620
  }
1565
1621
 
1622
+ get theme(): CanvasTheme {
1623
+ return this._theme;
1624
+ }
1625
+
1626
+ setTheme(theme: CanvasTheme): CanvasTheme {
1627
+ const next = normalizeCanvasTheme(theme, this._theme);
1628
+ if (next === this._theme) return this._theme;
1629
+ this._theme = next;
1630
+ this.scheduleSave();
1631
+ this.notifyChange('nodes');
1632
+ return this._theme;
1633
+ }
1634
+
1566
1635
  // ── Context pins ─────────────────────────────────────────────
1567
1636
 
1568
1637
  get contextPinnedNodeIds(): Set<string> {
1569
1638
  return new Set(this._contextPinnedNodeIds);
1570
1639
  }
1571
1640
 
1641
+ getAxState(): PmxAxState {
1642
+ return structuredClone(this.normalizeAxForCurrentNodes(this._axState));
1643
+ }
1644
+
1645
+ getAxFocus(): PmxAxFocusState {
1646
+ return this.getAxState().focus;
1647
+ }
1648
+
1649
+ setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
1650
+ const oldAxState = this.getAxState();
1651
+ const nextAxState: PmxAxState = {
1652
+ ...oldAxState,
1653
+ focus: {
1654
+ nodeIds,
1655
+ primaryNodeId: nodeIds[0] ?? null,
1656
+ updatedAt: new Date().toISOString(),
1657
+ source: options.source ?? 'api',
1658
+ },
1659
+ };
1660
+ this.applyAxState(nextAxState);
1661
+ const appliedAxState = this.getAxState();
1662
+ this.scheduleSave();
1663
+ this.notifyChange('ax');
1664
+ if (options.recordHistory === false) return appliedAxState.focus;
1665
+ this.recordMutation({
1666
+ operationType: 'setAxFocus',
1667
+ description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
1668
+ forward: this.suppressed(() => {
1669
+ this.applyAxState(appliedAxState);
1670
+ this.scheduleSave();
1671
+ this.notifyChange('ax');
1672
+ }),
1673
+ inverse: this.suppressed(() => {
1674
+ this.applyAxState(oldAxState);
1675
+ this.scheduleSave();
1676
+ this.notifyChange('ax');
1677
+ }),
1678
+ });
1679
+ return appliedAxState.focus;
1680
+ }
1681
+
1682
+ clearAxFocus(): PmxAxFocusState {
1683
+ return this.setAxFocus([], { source: 'system' });
1684
+ }
1685
+
1572
1686
  setContextPins(nodeIds: string[]): void {
1573
1687
  const oldPins = Array.from(this._contextPinnedNodeIds);
1574
1688
  this._contextPinnedNodeIds.clear();
@@ -1694,15 +1808,18 @@ class CanvasStateManager {
1694
1808
  const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
1695
1809
  const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
1696
1810
  const oldPins = Array.from(this._contextPinnedNodeIds);
1811
+ const oldAxState = this.getAxState();
1697
1812
  const oldViewport = { ...this._viewport };
1698
1813
  this.nodes.clear();
1699
1814
  this.edges.clear();
1700
1815
  this.annotations.clear();
1701
1816
  this._contextPinnedNodeIds.clear();
1817
+ this._axState = createEmptyAxState();
1702
1818
  this._viewport = { x: 0, y: 0, scale: 1 };
1703
1819
  this.scheduleSave();
1704
1820
  this.notifyChange('nodes');
1705
1821
  this.notifyChange('pins');
1822
+ this.notifyChange('ax');
1706
1823
  this.recordMutation({
1707
1824
  operationType: 'clear',
1708
1825
  description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
@@ -1712,7 +1829,9 @@ class CanvasStateManager {
1712
1829
  for (const e of oldEdges) this.addEdge(structuredClone(e));
1713
1830
  for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
1714
1831
  this.setContextPins(oldPins);
1832
+ this.applyAxState(oldAxState);
1715
1833
  this.setViewport(oldViewport);
1834
+ this.notifyChange('ax');
1716
1835
  }),
1717
1836
  });
1718
1837
  }
@@ -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';
@@ -11,6 +13,7 @@ import {
11
13
  addCanvasNode,
12
14
  addCanvasEdge,
13
15
  MARKDOWN_NODE_DEFAULT_SIZE,
16
+ MCP_APP_NODE_DEFAULT_SIZE,
14
17
  applyCanvasNodeUpdates,
15
18
  arrangeCanvasNodes,
16
19
  clearCanvas,
@@ -181,8 +184,16 @@ export class PmxCanvas extends EventEmitter {
181
184
  }
182
185
  const { id, needsCodeGraphRecompute } = addCanvasNode({
183
186
  ...input,
184
- defaultWidth: input.type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
185
- defaultHeight: input.type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.height : 200,
187
+ defaultWidth: input.type === 'markdown'
188
+ ? MARKDOWN_NODE_DEFAULT_SIZE.width
189
+ : input.type === 'mcp-app'
190
+ ? MCP_APP_NODE_DEFAULT_SIZE.width
191
+ : 360,
192
+ defaultHeight: input.type === 'markdown'
193
+ ? MARKDOWN_NODE_DEFAULT_SIZE.height
194
+ : input.type === 'mcp-app'
195
+ ? MCP_APP_NODE_DEFAULT_SIZE.height
196
+ : 200,
186
197
  fileMode: 'path',
187
198
  ...(input.strictSize ? { strictSize: true } : {}),
188
199
  });
@@ -402,7 +413,9 @@ export class PmxCanvas extends EventEmitter {
402
413
  y: node.position.y - 100,
403
414
  });
404
415
  }
416
+ const focus = canvasState.setAxFocus([id], { source: 'sdk', recordHistory: false });
405
417
  emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
418
+ emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
406
419
  if (!noPan) {
407
420
  emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
408
421
  }
@@ -410,6 +423,20 @@ export class PmxCanvas extends EventEmitter {
410
423
  return { focused: id, panned: !noPan };
411
424
  }
412
425
 
426
+ getAxState(): PmxAxState {
427
+ return canvasState.getAxState();
428
+ }
429
+
430
+ getAxContext(): PmxAxContext {
431
+ return buildCanvasAxContext();
432
+ }
433
+
434
+ setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): PmxAxFocusState {
435
+ const focus = canvasState.setAxFocus(nodeIds, { source: options?.source ?? 'sdk' });
436
+ emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
437
+ return focus;
438
+ }
439
+
413
440
  fitView(options?: {
414
441
  width?: number;
415
442
  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'
@@ -65,6 +66,13 @@ export interface SnapshotDiffResult {
65
66
  removedEdges: { id: string; from: string; to: string; type: string }[];
66
67
  }
67
68
 
69
+ function comparableNodeData(data: Record<string, unknown>): Record<string, unknown> {
70
+ const comparable = { ...data };
71
+ delete comparable.title;
72
+ delete comparable.content;
73
+ return comparable;
74
+ }
75
+
68
76
  // ── Ring Buffer ──────────────────────────────────────────────────────
69
77
 
70
78
  const MAX_ENTRIES = 200;
@@ -257,6 +265,10 @@ export function diffLayouts(
257
265
  changes.push(`content changed (${lenDiff >= 0 ? '+' : ''}${lenDiff} chars)`);
258
266
  }
259
267
 
268
+ if (JSON.stringify(comparableNodeData(snapNode.data)) !== JSON.stringify(comparableNodeData(curNode.data))) {
269
+ changes.push('data changed');
270
+ }
271
+
260
272
  if (changes.length > 0) {
261
273
  modifiedNodes.push({
262
274
  id,