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
@@ -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';
@@ -46,7 +47,7 @@ import type {
46
47
  ListResourceTemplatesResult,
47
48
  ListToolsResult,
48
49
  } from '@modelcontextprotocol/sdk/types.js';
49
- import { type CanvasAnnotation, type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
50
+ import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
50
51
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
51
52
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
52
53
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
@@ -75,11 +76,15 @@ 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,
81
85
  addCanvasEdge,
82
86
  MARKDOWN_NODE_DEFAULT_SIZE,
87
+ MCP_APP_NODE_DEFAULT_SIZE,
83
88
  applyCanvasNodeUpdates,
84
89
  buildStructuredNodeUpdate,
85
90
  arrangeCanvasNodes,
@@ -145,9 +150,7 @@ let nextWorkbenchSubscriberId = 1;
145
150
  const workbenchSubscribers = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
146
151
  const textEncoder = new TextEncoder();
147
152
  let primaryWorkbenchAutoOpenEnabled = true;
148
- const canvasThemeSetting = (['dark', 'light', 'high-contrast'].includes(process.env.PMX_CANVAS_THEME ?? '')
149
- ? process.env.PMX_CANVAS_THEME!
150
- : 'dark');
153
+ const initialCanvasThemeSetting = normalizeCanvasTheme(process.env.PMX_CANVAS_THEME);
151
154
  let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
152
155
 
153
156
  function normalizeGraphViewerSpec(
@@ -788,6 +791,64 @@ function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> |
788
791
  return value as Array<Record<string, unknown>>;
789
792
  }
790
793
 
794
+ type StringListField = { value?: string[]; error?: string };
795
+
796
+ function parseStringListField(field: string, value: unknown): StringListField {
797
+ if (value === undefined) return {};
798
+ if (!Array.isArray(value)) return { error: `"${field}" must be an array of node IDs.` };
799
+ const invalid = value.find((item) => typeof item !== 'string' || item.trim().length === 0);
800
+ if (invalid !== undefined) return { error: `"${field}" must contain only non-empty node IDs.` };
801
+ return { value };
802
+ }
803
+
804
+ function pickGroupChildIds(body: Record<string, unknown>): StringListField {
805
+ if ('children' in body) return parseStringListField('children', body.children);
806
+ if ('childIds' in body) return parseStringListField('childIds', body.childIds);
807
+ const data = isRecord(body.data) ? body.data : undefined;
808
+ return data && 'children' in data ? parseStringListField('data.children', data.children) : {};
809
+ }
810
+
811
+ function validateGroupChildIds(groupId: string, childIds: string[]): string | null {
812
+ const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
813
+ if (missingChildIds.length > 0) {
814
+ return `Missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`;
815
+ }
816
+ const invalidChildIds = childIds.filter((id) => {
817
+ const node = canvasState.getNode(id);
818
+ return id === groupId || node?.type === 'group';
819
+ });
820
+ if (invalidChildIds.length > 0) {
821
+ return `Invalid group child ID${invalidChildIds.length === 1 ? '' : 's'}: ${invalidChildIds.join(', ')}.`;
822
+ }
823
+ return null;
824
+ }
825
+
826
+ function setGroupChildrenFromApi(groupId: string, childIds: string[]): boolean {
827
+ const group = canvasState.getNode(groupId);
828
+ if (!group || group.type !== 'group') return false;
829
+
830
+ const dataChildIds = Array.isArray(group.data.children)
831
+ ? group.data.children.filter((id): id is string => typeof id === 'string')
832
+ : [];
833
+ const parentBackrefIds = canvasState.getLayout().nodes
834
+ .filter((node) => node.id !== groupId && node.data.parentGroup === groupId)
835
+ .map((node) => node.id);
836
+ const currentChildIds = [...new Set([...dataChildIds, ...parentBackrefIds])];
837
+ if (currentChildIds.length > 0) {
838
+ if (currentChildIds.length !== dataChildIds.length || currentChildIds.some((id) => !dataChildIds.includes(id))) {
839
+ canvasState.updateNode(groupId, { data: { ...group.data, children: currentChildIds } });
840
+ }
841
+ canvasState.ungroupNodes(groupId);
842
+ }
843
+ if (childIds.length === 0) return true;
844
+
845
+ const latestGroup = canvasState.getNode(groupId);
846
+ return canvasState.groupNodes(groupId, childIds, {
847
+ preservePositions: true,
848
+ keepGroupFrame: latestGroup?.data.frameMode === 'manual',
849
+ });
850
+ }
851
+
791
852
  function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
792
853
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
793
854
  const storedCheckpointId = appCheckpoint?.id;
@@ -1038,6 +1099,24 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
1038
1099
 
1039
1100
  // ── Canvas SPA HTML ────────────────────────────────────────────
1040
1101
 
1102
+ const CANVAS_ASSET_VERSION = Date.now().toString(36);
1103
+ const MAX_FRAME_DOCUMENTS = 128;
1104
+ const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
1105
+ const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
1106
+ const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
1107
+ 'allow-downloads',
1108
+ 'allow-forms',
1109
+ 'allow-modals',
1110
+ 'allow-orientation-lock',
1111
+ 'allow-pointer-lock',
1112
+ 'allow-popups',
1113
+ 'allow-popups-to-escape-sandbox',
1114
+ 'allow-presentation',
1115
+ 'allow-scripts',
1116
+ 'allow-storage-access-by-user-activation',
1117
+ ]);
1118
+ const frameDocuments = new Map<string, { html: string; sandbox: string }>();
1119
+
1041
1120
  function canvasSpaHtml(): string {
1042
1121
  return `<!doctype html>
1043
1122
  <html lang="en">
@@ -1106,7 +1185,7 @@ function canvasSpaHtml(): string {
1106
1185
  color: #eef4ff;
1107
1186
  }
1108
1187
  </style>
1109
- <link rel="stylesheet" href="/canvas/global.css" />
1188
+ <link rel="stylesheet" href="/canvas/global.css?v=${CANVAS_ASSET_VERSION}" />
1110
1189
  </head>
1111
1190
  <body>
1112
1191
  <div id="canvasBootstrap">
@@ -1143,7 +1222,7 @@ function canvasSpaHtml(): string {
1143
1222
  }, 4000);
1144
1223
  })();
1145
1224
  </script>
1146
- <script type="module" src="/canvas/index.js"></script>
1225
+ <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1147
1226
  </body>
1148
1227
  </html>`;
1149
1228
  }
@@ -1229,6 +1308,61 @@ function serveCanvasFavicon(): Response {
1229
1308
 
1230
1309
  // ── Canvas REST handlers ──────────────────────────────────────
1231
1310
 
1311
+ function normalizeFrameDocumentSandbox(value: unknown): string | null {
1312
+ if (value === undefined || value === null) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
1313
+ if (typeof value !== 'string') return null;
1314
+ const tokens = value.trim().split(/\s+/).filter(Boolean);
1315
+ if (tokens.length === 0) return DEFAULT_FRAME_DOCUMENT_SANDBOX;
1316
+ const uniqueTokens: string[] = [];
1317
+ for (const token of tokens) {
1318
+ if (!SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS.has(token)) return null;
1319
+ if (!uniqueTokens.includes(token)) uniqueTokens.push(token);
1320
+ }
1321
+ return uniqueTokens.join(' ');
1322
+ }
1323
+
1324
+ function addFrameDocument(html: string, sandbox: string): string {
1325
+ const id = randomUUID();
1326
+ frameDocuments.set(id, { html, sandbox });
1327
+ while (frameDocuments.size > MAX_FRAME_DOCUMENTS) {
1328
+ const firstKey = frameDocuments.keys().next().value;
1329
+ if (typeof firstKey !== 'string') break;
1330
+ frameDocuments.delete(firstKey);
1331
+ }
1332
+ return `/api/canvas/frame-documents/${id}`;
1333
+ }
1334
+
1335
+ async function handleCreateFrameDocument(req: Request): Promise<Response> {
1336
+ const body = await readJson(req);
1337
+ const html = body.html;
1338
+ if (typeof html !== 'string' || !html) {
1339
+ return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
1340
+ }
1341
+ if (new TextEncoder().encode(html).byteLength > MAX_FRAME_DOCUMENT_BYTES) {
1342
+ return responseJson({ ok: false, error: 'Frame document is too large.' }, 413);
1343
+ }
1344
+ const sandbox = normalizeFrameDocumentSandbox(body.sandbox);
1345
+ if (!sandbox) {
1346
+ return responseJson({ ok: false, error: 'Frame document sandbox contains unsupported tokens.' }, 400);
1347
+ }
1348
+ return responseJson({ ok: true, url: addFrameDocument(html, sandbox) });
1349
+ }
1350
+
1351
+ function handleFrameDocument(pathname: string): Response {
1352
+ const id = decodeURIComponent(pathname.slice('/api/canvas/frame-documents/'.length));
1353
+ const document = frameDocuments.get(id);
1354
+ if (!document) return responseText('Frame document not found.', 404);
1355
+ return new Response(document.html, {
1356
+ headers: {
1357
+ 'Content-Type': 'text/html; charset=utf-8',
1358
+ 'Cache-Control': 'no-store',
1359
+ 'Content-Security-Policy': `sandbox ${document.sandbox}`,
1360
+ 'Referrer-Policy': 'no-referrer',
1361
+ 'X-Content-Type-Options': 'nosniff',
1362
+ },
1363
+ });
1364
+ }
1365
+
1232
1366
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1233
1367
  const body = await readJson(req);
1234
1368
  const updates = Array.isArray(body.updates) ? body.updates : [];
@@ -1392,6 +1526,20 @@ function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1392
1526
  };
