pmx-canvas 0.1.24 → 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.
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ export declare function isSameOriginFrameDocumentUrl(url: string, origin?: string): boolean;
2
3
  export declare function McpAppNode({ node, expanded }: {
3
4
  node: CanvasNodeState;
4
5
  expanded?: boolean;
@@ -60,6 +60,10 @@ export declare const MARKDOWN_NODE_DEFAULT_SIZE: {
60
60
  width: number;
61
61
  height: number;
62
62
  };
63
+ export declare const MCP_APP_NODE_DEFAULT_SIZE: {
64
+ width: number;
65
+ height: number;
66
+ };
63
67
  interface CanvasCreateGroupInput {
64
68
  title?: string;
65
69
  childIds?: string[];
@@ -163,6 +163,7 @@ declare class CanvasStateManager {
163
163
  private applyResolvedGroupBounds;
164
164
  private getGroupSnapshot;
165
165
  private normalizeNode;
166
+ private nodeForRead;
166
167
  private reflowAllGroups;
167
168
  private translateGroupChildren;
168
169
  private recomputeParentGroupBounds;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -62,8 +62,10 @@ in the Codex in-app Browser, usually `http://127.0.0.1:4313/workbench` or
62
62
 
63
63
  ## Codex-Native Workflow
64
64
 
65
- 1. Start or connect to the PMX MCP server.
66
- 2. Open `/workbench` in the Codex in-app Browser.
65
+ 1. Open `/workbench` in the Codex in-app Browser as the first visible action. If PMX is not running
66
+ yet, start/connect the MCP server first only long enough to get the workbench URL, then open the
67
+ browser before mutating the board.
68
+ 2. Start or keep using the PMX MCP server for agent operations.
67
69
  3. Use the browser canvas for human spatial curation: pin nodes, move nodes, group nodes, and
68
70
  inspect rendered artifacts.
69
71
  4. Use MCP tools for agent operations: create/update nodes, pin nodes, read layout, and read AX
@@ -90,9 +92,10 @@ Codex agents should treat PMX AX context as host-native working context:
90
92
 
91
93
  ## Live-Test Checklist
92
94
 
93
- 1. Confirm the PMX MCP server is configured for the workspace.
94
- 2. Call `canvas_get_ax` and confirm it returns `ok: true`.
95
- 3. Open `http://127.0.0.1:4313/workbench` in the Codex in-app Browser.
95
+ 1. Open `http://127.0.0.1:4313/workbench` in the Codex in-app Browser first so the user can see
96
+ all later canvas mutations.
97
+ 2. Confirm the PMX MCP server is configured for the workspace.
98
+ 3. Call `canvas_get_ax` and confirm it returns `ok: true`.
96
99
  4. Add or reuse a node, then pin it from the browser or with `canvas_pin_nodes`.
97
100
  5. Read `canvas://ax-context` and confirm the pinned node appears.
98
101
  6. Call `canvas_set_ax_focus` with `source: "codex"` and a real node ID.
@@ -11,6 +11,20 @@ maps Copilot SDK features onto PMX AX primitives.
11
11
  - Canvas ID: `pmx-canvas`
12
12
  - Display name: `PMX Canvas`
13
13
 
14
+ ## Quick Start
15
+
16
+ 1. Install the project adapter by copying the packaged extension into the repository:
17
+ `mkdir -p .github/extensions/pmx-canvas && cp node_modules/pmx-canvas/.github/extensions/pmx-canvas/extension.mjs .github/extensions/pmx-canvas/extension.mjs`
18
+ 2. Reload Copilot app extensions with `extensions_reload` so `project:pmx-canvas` is registered.
19
+ 3. Start or confirm a PMX Canvas daemon for the workspace: `pmx-canvas serve --daemon`
20
+ and `pmx-canvas serve status`. The adapter can auto-start in many local sessions, but a running
21
+ daemon is the most reliable setup for fresh agents.
22
+ 4. Open the canvas with `extensionId: "project:pmx-canvas"`, `canvasId: "pmx-canvas"`, and a stable
23
+ `instanceId`.
24
+ 5. If the first `invoke_canvas_action` immediately after `open_canvas` returns
25
+ `Canvas instance not open`, retry the same action once. This is a known Copilot app timing race
26
+ during panel initialization, not a PMX server failure.
27
+
14
28
  Open it with:
15
29
 
16
30
  ```json
@@ -14,6 +14,18 @@ function withViewerParams(url: string, expanded: boolean): string {
14
14
  }
15
15
  }
16
16
 
17
+ export function isSameOriginFrameDocumentUrl(url: string, origin = window.location.origin): boolean {
18
+ if (!url) return false;
19
+ try {
20
+ const baseOrigin = new URL(origin).origin;
21
+ const resolved = new URL(url, baseOrigin);
22
+ return resolved.origin === baseOrigin &&
23
+ resolved.pathname.startsWith('/api/canvas/frame-documents/');
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
17
29
  export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
18
30
  if (node.data.mode === 'ext-app') {
19
31
  return <ExtAppFrame node={node} expanded={expanded} />;
@@ -23,7 +35,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
23
35
  const sourceServer = (node.data.sourceServer as string) || '';
24
36
  const hostMode = (node.data.hostMode as string) || 'hosted';
25
37
  const fallbackReason = node.data.fallbackReason as string | undefined;
26
- const trustedDomain = node.data.trustedDomain as boolean | undefined;
38
+ const trustedDomain = node.data.trustedDomain === true || isSameOriginFrameDocumentUrl(url);
27
39
 
28
40
  if (hostMode === 'fallback') {
29
41
  return (
@@ -75,7 +75,7 @@ const DEFAULT_POSITIONS: Record<
75
75
  status: { x: 40, y: 80, w: 300, h: 120 },
76
76
  markdown: { x: 380, y: 80, w: 720, h: 600 },
77
77
  context: { x: 1130, y: 80, w: 320, h: 400 },
78
- 'mcp-app': { x: 380, y: 720, w: 720, h: 500 },
78
+ 'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
79
79
  webpage: { x: 380, y: 80, w: 520, h: 420 },
80
80
  'json-render': { x: 380, y: 720, w: 840, h: 620 },
81
81
  graph: { x: 380, y: 720, w: 760, h: 520 },
@@ -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;
@@ -1176,7 +1177,7 @@ function shiftGridUpdatesBelowObstacles(
1176
1177
  }
1177
1178
 
1178
1179
  export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: number; layout: CanvasArrangeMode } {
1179
- const nodes = canvasState.getLayout().nodes;
1180
+ const nodes = canvasState.getLayoutForPersistence().nodes;
1180
1181
  const excludedIds = layout === 'grid'
1181
1182
  ? collectGridArrangeExcludedNodeIds(nodes)
1182
1183
  : collectArrangeExcludedNodeIds(nodes);
@@ -1657,8 +1658,20 @@ export async function executeCanvasBatch(
1657
1658
  ...(typeof args.width === 'number' ? { width: args.width } : {}),
1658
1659
  ...(typeof args.height === 'number' ? { height: args.height } : {}),
1659
1660
  ...(args.strictSize === true ? { strictSize: true } : {}),
1660
- defaultWidth: type === 'html' ? 720 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
1661
- 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,
1662
1675
  fileMode: 'auto',
1663
1676
  });
1664
1677
  result = { ok: true, ...serializeCreatedNode(created.node) };
@@ -400,6 +400,14 @@ class CanvasStateManager {
400
400
  return normalized;
401
401
  }
402
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
+
403
411
  private reflowAllGroups(): void {
404
412
  const groups = Array.from(this.nodes.values())
405
413
  .filter((node): node is CanvasNodeState => node.type === 'group')
@@ -1379,7 +1387,7 @@ class CanvasStateManager {
1379
1387
 
1380
1388
  getNode(id: string): CanvasNodeState | undefined {
1381
1389
  const node = this.nodes.get(id);
1382
- return node ? structuredClone(this.resolveNodeDataBlobs(node)) : undefined;
1390
+ return node ? structuredClone(this.nodeForRead(node)) : undefined;
1383
1391
  }
1384
1392
 
1385
1393
  getNodeForPersistence(id: string): CanvasNodeState | undefined {
@@ -1482,7 +1490,7 @@ class CanvasStateManager {
1482
1490
  return {
1483
1491
  viewport: structuredClone(this._viewport),
1484
1492
  theme: this._theme,
1485
- nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1493
+ nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
1486
1494
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1487
1495
  annotations: this.getAnnotations(),
1488
1496
  };
@@ -13,6 +13,7 @@ import {
13
13
  addCanvasNode,
14
14
  addCanvasEdge,
15
15
  MARKDOWN_NODE_DEFAULT_SIZE,
16
+ MCP_APP_NODE_DEFAULT_SIZE,
16
17
  applyCanvasNodeUpdates,
17
18
  arrangeCanvasNodes,
18
19
  clearCanvas,
@@ -183,8 +184,16 @@ export class PmxCanvas extends EventEmitter {
183
184
  }
184
185
  const { id, needsCodeGraphRecompute } = addCanvasNode({
185
186
  ...input,
186
- defaultWidth: input.type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
187
- 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,
188
197
  fileMode: 'path',
189
198
  ...(input.strictSize ? { strictSize: true } : {}),
190
199
  });
@@ -66,6 +66,13 @@ export interface SnapshotDiffResult {
66
66
  removedEdges: { id: string; from: string; to: string; type: string }[];
67
67
  }
68
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
+
69
76
  // ── Ring Buffer ──────────────────────────────────────────────────────
70
77
 
71
78
  const MAX_ENTRIES = 200;
@@ -258,6 +265,10 @@ export function diffLayouts(
258
265
  changes.push(`content changed (${lenDiff >= 0 ? '+' : ''}${lenDiff} chars)`);
259
266
  }
260
267
 
268
+ if (JSON.stringify(comparableNodeData(snapNode.data)) !== JSON.stringify(comparableNodeData(curNode.data))) {
269
+ changes.push('data changed');
270
+ }
271
+
261
272
  if (changes.length > 0) {
262
273
  modifiedNodes.push({
263
274
  id,
@@ -47,7 +47,7 @@ import type {
47
47
  ListResourceTemplatesResult,
48
48
  ListToolsResult,
49
49
  } from '@modelcontextprotocol/sdk/types.js';
50
- 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';
51
51
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
52
52
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
53
53
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
@@ -84,6 +84,7 @@ import {
84
84
  addCanvasNode,
85
85
  addCanvasEdge,
86
86
  MARKDOWN_NODE_DEFAULT_SIZE,
87
+ MCP_APP_NODE_DEFAULT_SIZE,
87
88
  applyCanvasNodeUpdates,
88
89
  buildStructuredNodeUpdate,
89
90
  arrangeCanvasNodes,
@@ -790,6 +791,64 @@ function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> |
790
791
  return value as Array<Record<string, unknown>>;
791
792
  }
792
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
+
793
852
  function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
794
853
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
795
854
  const storedCheckpointId = appCheckpoint?.id;
@@ -1467,6 +1526,20 @@ function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1467
1526
  };
1468
1527
  }
1469
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
+
1470
1543
  async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<Response> {
1471
1544
  const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
1472
1545
  ? body.url
@@ -1547,6 +1620,23 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1547
1620
  return createCanvasHtmlPrimitiveNode(body);
1548
1621
  }
1549
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
+
1550
1640
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1551
1641
  ? body.data as Record<string, unknown>
1552
1642
  : undefined;
@@ -1592,8 +1682,20 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1592
1682
  ...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
1593
1683
  ...(body.strictSize === true ? { strictSize: true } : {}),
1594
1684
  ...geometry,
1595
- defaultWidth: type === 'html' ? 720 : type === 'markdown' ? MARKDOWN_NODE_DEFAULT_SIZE.width : 360,
1596
- 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,
1597
1699
  fileMode: 'auto',
1598
1700
  });
1599
1701
  } catch (error) {
@@ -1797,6 +1899,13 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1797
1899
  if (existing.type === 'webpage' && body.refresh === true) {
1798
1900
  return handleCanvasRefreshWebpageNode(nodeId, req);
1799
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
+ }
1800
1909
  const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
1801
1910
  if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
1802
1911
  if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
@@ -1855,6 +1964,9 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1855
1964
  });
1856
1965
  if (error) return responseJson({ ok: false, error }, 400);
1857
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
+ }
1858
1970
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1859
1971
  const updated = canvasState.getNode(nodeId);
1860
1972
  return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
@@ -4346,7 +4458,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4346
4458
  const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
4347
4459
  return responseJson(includeBlobs
4348
4460
  ? serializeCanvasLayout(canvasState.getLayout())
4349
- : serializeCanvasLayoutWithBlobSummaries(canvasState.getLayoutForPersistence()));
4461
+ : serializeCanvasLayoutWithBlobSummaries(withContextPinLayoutReadState(canvasState.getLayoutForPersistence())));
4350
4462
  }
4351
4463
 
4352
4464
  if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
@@ -4416,9 +4528,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4416
4528
  const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
4417
4529
  const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
4418
4530
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
4531
+ const responseNode = withContextPinReadState(node);
4419
4532
  return responseJson(includeBlobs
4420
- ? serializeCanvasNode(node)
4421
- : serializeCanvasNodeWithBlobSummaries(node));
4533
+ ? serializeCanvasNode(responseNode)
4534
+ : serializeCanvasNodeWithBlobSummaries(responseNode));
4422
4535
  }
4423
4536
 
4424
4537
  if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {