pmx-canvas 0.1.0

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 (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,1134 @@
1
+ /**
2
+ * Server-side canvas state manager.
3
+ *
4
+ * Maintains the authoritative node layout so that:
5
+ * - Agent tools (Phase 3) can read/mutate canvas state
6
+ * - Client syncs bidirectionally (SSE for server→client, POST for client→server)
7
+ *
8
+ * Persistence: canvas state auto-saves to `.pmx-canvas/state.json` in the
9
+ * workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { normalizeCanvasNodeData } from './canvas-provenance.js';
15
+ import {
16
+ type CanvasPlacementRect,
17
+ computeGroupBounds,
18
+ computePackedGroupLayout,
19
+ GROUP_PAD,
20
+ GROUP_TITLEBAR_HEIGHT,
21
+ resolveGroupCollision,
22
+ } from './placement.js';
23
+
24
+ function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
25
+ console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
26
+ }
27
+
28
+ export const PMX_CANVAS_DIR = '.pmx-canvas';
29
+ const STATE_FILENAME = 'state.json';
30
+ const SNAPSHOTS_SUBDIR = 'snapshots';
31
+ const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
32
+ const LEGACY_SNAPSHOTS_DIR = '.pmx-canvas-snapshots';
33
+ const SAVE_DEBOUNCE_MS = 500;
34
+
35
+ interface PersistedCanvasState {
36
+ version: number;
37
+ viewport: ViewportState;
38
+ nodes: CanvasNodeState[];
39
+ edges: CanvasEdge[];
40
+ contextPins: string[];
41
+ }
42
+
43
+ interface LoadFromDiskOptions {
44
+ clearExisting?: boolean;
45
+ }
46
+
47
+ export const IMAGE_MIME_MAP: Record<string, string> = {
48
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
49
+ gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp',
50
+ bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif',
51
+ };
52
+
53
+ export interface CanvasSnapshot {
54
+ id: string;
55
+ name: string;
56
+ createdAt: string;
57
+ nodeCount: number;
58
+ edgeCount: number;
59
+ }
60
+
61
+ export interface CanvasNodeState {
62
+ id: string;
63
+ type:
64
+ | 'markdown'
65
+ | 'mcp-app'
66
+ | 'webpage'
67
+ | 'json-render'
68
+ | 'graph'
69
+ | 'prompt'
70
+ | 'response'
71
+ | 'status'
72
+ | 'context'
73
+ | 'ledger'
74
+ | 'trace'
75
+ | 'file'
76
+ | 'image'
77
+ | 'group';
78
+ position: { x: number; y: number };
79
+ size: { width: number; height: number };
80
+ zIndex: number;
81
+ collapsed: boolean;
82
+ pinned: boolean;
83
+ dockPosition: 'left' | 'right' | null;
84
+ data: Record<string, unknown>;
85
+ }
86
+
87
+ export interface ViewportState {
88
+ x: number;
89
+ y: number;
90
+ scale: number;
91
+ }
92
+
93
+ export interface CanvasEdge {
94
+ id: string;
95
+ from: string;
96
+ to: string;
97
+ type: 'relation' | 'depends-on' | 'flow' | 'references';
98
+ label?: string;
99
+ style?: 'solid' | 'dashed' | 'dotted';
100
+ animated?: boolean;
101
+ }
102
+
103
+ export interface CanvasLayout {
104
+ viewport: ViewportState;
105
+ nodes: CanvasNodeState[];
106
+ edges: CanvasEdge[];
107
+ }
108
+
109
+ export interface CanvasNodeUpdate {
110
+ id: string;
111
+ position?: { x: number; y: number };
112
+ size?: { width: number; height: number };
113
+ collapsed?: boolean;
114
+ dockPosition?: 'left' | 'right' | null;
115
+ }
116
+
117
+ export type CanvasChangeType = 'pins' | 'nodes';
118
+
119
+ export interface MutationRecordInfo {
120
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
121
+ description: string;
122
+ forward: () => void;
123
+ inverse: () => void;
124
+ }
125
+
126
+ interface GroupNodesOptions {
127
+ preservePositions?: boolean;
128
+ layout?: 'grid' | 'column' | 'flow';
129
+ keepGroupFrame?: boolean;
130
+ }
131
+
132
+ function formatBatchUpdateDescription(updates: CanvasNodeUpdate[]): string {
133
+ let moved = 0;
134
+ let resized = 0;
135
+ let collapsed = 0;
136
+ let docked = 0;
137
+
138
+ for (const update of updates) {
139
+ if (update.position) moved++;
140
+ if (update.size) resized++;
141
+ if (update.collapsed !== undefined) collapsed++;
142
+ if (update.dockPosition !== undefined) docked++;
143
+ }
144
+
145
+ const parts: string[] = [];
146
+ if (moved > 0) parts.push(`${moved} moved`);
147
+ if (resized > 0) parts.push(`${resized} resized`);
148
+ if (collapsed > 0) parts.push(`${collapsed} collapsed`);
149
+ if (docked > 0) parts.push(`${docked} docked`);
150
+
151
+ const prefix = `Updated ${updates.length} node${updates.length === 1 ? '' : 's'}`;
152
+ return parts.length > 0 ? `${prefix} (${parts.join(', ')})` : prefix;
153
+ }
154
+
155
+ class CanvasStateManager {
156
+ private nodes = new Map<string, CanvasNodeState>();
157
+ private edges = new Map<string, CanvasEdge>();
158
+ private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
159
+ private _contextPinnedNodeIds = new Set<string>();
160
+ private _workspaceRoot = process.cwd();
161
+
162
+ // ── Change listeners (for MCP resource notifications) ──────
163
+ private _changeListeners: ((type: CanvasChangeType) => void)[] = [];
164
+
165
+ /** Register a listener for state changes. Used by MCP server to emit resource notifications. */
166
+ onChange(cb: (type: CanvasChangeType) => void): void {
167
+ this._changeListeners.push(cb);
168
+ }
169
+
170
+ private notifyChange(type: CanvasChangeType): void {
171
+ for (const cb of this._changeListeners) {
172
+ try {
173
+ cb(type);
174
+ } catch (error) {
175
+ logCanvasStateWarning('change-listener failed', error, { type });
176
+ }
177
+ }
178
+ }
179
+
180
+ // ── Mutation recorder (for undo/redo history) ─────────────
181
+ private _mutationRecorder: ((info: MutationRecordInfo) => void) | null = null;
182
+ private _suppressRecording = false;
183
+
184
+ /** Register a mutation recorder. Used by mutation-history to capture undo/redo closures. */
185
+ onMutation(cb: (info: MutationRecordInfo) => void): void {
186
+ this._mutationRecorder = cb;
187
+ }
188
+
189
+ /** Run a function with mutation recording suppressed (for undo/redo replay and computed edges). */
190
+ withSuppressedRecording(fn: () => void): void {
191
+ this._suppressRecording = true;
192
+ try { fn(); } finally { this._suppressRecording = false; }
193
+ }
194
+
195
+ /** Create a closure that runs with recording suppressed. */
196
+ private suppressed(fn: () => void): () => void {
197
+ return () => this.withSuppressedRecording(fn);
198
+ }
199
+
200
+ private recordMutation(info: MutationRecordInfo): void {
201
+ if (this._suppressRecording || !this._mutationRecorder) return;
202
+ try {
203
+ this._mutationRecorder(info);
204
+ } catch (error) {
205
+ logCanvasStateWarning('mutation-recorder failed', error, { description: info.description });
206
+ }
207
+ }
208
+
209
+ private applyResolvedGroupBounds(
210
+ group: CanvasNodeState,
211
+ groupId: string,
212
+ childIds: string[],
213
+ bounds: { x: number; y: number; width: number; height: number },
214
+ existingGroups?: CanvasPlacementRect[],
215
+ ): void {
216
+ const otherGroups = existingGroups ?? Array.from(this.nodes.values()).filter(
217
+ (node) => node.id !== groupId && node.type === 'group',
218
+ );
219
+ const resolved = resolveGroupCollision(bounds, otherGroups);
220
+ const deltaX = resolved.x - bounds.x;
221
+ const deltaY = resolved.y - bounds.y;
222
+
223
+ if (deltaX !== 0 || deltaY !== 0) {
224
+ for (const childId of childIds) {
225
+ const child = this.nodes.get(childId);
226
+ if (!child || child.type === 'group') continue;
227
+ this.nodes.set(childId, {
228
+ ...child,
229
+ position: {
230
+ x: child.position.x + deltaX,
231
+ y: child.position.y + deltaY,
232
+ },
233
+ });
234
+ }
235
+ }
236
+
237
+ this.nodes.set(groupId, {
238
+ ...group,
239
+ position: { x: resolved.x, y: resolved.y },
240
+ size: { width: bounds.width, height: bounds.height },
241
+ });
242
+ }
243
+
244
+ private getGroupSnapshot(groupId: string): {
245
+ group: CanvasNodeState;
246
+ childIds: string[];
247
+ children: CanvasNodeState[];
248
+ } | null {
249
+ const group = this.nodes.get(groupId);
250
+ if (!group || group.type !== 'group') return null;
251
+
252
+ const childIds = (group.data.children as string[]) ?? [];
253
+ const children = childIds
254
+ .map((id) => this.nodes.get(id))
255
+ .filter((node): node is CanvasNodeState => node !== undefined && node.type !== 'group');
256
+
257
+ return { group, childIds, children };
258
+ }
259
+
260
+ private normalizeNode(node: CanvasNodeState): CanvasNodeState {
261
+ return {
262
+ ...node,
263
+ data: normalizeCanvasNodeData(node.type, node.data),
264
+ };
265
+ }
266
+
267
+ private reflowAllGroups(): void {
268
+ const groups = Array.from(this.nodes.values())
269
+ .filter((node): node is CanvasNodeState => node.type === 'group')
270
+ .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
271
+
272
+ for (const group of groups) {
273
+ const snapshot = this.getGroupSnapshot(group.id);
274
+ if (!snapshot) continue;
275
+ if (snapshot.group.data.frameMode === 'manual') {
276
+ continue;
277
+ }
278
+ const bounds = computeGroupBounds(snapshot.children);
279
+ if (!bounds) continue;
280
+ this.nodes.set(group.id, {
281
+ ...snapshot.group,
282
+ position: { x: bounds.x, y: bounds.y },
283
+ size: { width: bounds.width, height: bounds.height },
284
+ });
285
+ }
286
+ }
287
+
288
+ private translateGroupChildren(groupId: string, deltaX: number, deltaY: number): void {
289
+ if (deltaX === 0 && deltaY === 0) return;
290
+ const snapshot = this.getGroupSnapshot(groupId);
291
+ if (!snapshot) return;
292
+
293
+ for (const child of snapshot.children) {
294
+ this.nodes.set(child.id, {
295
+ ...child,
296
+ position: {
297
+ x: child.position.x + deltaX,
298
+ y: child.position.y + deltaY,
299
+ },
300
+ });
301
+ }
302
+ }
303
+
304
+ private recomputeParentGroupBounds(groupId: string | undefined): void {
305
+ if (!groupId) return;
306
+ const snapshot = this.getGroupSnapshot(groupId);
307
+ if (!snapshot) return;
308
+ if (snapshot.group.data.frameMode === 'manual') return;
309
+
310
+ const bounds = computeGroupBounds(snapshot.children);
311
+ if (!bounds) return;
312
+
313
+ this.nodes.set(groupId, {
314
+ ...snapshot.group,
315
+ position: { x: bounds.x, y: bounds.y },
316
+ size: { width: bounds.width, height: bounds.height },
317
+ });
318
+ }
319
+
320
+ private compactGroupChildren(groupId: string, layout: 'grid' | 'column' | 'flow' = 'grid'): void {
321
+ const snapshot = this.getGroupSnapshot(groupId);
322
+ if (!snapshot || snapshot.children.length === 0) return;
323
+ if (snapshot.group.data.frameMode === 'manual') {
324
+ const sorted = [...snapshot.children].sort(
325
+ (a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
326
+ );
327
+ const left = snapshot.group.position.x + GROUP_PAD;
328
+ const top = snapshot.group.position.y + GROUP_TITLEBAR_HEIGHT + GROUP_PAD;
329
+ const right = snapshot.group.position.x + snapshot.group.size.width - GROUP_PAD;
330
+ const gap = 24;
331
+ let cursorX = left;
332
+ let cursorY = top;
333
+ let rowHeight = 0;
334
+
335
+ for (const child of sorted) {
336
+ if (layout === 'column') {
337
+ this.nodes.set(child.id, { ...child, position: { x: left, y: cursorY } });
338
+ cursorY += child.size.height + gap;
339
+ continue;
340
+ }
341
+
342
+ if (layout === 'flow') {
343
+ this.nodes.set(child.id, { ...child, position: { x: cursorX, y: top } });
344
+ cursorX += child.size.width + gap;
345
+ continue;
346
+ }
347
+
348
+ if (cursorX > left && cursorX + child.size.width > right) {
349
+ cursorX = left;
350
+ cursorY += rowHeight + gap;
351
+ rowHeight = 0;
352
+ }
353
+
354
+ this.nodes.set(child.id, { ...child, position: { x: cursorX, y: cursorY } });
355
+ cursorX += child.size.width + gap;
356
+ rowHeight = Math.max(rowHeight, child.size.height);
357
+ }
358
+ return;
359
+ }
360
+
361
+ const { positions, bounds } = computePackedGroupLayout(
362
+ snapshot.children.map((child) => ({
363
+ id: child.id,
364
+ position: child.position,
365
+ size: child.size,
366
+ })),
367
+ );
368
+
369
+ for (const child of snapshot.children) {
370
+ const position = positions.get(child.id);
371
+ if (!position) continue;
372
+ this.nodes.set(child.id, { ...child, position });
373
+ }
374
+
375
+ const updatedGroup = this.nodes.get(groupId);
376
+ if (bounds && updatedGroup?.type === 'group') {
377
+ this.applyResolvedGroupBounds(updatedGroup, groupId, snapshot.childIds, bounds);
378
+ }
379
+ }
380
+
381
+ // ── Persistence ────────────────────────────────────────────
382
+ private _stateFilePath: string | null = null;
383
+ private _saveTimer: ReturnType<typeof setTimeout> | null = null;
384
+
385
+ /** Set the workspace root to enable auto-persistence. */
386
+ setWorkspaceRoot(workspaceRoot: string): void {
387
+ this._workspaceRoot = workspaceRoot;
388
+ this.migrateLegacyLayout(workspaceRoot);
389
+ const override = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
390
+ this._stateFilePath = override || join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
391
+ }
392
+
393
+ /**
394
+ * One-time migration: rename files from the pre-consolidation layout
395
+ * (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
396
+ * No-op when the new layout already exists.
397
+ */
398
+ private migrateLegacyLayout(workspaceRoot: string): void {
399
+ const newDir = join(workspaceRoot, PMX_CANVAS_DIR);
400
+ const legacyState = join(workspaceRoot, LEGACY_STATE_FILENAME);
401
+ const newState = join(newDir, STATE_FILENAME);
402
+ const legacySnapshots = join(workspaceRoot, LEGACY_SNAPSHOTS_DIR);
403
+ const newSnapshots = join(newDir, SNAPSHOTS_SUBDIR);
404
+
405
+ try {
406
+ if (existsSync(legacyState) && !existsSync(newState)) {
407
+ mkdirSync(newDir, { recursive: true });
408
+ renameSync(legacyState, newState);
409
+ }
410
+ if (existsSync(legacySnapshots) && !existsSync(newSnapshots)) {
411
+ mkdirSync(newDir, { recursive: true });
412
+ renameSync(legacySnapshots, newSnapshots);
413
+ }
414
+ } catch (error) {
415
+ logCanvasStateWarning('legacy layout migration failed', error, {
416
+ workspaceRoot,
417
+ });
418
+ }
419
+ }
420
+
421
+ getWorkspaceRoot(): string {
422
+ return this._workspaceRoot;
423
+ }
424
+
425
+ private emptyPersistedState(): PersistedCanvasState {
426
+ return {
427
+ version: 1,
428
+ viewport: { x: 0, y: 0, scale: 1 },
429
+ nodes: [],
430
+ edges: [],
431
+ contextPins: [],
432
+ };
433
+ }
434
+
435
+ /** Load canvas state from disk. Call once on server startup. */
436
+ loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
437
+ if (!this._stateFilePath || !existsSync(this._stateFilePath)) {
438
+ if (options.clearExisting) {
439
+ this.applyPersistedState(this.emptyPersistedState());
440
+ }
441
+ return false;
442
+ }
443
+ try {
444
+ const raw = readFileSync(this._stateFilePath, 'utf-8');
445
+ const parsed = JSON.parse(raw) as PersistedCanvasState;
446
+ if (!parsed || parsed.version !== 1) return false;
447
+ this.applyPersistedState(parsed);
448
+ return true;
449
+ } catch (error) {
450
+ logCanvasStateWarning('load state from disk failed', error, {
451
+ path: this._stateFilePath ?? undefined,
452
+ });
453
+ return false;
454
+ }
455
+ }
456
+
457
+ /** Debounced save — coalesces rapid mutations into a single disk write. */
458
+ private scheduleSave(): void {
459
+ if (!this._stateFilePath) return;
460
+ if (this._saveTimer) clearTimeout(this._saveTimer);
461
+ this._saveTimer = setTimeout(() => {
462
+ this._saveTimer = null;
463
+ this.saveToDisk();
464
+ }, SAVE_DEBOUNCE_MS);
465
+ }
466
+
467
+ flushToDisk(): void {
468
+ if (this._saveTimer) {
469
+ clearTimeout(this._saveTimer);
470
+ this._saveTimer = null;
471
+ }
472
+ this.saveToDisk();
473
+ }
474
+
475
+ /** Write current state to disk immediately. */
476
+ private saveToDisk(): void {
477
+ if (!this._stateFilePath) return;
478
+ try {
479
+ const dir = dirname(this._stateFilePath);
480
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
481
+
482
+ const payload: PersistedCanvasState = {
483
+ version: 1,
484
+ viewport: this._viewport,
485
+ nodes: Array.from(this.nodes.values()),
486
+ edges: Array.from(this.edges.values()),
487
+ contextPins: Array.from(this._contextPinnedNodeIds),
488
+ };
489
+ writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
490
+ } catch (error) {
491
+ logCanvasStateWarning('save state to disk failed', error, {
492
+ path: this._stateFilePath ?? undefined,
493
+ });
494
+ }
495
+ }
496
+
497
+ // ── Snapshots ───────────────────────────────────────────────
498
+
499
+ private get snapshotsDir(): string | null {
500
+ if (!this._workspaceRoot) return null;
501
+ return join(this._workspaceRoot, PMX_CANVAS_DIR, SNAPSHOTS_SUBDIR);
502
+ }
503
+
504
+ private applyPersistedState(state: PersistedCanvasState): void {
505
+ this.nodes.clear();
506
+ this.edges.clear();
507
+ this._contextPinnedNodeIds.clear();
508
+
509
+ this._viewport = {
510
+ x: state.viewport?.x ?? 0,
511
+ y: state.viewport?.y ?? 0,
512
+ scale: state.viewport?.scale ?? 1,
513
+ };
514
+
515
+ if (Array.isArray(state.nodes)) {
516
+ for (const node of state.nodes) {
517
+ if (node?.id) {
518
+ this.nodes.set(node.id, structuredClone(this.normalizeNode(node)));
519
+ }
520
+ }
521
+ }
522
+ if (Array.isArray(state.edges)) {
523
+ for (const edge of state.edges) {
524
+ if (edge?.id) this.edges.set(edge.id, structuredClone(edge));
525
+ }
526
+ }
527
+ if (Array.isArray(state.contextPins)) {
528
+ for (const pinId of state.contextPins) {
529
+ if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
530
+ }
531
+ }
532
+ }
533
+
534
+ private readResolvedSnapshot(idOrName: string): {
535
+ snapshot: CanvasSnapshot;
536
+ state: PersistedCanvasState;
537
+ } | null {
538
+ const dir = this.snapshotsDir;
539
+ if (!dir || !existsSync(dir)) return null;
540
+
541
+ const directPath = join(dir, `${idOrName}.json`);
542
+ if (existsSync(directPath)) {
543
+ try {
544
+ const raw = readFileSync(directPath, 'utf-8');
545
+ const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
546
+ if (parsed.snapshot) {
547
+ return { snapshot: parsed.snapshot, state: parsed };
548
+ }
549
+ } catch (error) {
550
+ logCanvasStateWarning('read snapshot by id failed', error, { idOrName, directPath });
551
+ }
552
+ }
553
+
554
+ try {
555
+ const matches: Array<{ snapshot: CanvasSnapshot; state: PersistedCanvasState }> = [];
556
+ const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
557
+ for (const file of files) {
558
+ try {
559
+ const raw = readFileSync(join(dir, file), 'utf-8');
560
+ const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
561
+ if (!parsed.snapshot) continue;
562
+ if (parsed.snapshot.name === idOrName || parsed.snapshot.id === idOrName) {
563
+ matches.push({ snapshot: parsed.snapshot, state: parsed });
564
+ }
565
+ } catch (error) {
566
+ logCanvasStateWarning('skip unreadable snapshot while searching by name', error, {
567
+ idOrName,
568
+ file,
569
+ });
570
+ }
571
+ }
572
+ matches.sort((a, b) => b.snapshot.createdAt.localeCompare(a.snapshot.createdAt));
573
+ return matches[0] ?? null;
574
+ } catch (error) {
575
+ logCanvasStateWarning('search snapshots by name failed', error, { idOrName, dir });
576
+ return null;
577
+ }
578
+ }
579
+
580
+ /** Save current canvas state as a named snapshot. */
581
+ saveSnapshot(name: string): CanvasSnapshot | null {
582
+ const dir = this.snapshotsDir;
583
+ if (!dir) return null;
584
+
585
+ const id = `snap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
586
+ const snapshot: CanvasSnapshot = {
587
+ id,
588
+ name,
589
+ createdAt: new Date().toISOString(),
590
+ nodeCount: this.nodes.size,
591
+ edgeCount: this.edges.size,
592
+ };
593
+
594
+ try {
595
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
596
+
597
+ const payload: PersistedCanvasState & { snapshot: CanvasSnapshot } = {
598
+ version: 1,
599
+ snapshot,
600
+ viewport: this._viewport,
601
+ nodes: Array.from(this.nodes.values()),
602
+ edges: Array.from(this.edges.values()),
603
+ contextPins: Array.from(this._contextPinnedNodeIds),
604
+ };
605
+ writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
606
+ return snapshot;
607
+ } catch (error) {
608
+ logCanvasStateWarning('save snapshot failed', error, { id, name });
609
+ return null;
610
+ }
611
+ }
612
+
613
+ /** List all saved snapshots. */
614
+ listSnapshots(): CanvasSnapshot[] {
615
+ const dir = this.snapshotsDir;
616
+ if (!dir || !existsSync(dir)) return [];
617
+
618
+ try {
619
+ const files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
620
+ const snapshots: CanvasSnapshot[] = [];
621
+ for (const file of files) {
622
+ try {
623
+ const raw = readFileSync(join(dir, file), 'utf-8');
624
+ const parsed = JSON.parse(raw) as { snapshot?: CanvasSnapshot };
625
+ if (parsed.snapshot) snapshots.push(parsed.snapshot);
626
+ } catch (error) {
627
+ logCanvasStateWarning('skip corrupt snapshot file', error, { file });
628
+ }
629
+ }
630
+ return snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
631
+ } catch (error) {
632
+ logCanvasStateWarning('list snapshots failed', error, { dir });
633
+ return [];
634
+ }
635
+ }
636
+
637
+ /** Restore canvas state from a snapshot. */
638
+ restoreSnapshot(idOrName: string): boolean {
639
+ const resolved = this.readResolvedSnapshot(idOrName);
640
+ if (!resolved || resolved.state.version !== 1) return false;
641
+
642
+ const previousState: PersistedCanvasState = {
643
+ version: 1,
644
+ viewport: structuredClone(this._viewport),
645
+ nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
646
+ edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
647
+ contextPins: Array.from(this._contextPinnedNodeIds),
648
+ };
649
+ const nextState: PersistedCanvasState = {
650
+ version: 1,
651
+ viewport: structuredClone(resolved.state.viewport),
652
+ nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
653
+ edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
654
+ contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
655
+ };
656
+
657
+ try {
658
+ this.applyPersistedState(nextState);
659
+ this.scheduleSave();
660
+ this.notifyChange('nodes');
661
+ this.notifyChange('pins');
662
+ this.recordMutation({
663
+ operationType: 'restoreSnapshot',
664
+ description: `Restored snapshot "${resolved.snapshot.name}"`,
665
+ forward: this.suppressed(() => {
666
+ this.applyPersistedState(nextState);
667
+ this.scheduleSave();
668
+ this.notifyChange('nodes');
669
+ this.notifyChange('pins');
670
+ }),
671
+ inverse: this.suppressed(() => {
672
+ this.applyPersistedState(previousState);
673
+ this.scheduleSave();
674
+ this.notifyChange('nodes');
675
+ this.notifyChange('pins');
676
+ }),
677
+ });
678
+ return true;
679
+ } catch (error) {
680
+ logCanvasStateWarning('restore snapshot failed', error, {
681
+ idOrName,
682
+ snapshotId: resolved.snapshot.id,
683
+ snapshotName: resolved.snapshot.name,
684
+ });
685
+ return false;
686
+ }
687
+ }
688
+
689
+ /** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
690
+ getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[] } | null {
691
+ const resolved = this.readResolvedSnapshot(idOrName);
692
+ if (!resolved) return null;
693
+ return {
694
+ name: resolved.snapshot.name,
695
+ nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
696
+ edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
697
+ };
698
+ }
699
+
700
+ /** Delete a snapshot. */
701
+ deleteSnapshot(id: string): boolean {
702
+ const dir = this.snapshotsDir;
703
+ if (!dir) return false;
704
+ const filePath = join(dir, `${id}.json`);
705
+ if (!existsSync(filePath)) return false;
706
+ try {
707
+ unlinkSync(filePath);
708
+ return true;
709
+ } catch (error) {
710
+ logCanvasStateWarning('delete snapshot failed', error, { id, filePath });
711
+ return false;
712
+ }
713
+ }
714
+
715
+ // ── Node CRUD ──────────────────────────────────────────────
716
+
717
+ get viewport(): ViewportState {
718
+ return structuredClone(this._viewport);
719
+ }
720
+
721
+ addNode(node: CanvasNodeState): void {
722
+ const cloned = structuredClone(this.normalizeNode(node));
723
+ this.nodes.set(node.id, cloned);
724
+ this.scheduleSave();
725
+ this.notifyChange('nodes');
726
+ this.recordMutation({
727
+ operationType: 'addNode',
728
+ description: `Added ${node.type} node "${(node.data.title as string) ?? node.id}"`,
729
+ forward: this.suppressed(() => this.addNode(structuredClone(cloned))),
730
+ inverse: this.suppressed(() => this.removeNode(node.id)),
731
+ });
732
+ }
733
+
734
+ addJsonRenderNode(node: CanvasNodeState): void {
735
+ this.addNode(node);
736
+ }
737
+
738
+ addGraphNode(node: CanvasNodeState): void {
739
+ this.addNode(node);
740
+ }
741
+
742
+ updateNode(id: string, patch: Partial<CanvasNodeState>): void {
743
+ const existing = this.nodes.get(id);
744
+ if (!existing) return;
745
+ const oldSnapshot = structuredClone(existing);
746
+ if (existing.type === 'group' && patch.position) {
747
+ this.translateGroupChildren(
748
+ id,
749
+ patch.position.x - existing.position.x,
750
+ patch.position.y - existing.position.y,
751
+ );
752
+ }
753
+ const nextNode = this.normalizeNode({ ...existing, ...patch });
754
+ this.nodes.set(id, nextNode);
755
+ const parentGroupId = existing.data.parentGroup as string | undefined;
756
+ if (parentGroupId) {
757
+ if (patch.size) {
758
+ this.compactGroupChildren(parentGroupId);
759
+ } else {
760
+ this.recomputeParentGroupBounds(parentGroupId);
761
+ }
762
+ this.reflowAllGroups();
763
+ }
764
+ this.scheduleSave();
765
+ this.notifyChange('nodes');
766
+ this.recordMutation({
767
+ operationType: 'updateNode',
768
+ description: `Updated node "${(existing.data.title as string) ?? id}"`,
769
+ forward: this.suppressed(() => this.updateNode(id, structuredClone(patch))),
770
+ inverse: this.suppressed(() => { this.nodes.set(id, structuredClone(oldSnapshot)); this.scheduleSave(); this.notifyChange('nodes'); }),
771
+ });
772
+ }
773
+
774
+ removeNode(id: string): void {
775
+ const existing = this.nodes.get(id);
776
+ const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
777
+ const cloned = existing ? structuredClone(existing) : null;
778
+
779
+ // Prune from parent group's children list
780
+ if (existing) {
781
+ const parentGroupId = existing.data.parentGroup as string | undefined;
782
+ if (parentGroupId) {
783
+ const parent = this.nodes.get(parentGroupId);
784
+ if (parent && parent.type === 'group') {
785
+ const children = (parent.data.children as string[]) ?? [];
786
+ const pruned = children.filter((cid) => cid !== id);
787
+ this.nodes.set(parentGroupId, { ...parent, data: { ...parent.data, children: pruned } });
788
+ }
789
+ }
790
+ // If removing a group, clear parentGroup on all its children
791
+ if (existing.type === 'group') {
792
+ const childIds = (existing.data.children as string[]) ?? [];
793
+ for (const cid of childIds) {
794
+ const child = this.nodes.get(cid);
795
+ if (!child) continue;
796
+ const d = { ...child.data };
797
+ delete d.parentGroup;
798
+ this.nodes.set(cid, { ...child, data: d });
799
+ }
800
+ }
801
+ }
802
+
803
+ this.nodes.delete(id);
804
+ this.removeEdgesForNode(id);
805
+ this._contextPinnedNodeIds.delete(id);
806
+ this.scheduleSave();
807
+ this.notifyChange('nodes');
808
+ this.notifyChange('pins');
809
+ if (cloned) {
810
+ this.recordMutation({
811
+ operationType: 'removeNode',
812
+ description: `Removed ${cloned.type} node "${(cloned.data.title as string) ?? id}"`,
813
+ forward: this.suppressed(() => this.removeNode(id)),
814
+ inverse: this.suppressed(() => {
815
+ this.addNode(structuredClone(cloned));
816
+ for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
817
+ }),
818
+ });
819
+ }
820
+ }
821
+
822
+ getNode(id: string): CanvasNodeState | undefined {
823
+ const node = this.nodes.get(id);
824
+ return node ? structuredClone(node) : undefined;
825
+ }
826
+
827
+ // ── Edge CRUD ──────────────────────────────────────────────
828
+
829
+ addEdge(edge: CanvasEdge): boolean {
830
+ if (edge.from === edge.to) return false;
831
+ for (const existing of this.edges.values()) {
832
+ if (existing.from === edge.from && existing.to === edge.to && existing.type === edge.type) {
833
+ return false;
834
+ }
835
+ }
836
+ const cloned = structuredClone(edge);
837
+ this.edges.set(edge.id, edge);
838
+ this.scheduleSave();
839
+ this.notifyChange('nodes');
840
+ this.recordMutation({
841
+ operationType: 'addEdge',
842
+ description: `Added ${edge.type} edge ${edge.from} → ${edge.to}`,
843
+ forward: this.suppressed(() => this.addEdge(structuredClone(cloned))),
844
+ inverse: this.suppressed(() => this.removeEdge(edge.id)),
845
+ });
846
+ return true;
847
+ }
848
+
849
+ removeEdge(id: string): boolean {
850
+ const existing = this.edges.get(id);
851
+ const cloned = existing ? structuredClone(existing) : null;
852
+ const removed = this.edges.delete(id);
853
+ if (removed && cloned) {
854
+ this.scheduleSave();
855
+ this.notifyChange('nodes');
856
+ this.recordMutation({
857
+ operationType: 'removeEdge',
858
+ description: `Removed ${cloned.type} edge ${cloned.from} → ${cloned.to}`,
859
+ forward: this.suppressed(() => this.removeEdge(id)),
860
+ inverse: this.suppressed(() => this.addEdge(structuredClone(cloned))),
861
+ });
862
+ }
863
+ return removed;
864
+ }
865
+
866
+ getEdges(): CanvasEdge[] {
867
+ return Array.from(this.edges.values(), (edge) => structuredClone(edge));
868
+ }
869
+
870
+ getEdgesForNode(nodeId: string): CanvasEdge[] {
871
+ return Array.from(this.edges.values())
872
+ .filter((edge) => edge.from === nodeId || edge.to === nodeId)
873
+ .map((edge) => structuredClone(edge));
874
+ }
875
+
876
+ private removeEdgesForNode(nodeId: string): void {
877
+ for (const [id, edge] of this.edges) {
878
+ if (edge.from === nodeId || edge.to === nodeId) {
879
+ this.edges.delete(id);
880
+ }
881
+ }
882
+ }
883
+
884
+ getLayout(): CanvasLayout {
885
+ return {
886
+ viewport: structuredClone(this._viewport),
887
+ nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
888
+ edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
889
+ };
890
+ }
891
+
892
+ applyUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
893
+ let applied = 0;
894
+ let skipped = 0;
895
+ const touchedParentGroups = new Map<string, { compact: boolean }>();
896
+ const oldSnapshots = new Map<string, CanvasNodeState>();
897
+ const appliedUpdates: CanvasNodeUpdate[] = [];
898
+
899
+ for (const update of updates) {
900
+ const existing = this.nodes.get(update.id);
901
+ if (!existing) {
902
+ skipped++;
903
+ continue;
904
+ }
905
+ oldSnapshots.set(update.id, structuredClone(existing));
906
+ appliedUpdates.push(structuredClone(update));
907
+ if (existing.type === 'group' && update.position) {
908
+ this.translateGroupChildren(
909
+ update.id,
910
+ update.position.x - existing.position.x,
911
+ update.position.y - existing.position.y,
912
+ );
913
+ }
914
+ this.nodes.set(update.id, {
915
+ ...existing,
916
+ ...(update.position && { position: update.position }),
917
+ ...(update.size && { size: update.size }),
918
+ ...(update.collapsed !== undefined && { collapsed: update.collapsed }),
919
+ ...(update.dockPosition !== undefined && { dockPosition: update.dockPosition }),
920
+ });
921
+ const parentGroupId = existing.data.parentGroup as string | undefined;
922
+ if (parentGroupId) {
923
+ const entry = touchedParentGroups.get(parentGroupId) ?? { compact: false };
924
+ entry.compact = entry.compact || update.size !== undefined;
925
+ touchedParentGroups.set(parentGroupId, entry);
926
+ }
927
+ applied++;
928
+ }
929
+
930
+ for (const [groupId, entry] of touchedParentGroups) {
931
+ if (entry.compact) {
932
+ this.compactGroupChildren(groupId);
933
+ } else {
934
+ this.recomputeParentGroupBounds(groupId);
935
+ }
936
+ }
937
+ if (touchedParentGroups.size > 0) this.reflowAllGroups();
938
+
939
+ if (applied > 0) {
940
+ this.scheduleSave();
941
+ this.notifyChange('nodes');
942
+ const inverseSnapshots = Array.from(oldSnapshots.entries()).map(([id, node]) => ({ id, node }));
943
+ this.recordMutation({
944
+ operationType: 'batch',
945
+ description: formatBatchUpdateDescription(appliedUpdates),
946
+ forward: this.suppressed(() => {
947
+ this.applyUpdates(appliedUpdates.map((update) => structuredClone(update)));
948
+ }),
949
+ inverse: this.suppressed(() => {
950
+ for (const snapshot of inverseSnapshots) {
951
+ this.nodes.set(snapshot.id, structuredClone(snapshot.node));
952
+ }
953
+ this.reflowAllGroups();
954
+ this.scheduleSave();
955
+ this.notifyChange('nodes');
956
+ }),
957
+ });
958
+ }
959
+ return { applied, skipped };
960
+ }
961
+
962
+ setViewport(v: Partial<ViewportState>): void {
963
+ const oldViewport = { ...this._viewport };
964
+ this._viewport = { ...this._viewport, ...v };
965
+ this.scheduleSave();
966
+ this.notifyChange('nodes');
967
+ this.recordMutation({
968
+ operationType: 'viewport',
969
+ description: 'Updated viewport',
970
+ forward: this.suppressed(() => this.setViewport({ ...v })),
971
+ inverse: this.suppressed(() => {
972
+ this._viewport = oldViewport;
973
+ this.scheduleSave();
974
+ this.notifyChange('nodes');
975
+ }),
976
+ });
977
+ }
978
+
979
+ // ── Context pins ─────────────────────────────────────────────
980
+
981
+ get contextPinnedNodeIds(): Set<string> {
982
+ return new Set(this._contextPinnedNodeIds);
983
+ }
984
+
985
+ setContextPins(nodeIds: string[]): void {
986
+ const oldPins = Array.from(this._contextPinnedNodeIds);
987
+ this._contextPinnedNodeIds.clear();
988
+ for (const id of nodeIds) {
989
+ if (this.nodes.has(id)) {
990
+ this._contextPinnedNodeIds.add(id);
991
+ }
992
+ }
993
+ this.scheduleSave();
994
+ this.notifyChange('pins');
995
+ this.recordMutation({
996
+ operationType: 'setPins',
997
+ description: `Set context pins (${this._contextPinnedNodeIds.size} nodes)`,
998
+ forward: this.suppressed(() => this.setContextPins([...nodeIds])),
999
+ inverse: this.suppressed(() => this.setContextPins(oldPins)),
1000
+ });
1001
+ }
1002
+
1003
+ clearContextPins(): void {
1004
+ this._contextPinnedNodeIds.clear();
1005
+ this.scheduleSave();
1006
+ this.notifyChange('pins');
1007
+ }
1008
+
1009
+ /** Move child nodes into a group. Sets data.parentGroup on children and data.children on the group. */
1010
+ groupNodes(groupId: string, childIds: string[], options: GroupNodesOptions = {}): boolean {
1011
+ const group = this.nodes.get(groupId);
1012
+ if (!group || group.type !== 'group') return false;
1013
+
1014
+ const validIds: string[] = [];
1015
+ for (const id of childIds) {
1016
+ const child = this.nodes.get(id);
1017
+ if (child && id !== groupId) validIds.push(id);
1018
+ }
1019
+ if (validIds.length === 0) return false;
1020
+
1021
+ const oldChildren = ((group.data.children as string[]) ?? []).slice();
1022
+ const merged = [...new Set([...oldChildren, ...validIds])];
1023
+
1024
+ // Snapshot for undo
1025
+ const oldParents = new Map<string, string | undefined>();
1026
+ for (const id of validIds) {
1027
+ const child = this.nodes.get(id)!;
1028
+ oldParents.set(id, child.data.parentGroup as string | undefined);
1029
+ }
1030
+
1031
+ // Apply
1032
+ this.nodes.set(groupId, { ...group, data: { ...group.data, children: merged } });
1033
+ for (const id of validIds) {
1034
+ const child = this.nodes.get(id)!;
1035
+ this.nodes.set(id, { ...child, data: { ...child.data, parentGroup: groupId } });
1036
+ }
1037
+ if (options.preservePositions === true) {
1038
+ if (options.keepGroupFrame !== true && group.data.frameMode !== 'manual') {
1039
+ this.recomputeParentGroupBounds(groupId);
1040
+ }
1041
+ } else {
1042
+ this.compactGroupChildren(groupId, options.layout ?? 'grid');
1043
+ }
1044
+ if (options.preservePositions !== true && options.keepGroupFrame !== true) {
1045
+ this.reflowAllGroups();
1046
+ } else if (options.keepGroupFrame !== true && group.data.frameMode !== 'manual') {
1047
+ this.recomputeParentGroupBounds(groupId);
1048
+ }
1049
+
1050
+ this.scheduleSave();
1051
+ this.notifyChange('nodes');
1052
+ this.recordMutation({
1053
+ operationType: 'groupNodes',
1054
+ description: `Grouped ${validIds.length} nodes into "${(group.data.title as string) ?? groupId}"`,
1055
+ forward: this.suppressed(() => this.groupNodes(groupId, validIds, options)),
1056
+ inverse: this.suppressed(() => {
1057
+ const g = this.nodes.get(groupId);
1058
+ if (g) this.nodes.set(groupId, { ...g, data: { ...g.data, children: oldChildren } });
1059
+ for (const [id, oldParent] of oldParents) {
1060
+ const c = this.nodes.get(id);
1061
+ if (!c) continue;
1062
+ const d = { ...c.data };
1063
+ if (oldParent) d.parentGroup = oldParent; else delete d.parentGroup;
1064
+ this.nodes.set(id, { ...c, data: d });
1065
+ }
1066
+ this.scheduleSave();
1067
+ this.notifyChange('nodes');
1068
+ }),
1069
+ });
1070
+ return true;
1071
+ }
1072
+
1073
+ /** Remove all children from a group, clearing their parentGroup. */
1074
+ ungroupNodes(groupId: string): boolean {
1075
+ const group = this.nodes.get(groupId);
1076
+ if (!group || group.type !== 'group') return false;
1077
+
1078
+ const childIds = (group.data.children as string[]) ?? [];
1079
+ if (childIds.length === 0) return false;
1080
+
1081
+ const snapshot = childIds.slice();
1082
+
1083
+ // Clear children from group
1084
+ this.nodes.set(groupId, { ...group, data: { ...group.data, children: [] } });
1085
+ // Clear parentGroup from each child
1086
+ for (const id of childIds) {
1087
+ const child = this.nodes.get(id);
1088
+ if (!child) continue;
1089
+ const d = { ...child.data };
1090
+ delete d.parentGroup;
1091
+ this.nodes.set(id, { ...child, data: d });
1092
+ }
1093
+
1094
+ this.scheduleSave();
1095
+ this.notifyChange('nodes');
1096
+ this.recordMutation({
1097
+ operationType: 'ungroupNodes',
1098
+ description: `Ungrouped ${childIds.length} nodes from "${(group.data.title as string) ?? groupId}"`,
1099
+ forward: this.suppressed(() => this.ungroupNodes(groupId)),
1100
+ inverse: this.suppressed(() => this.groupNodes(groupId, snapshot)),
1101
+ });
1102
+ return true;
1103
+ }
1104
+
1105
+ clear(): void {
1106
+ const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
1107
+ const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
1108
+ const oldPins = Array.from(this._contextPinnedNodeIds);
1109
+ const oldViewport = { ...this._viewport };
1110
+ this.nodes.clear();
1111
+ this.edges.clear();
1112
+ this._contextPinnedNodeIds.clear();
1113
+ this._viewport = { x: 0, y: 0, scale: 1 };
1114
+ this.scheduleSave();
1115
+ this.notifyChange('nodes');
1116
+ this.notifyChange('pins');
1117
+ this.recordMutation({
1118
+ operationType: 'clear',
1119
+ description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
1120
+ forward: this.suppressed(() => this.clear()),
1121
+ inverse: this.suppressed(() => {
1122
+ for (const n of oldNodes) this.addNode(structuredClone(n));
1123
+ for (const e of oldEdges) this.addEdge(structuredClone(e));
1124
+ this.setContextPins(oldPins);
1125
+ this.setViewport(oldViewport);
1126
+ }),
1127
+ });
1128
+ }
1129
+ }
1130
+
1131
+ // Module-level singleton — safe because Bun is single-threaded and this
1132
+ // module is imported once per process. Agent tools and the HTTP server share
1133
+ // the same instance; no locking needed.
1134
+ export const canvasState = new CanvasStateManager();