1393
1527
  }
1394
1528
 
1529
+ function withContextPinReadState(node: CanvasNodeState): CanvasNodeState {
1530
+ return {
1531
+ ...node,
1532
+ pinned: node.pinned || canvasState.contextPinnedNodeIds.has(node.id),
1533
+ };
1534
+ }
1535
+
1536
+ function withContextPinLayoutReadState(layout: CanvasLayout): CanvasLayout {
1537
+ return {
1538
+ ...layout,
1539
+ nodes: layout.nodes.map(withContextPinReadState),
1540
+ };
1541
+ }
1542
+
1395
1543
  async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<Response> {
1396
1544
  const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
1397
1545
  ? body.url
@@ -1472,6 +1620,23 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1472
1620
  return createCanvasHtmlPrimitiveNode(body);
1473
1621
  }
1474
1622
 
1623
+ if (type === 'group') {
1624
+ const geometry = resolveCreateGeometry(body);
1625
+ const childList = pickGroupChildIds(body);
1626
+ if (childList.error) return responseJson({ ok: false, error: `Cannot create group: ${childList.error}` }, 400);
1627
+ const childIds = childList.value ?? [];
1628
+ const childError = validateGroupChildIds('', childIds);
1629
+ if (childError) return responseJson({ ok: false, error: `Cannot create group: ${childError}` }, 400);
1630
+ const { node } = createCanvasGroup({
1631
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
1632
+ childIds,
1633
+ ...(typeof body.color === 'string' ? { color: body.color } : {}),
1634
+ ...geometry,
1635
+ });
1636
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1637
+ return responseJson(buildNodeResponse(node));
1638
+ }
1639
+
1475
1640
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1476
1641
  ? body.data as Record<string, unknown>
1477
1642
  : undefined;
@@ -1517,8 +1682,20 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1517
1682
  ...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
1518
1683
  ...(body.strictSize === true ? { strictSize: true } : {}),
1519
1684
  ...geometry,
1520
- defaultWidth: type === 'html' ? 720 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
1521
- defaultHeight: type === 'html' ? 640 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.height : 200,
1685
+ defaultWidth: type === 'html'
1686
+ ? 720
1687
+ : type === 'markdown'
1688
+ ? MARKDOWN_NODE_DEFAULT_SIZE.width
1689
+ : type === 'mcp-app'
1690
+ ? MCP_APP_NODE_DEFAULT_SIZE.width
1691
+ : 360,
1692
+ defaultHeight: type === 'html'
1693
+ ? 640
1694
+ : type === 'markdown'
1695
+ ? MARKDOWN_NODE_DEFAULT_SIZE.height
1696
+ : type === 'mcp-app'
1697
+ ? MCP_APP_NODE_DEFAULT_SIZE.height
1698
+ : 200,
1522
1699
  fileMode: 'auto',
1523
1700
  });
1524
1701
  } catch (error) {
@@ -1722,6 +1899,13 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1722
1899
  if (existing.type === 'webpage' && body.refresh === true) {
1723
1900
  return handleCanvasRefreshWebpageNode(nodeId, req);
1724
1901
  }
1902
+ const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
1903
+ if (groupChildList.error) return responseJson({ ok: false, error: `Cannot update group: ${groupChildList.error}` }, 400);
1904
+ const groupChildIds = groupChildList.value;
1905
+ if (groupChildIds !== undefined) {
1906
+ const childError = validateGroupChildIds(nodeId, groupChildIds);
1907
+ if (childError) return responseJson({ ok: false, error: `Cannot update group: ${childError}` }, 400);
1908
+ }
1725
1909
  const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
1726
1910
  if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
1727
1911
  if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
@@ -1780,6 +1964,9 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1780
1964
  });
1781
1965
  if (error) return responseJson({ ok: false, error }, 400);
1782
1966
  canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
1967
+ if (groupChildIds !== undefined && !setGroupChildrenFromApi(nodeId, groupChildIds)) {
1968
+ return responseJson({ ok: false, error: `Group "${nodeId}" not found.` }, 404);
1969
+ }
1783
1970
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1784
1971
  const updated = canvasState.getNode(nodeId);
1785
1972
  return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
@@ -1817,10 +2004,16 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1817
2004
  const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
1818
2005
  canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
1819
2006
  }
2007
+ const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
2008
+ broadcastWorkbenchEvent('ax-state-changed', {
2009
+ focus,
2010
+ sessionId: primaryWorkbenchSessionId,
2011
+ timestamp: new Date().toISOString(),
2012
+ });
1820
2013
  emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
1821
2014
  if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1822
2015
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1823
- return responseJson({ ok: true, focused: nodeId, panned: !noPan });
2016
+ return responseJson({ ok: true, focused: nodeId, panned: !noPan, axFocus: focus });
1824
2017
  }
1825
2018
 
1826
2019
  async function handleCanvasFit(req: Request): Promise<Response> {
@@ -2105,6 +2298,18 @@ function handleCanvasValidate(): Response {
2105
2298
  return responseJson(validateCanvasLayout(canvasState.getLayout()));
2106
2299
  }
2107
2300
 
2301
+ async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
2302
+ const body = await readJson(req);
2303
+ const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
2304
+ const next = canvasState.setTheme(theme);
2305
+ broadcastWorkbenchEvent('theme-changed', {
2306
+ theme: next,
2307
+ sessionId: primaryWorkbenchSessionId,
2308
+ timestamp: new Date().toISOString(),
2309
+ });
2310
+ return responseJson({ ok: true, theme: next });
2311
+ }
2312
+
2108
2313
  async function handleJsonRenderView(url: URL): Promise<Response> {
2109
2314
  const nodeId = url.searchParams.get('nodeId') ?? '';
2110
2315
  if (!nodeId) return responseText('Missing nodeId', 400);
@@ -2973,7 +3178,7 @@ function handleWorkbenchEvents(req: Request): Response {
2973
3178
  requestedSessionId: requestedSessionId || null,
2974
3179
  continuity,
2975
3180
  path: primaryWorkbenchPath,
2976
- theme: canvasThemeSetting,
3181
+ theme: canvasState.theme,
2977
3182
  timestamp: new Date().toISOString(),
2978
3183
  }),
2979
3184
  );
@@ -3391,6 +3596,63 @@ function handleGetPinnedContext(): Response {
3391
3596
  return responseJson({ preamble, nodeIds: pinnedIds, count: pinnedIds.length, nodes });
3392
3597
  }
3393
3598
 
3599
+ function normalizeAxNodeIds(value: unknown): string[] {
3600
+ if (!Array.isArray(value)) return [];
3601
+ return value.filter((id): id is string => typeof id === 'string');
3602
+ }
3603
+
3604
+ function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
3605
+ return value === 'agent' ||
3606
+ value === 'api' ||
3607
+ value === 'browser' ||
3608
+ value === 'cli' ||
3609
+ value === 'codex' ||
3610
+ value === 'copilot' ||
3611
+ value === 'mcp' ||
3612
+ value === 'sdk' ||
3613
+ value === 'system'
3614
+ ? value
3615
+ : fallback;
3616
+ }
3617
+
3618
+ function handleGetAxState(): Response {
3619
+ return responseJson({ ok: true, state: canvasState.getAxState() });
3620
+ }
3621
+
3622
+ function handleGetAxContext(): Response {
3623
+ return responseJson(buildCanvasAxContext());
3624
+ }
3625
+
3626
+ async function handleAxFocusUpdate(req: Request): Promise<Response> {
3627
+ const body = await readJson(req);
3628
+ const nodeIds = normalizeAxNodeIds(body.nodeIds);
3629
+ const source = normalizeAxSource(body.source, 'api');
3630
+ const focus = canvasState.setAxFocus(nodeIds, { source });
3631
+ broadcastWorkbenchEvent('ax-state-changed', {
3632
+ focus,
3633
+ sessionId: primaryWorkbenchSessionId,
3634
+ timestamp: new Date().toISOString(),
3635
+ });
3636
+ return responseJson({ ok: true, focus });
3637
+ }
3638
+
3639
+ async function handleAxStatePatch(req: Request): Promise<Response> {
3640
+ const body = await readJson(req);
3641
+ if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
3642
+ return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
3643
+ }
3644
+ const focusInput = body.focus as Record<string, unknown>;
3645
+ const focus = canvasState.setAxFocus(normalizeAxNodeIds(focusInput.nodeIds), {
3646
+ source: normalizeAxSource(focusInput.source, 'api'),
3647
+ });
3648
+ broadcastWorkbenchEvent('ax-state-changed', {
3649
+ focus,
3650
+ sessionId: primaryWorkbenchSessionId,
3651
+ timestamp: new Date().toISOString(),
3652
+ });
3653
+ return responseJson({ ok: true, state: canvasState.getAxState() });
3654
+ }
3655
+
3394
3656
  // ── Port resolution ───────────────────────────────────────────
3395
3657
 
3396
3658
  function buildPortCandidates(preferredPort: number): number[] {
@@ -4075,6 +4337,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4075
4337
 
4076
4338
  // ── Canvas persistence: set workspace root and load saved state ──
4077
4339
  canvasState.setWorkspaceRoot(activeWorkspaceRoot);
4340
+ canvasState.setTheme(initialCanvasThemeSetting as CanvasTheme);
4078
4341
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
4079
4342
  setCanvasLayoutUpdateEmitter(() => {
4080
4343
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -4090,7 +4353,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4090
4353
  rotatePrimaryWorkbenchSessionIfNeeded();
4091
4354
 
4092
4355
  const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
4093
- const portCandidates = options.allowPortFallback === false
4356
+ const portCandidates = options.port === 0
4357
+ ? [0]
4358
+ : options.allowPortFallback === false
4094
4359
  ? [preferredPort > 0 ? Math.floor(preferredPort) : DEFAULT_PORT]
4095
4360
  : buildPortCandidates(preferredPort);
4096
4361
 
@@ -4119,6 +4384,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4119
4384
  return handleJsonRenderView(url);
4120
4385
  }
4121
4386
 
4387
+ if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
4388
+ return handleCreateFrameDocument(req);
4389
+ }
4390
+
4391
+ if (url.pathname.startsWith('/api/canvas/frame-documents/') && req.method === 'GET') {
4392
+ return handleFrameDocument(url.pathname);
4393
+ }
4394
+
4122
4395
  if (url.pathname === '/' || url.pathname === '/workbench' || url.pathname === '/artifact') {
4123
4396
  return new Response(canvasSpaHtml(), {
4124
4397
  headers: {
@@ -4185,13 +4458,21 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4185
4458
  const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
4186
4459
  return responseJson(includeBlobs
4187
4460
  ? serializeCanvasLayout(canvasState.getLayout())
4188
- : serializeCanvasLayoutWithBlobSummaries(canvasState.getLayoutForPersistence()));
4461
+ : serializeCanvasLayoutWithBlobSummaries(withContextPinLayoutReadState(canvasState.getLayoutForPersistence())));
4189
4462
  }
4190
4463
 
4191
4464
  if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
4192
4465
  return responseJson(buildCanvasSummary());
4193
4466
  }
4194
4467
 
4468
+ if (url.pathname === '/api/canvas/theme' && req.method === 'GET') {
4469
+ return responseJson({ ok: true, theme: canvasState.theme });
4470
+ }
4471
+
4472
+ if (url.pathname === '/api/canvas/theme' && req.method === 'POST') {
4473
+ return handleCanvasThemeUpdate(req);
4474
+ }
4475
+
4195
4476
  if (url.pathname === '/api/canvas/update' && req.method === 'POST') {
4196
4477
  return handleCanvasUpdate(req);
4197
4478
  }
@@ -4247,9 +4528,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4247
4528
  const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
4248
4529
  const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
4249
4530
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
4531
+ const responseNode = withContextPinReadState(node);
4250
4532
  return responseJson(includeBlobs
4251
- ? serializeCanvasNode(node)
4252
- : serializeCanvasNodeWithBlobSummaries(node));
4533
+ ? serializeCanvasNode(responseNode)
4534
+ : serializeCanvasNodeWithBlobSummaries(responseNode));
4253
4535
  }
4254
4536
 
4255
4537
  if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
@@ -4334,6 +4616,22 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4334
4616
  return handleGetPinnedContext();
4335
4617
  }
4336
4618
 
4619
+ if (url.pathname === '/api/canvas/ax' && req.method === 'GET') {
4620
+ return handleGetAxState();
4621
+ }
4622
+
4623
+ if (url.pathname === '/api/canvas/ax' && req.method === 'PATCH') {
4624
+ return handleAxStatePatch(req);
4625
+ }
4626
+
4627
+ if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
4628
+ return handleGetAxContext();
4629
+ }
4630
+
4631
+ if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
4632
+ return handleAxFocusUpdate(req);
4633
+ }
4634
+
4337
4635
  // Spatial context API
4338
4636
  if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
4339
4637
  const layout = canvasState.getLayout();