pmx-canvas 0.3.4 → 0.4.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 (39) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/Readme.md +11 -3
  3. package/dist/canvas/index.js +67 -67
  4. package/dist/types/client/index.d.ts +1 -1
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  6. package/dist/types/client/state/node-factory.d.ts +29 -0
  7. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  8. package/dist/types/server/canvas-db.d.ts +0 -1
  9. package/dist/types/server/canvas-state.d.ts +14 -15
  10. package/dist/types/server/operations/composites.d.ts +6 -21
  11. package/dist/types/server/server.d.ts +1 -0
  12. package/docs/cli.md +5 -0
  13. package/docs/environment.md +2 -2
  14. package/docs/http-api.md +25 -1
  15. package/docs/mcp.md +21 -30
  16. package/docs/plans/plan-009-tech-debt-backlog.md +13 -12
  17. package/docs/screenshot.png +0 -0
  18. package/docs/tech-debt-assessment-2026-06.md +5 -5
  19. package/package.json +7 -7
  20. package/skills/pmx-canvas/SKILL.md +16 -6
  21. package/skills/pmx-canvas/evals/evals.json +2 -2
  22. package/skills/pmx-canvas/references/full-reference.md +17 -15
  23. package/src/cli/commands/history.ts +1 -1
  24. package/src/cli/commands/nodes.ts +31 -1
  25. package/src/cli/index.ts +8 -2
  26. package/src/cli/shared.ts +1 -0
  27. package/src/client/index.tsx +2 -0
  28. package/src/client/nodes/ExtAppFrame.tsx +17 -2
  29. package/src/client/state/node-factory.ts +72 -0
  30. package/src/client/state/sse-bridge.ts +209 -86
  31. package/src/client/uuid-polyfill.ts +14 -0
  32. package/src/server/canvas-db.ts +0 -8
  33. package/src/server/canvas-schema.ts +2 -8
  34. package/src/server/canvas-state.ts +51 -151
  35. package/src/server/index.ts +3 -9
  36. package/src/server/operations/composites.ts +24 -21
  37. package/src/server/operations/ops/snapshots.ts +6 -7
  38. package/src/server/operations/registry.ts +4 -1
  39. package/src/server/server.ts +170 -74
@@ -7,20 +7,13 @@
7
7
  *
8
8
  * Persistence: canvas state auto-saves to `.pmx-canvas/canvas.db` (SQLite WAL mode)
9
9
  * in the workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
- * Legacy `.pmx-canvas/state.json` is auto-migrated on first boot.
10
+ * Legacy pre-0.2 formats (`.pmx-canvas/state.json`, `.pmx-canvas.json`,
11
+ * `.pmx-canvas-snapshots/`, loose blob files) are no longer imported as of 0.4.0 —
12
+ * the one-shot boot migration into SQLite was retired.
11
13
  */
12
14
 
13
15
  import { createHash } from 'node:crypto';
14
- import {
15
- existsSync,
16
- readFileSync,
17
- writeFileSync,
18
- mkdirSync,
19
- readdirSync,
20
- renameSync,
21
- rmSync,
22
- unlinkSync,
23
- } from 'node:fs';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
24
17
  import { isAbsolute, join, dirname, relative } from 'node:path';
25
18
  import { gzipSync, gunzipSync } from 'node:zlib';
26
19
  import { normalizeCanvasNodeData } from './canvas-provenance.js';
@@ -34,7 +27,6 @@ import {
34
27
  deleteSnapshotFromDB,
35
28
  writeBlobToDB,
36
29
  readBlobFromDB,
37
- hasBlobInDB,
38
30
  isDbPopulated,
39
31
  checkpointCanvasDb,
40
32
  finalizeCanvasDbForClose,
@@ -97,12 +89,9 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
97
89
  }
98
90
 
99
91
  export const PMX_CANVAS_DIR = '.pmx-canvas';
100
- const STATE_FILENAME = 'state.json';
101
92
  const DB_FILENAME = 'canvas.db';
102
93
  const SNAPSHOTS_SUBDIR = 'snapshots';
103
94
  const BLOBS_SUBDIR = 'blobs';
104
- const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
105
- const LEGACY_SNAPSHOTS_DIR = '.pmx-canvas-snapshots';
106
95
  const SAVE_DEBOUNCE_MS = 500;
107
96
  const BLOB_JSON_THRESHOLD_BYTES = Number(process.env.PMX_CANVAS_BLOB_THRESHOLD_BYTES ?? '2048');
108
97
  const BLOB_DATA_FIELDS = new Set([
@@ -393,10 +382,23 @@ class CanvasStateManager {
393
382
  }
394
383
 
395
384
  // ── Mutation recorder (for undo/redo history) ─────────────
385
+ //
386
+ // Single slot BY DESIGN, last-write-wins (plan-009 L5): the only logical
387
+ // consumer is the `mutationHistory` singleton, wired by `startCanvasServer()`
388
+ // (server.ts) on every fresh start. A listener LIST here would record every
389
+ // mutation twice into the history ring (breaking undo) if a second wire ever
390
+ // appeared, so keep it a single slot and wire recorders in one place only.
396
391
  private _mutationRecorder: ((info: MutationRecordInfo) => void) | null = null;
392
+ // The ONE recording-suppression counter (depth-counted so nested suppressed
393
+ // scopes compose). Two related mechanisms are deliberately NOT merged into
394
+ // it: `mutationHistory._replaying` guards direct `record()` calls during
395
+ // undo/redo replay, and the operation registry's `suppressEmitDepth`
396
+ // suppresses SSE emits during meta-ops (canvas.batch) — whose sub-ops must
397
+ // still record history, just as undo/redo still emits SSE while recording is
398
+ // suppressed here. The three counters are active at different times.
397
399
  private _suppressRecordingDepth = 0;
398
400
 
399
- /** Register a mutation recorder. Used by mutation-history to capture undo/redo closures. */
401
+ /** Register THE mutation recorder (single slot, last-write-wins — see note above). */
400
402
  onMutation(cb: (info: MutationRecordInfo) => void): void {
401
403
  this._mutationRecorder = cb;
402
404
  }
@@ -617,7 +619,6 @@ class CanvasStateManager {
617
619
  }
618
620
 
619
621
  // ── Persistence ────────────────────────────────────────────
620
- private _stateFilePath: string | null = null;
621
622
  private _db: import('bun:sqlite').Database | null = null;
622
623
  private _saveTimer: ReturnType<typeof setTimeout> | null = null;
623
624
 
@@ -630,9 +631,9 @@ class CanvasStateManager {
630
631
  setWorkspaceRoot(workspaceRoot: string): void {
631
632
  this.close();
632
633
  this._workspaceRoot = workspaceRoot;
633
- this.migrateLegacyLayout(workspaceRoot);
634
634
 
635
- // Determine DB path
635
+ // Determine DB path. PMX_CANVAS_DB_PATH wins; PMX_CANVAS_STATE_FILE is
636
+ // honored only as a legacy alias when it points at a `.db` file.
636
637
  const dbOverride = (process.env.PMX_CANVAS_DB_PATH ?? '').trim();
637
638
  const stateFileOverride = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
638
639
  let dbPath: string;
@@ -643,16 +644,20 @@ class CanvasStateManager {
643
644
  } else {
644
645
  dbPath = join(workspaceRoot, PMX_CANVAS_DIR, DB_FILENAME);
645
646
  }
647
+ if (stateFileOverride && !stateFileOverride.endsWith('.db')) {
648
+ logCanvasStateWarning(
649
+ 'PMX_CANVAS_STATE_FILE ignored',
650
+ 'legacy JSON state files are no longer supported as of 0.4.0 — use PMX_CANVAS_DB_PATH',
651
+ { stateFileOverride },
652
+ );
653
+ }
646
654
 
647
- // Keep legacy _stateFilePath for JSON migration detection
648
- this._stateFilePath =
649
- stateFileOverride && !stateFileOverride.endsWith('.db')
650
- ? stateFileOverride
651
- : join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
652
-
655
+ // Legacy pre-0.2 formats (`.pmx-canvas/state.json`, `.pmx-canvas.json`,
656
+ // `.pmx-canvas-snapshots/`, loose blob files) are no longer imported as of
657
+ // 0.4.0 — the one-shot boot migration that copied them into SQLite and
658
+ // renamed the originals to `.bak` was retired.
653
659
  try {
654
660
  this._db = openCanvasDb(dbPath);
655
- this.migrateJsonToSqlite();
656
661
  } catch (error) {
657
662
  logCanvasStateWarning('open canvas database failed', error, { dbPath });
658
663
  }
@@ -745,7 +750,7 @@ class CanvasStateManager {
745
750
  }
746
751
  }
747
752
 
748
- // Fallback to filesystem (for legacy blobs not yet migrated)
753
+ // Fallback to filesystem (legacy on-disk blobs are still readable in place)
749
754
  const filePath = this.resolveBlobPath(ref);
750
755
  if (!filePath) return ref;
751
756
  try {
@@ -804,112 +809,6 @@ class CanvasStateManager {
804
809
  };
805
810
  }
806
811
 
807
- /**
808
- * One-time migration: rename files from the pre-consolidation layout
809
- * (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
810
- * No-op when the new layout already exists.
811
- */
812
- private migrateLegacyLayout(workspaceRoot: string): void {
813
- const newDir = join(workspaceRoot, PMX_CANVAS_DIR);
814
- const legacyState = join(workspaceRoot, LEGACY_STATE_FILENAME);
815
- const newState = join(newDir, STATE_FILENAME);
816
- const legacySnapshots = join(workspaceRoot, LEGACY_SNAPSHOTS_DIR);
817
- const newSnapshots = join(newDir, SNAPSHOTS_SUBDIR);
818
-
819
- try {
820
- if (existsSync(legacyState) && !existsSync(newState)) {
821
- mkdirSync(newDir, { recursive: true });
822
- renameSync(legacyState, newState);
823
- }
824
- if (existsSync(legacySnapshots) && !existsSync(newSnapshots)) {
825
- mkdirSync(newDir, { recursive: true });
826
- renameSync(legacySnapshots, newSnapshots);
827
- }
828
- } catch (error) {
829
- logCanvasStateWarning('legacy layout migration failed', error, {
830
- workspaceRoot,
831
- });
832
- }
833
- }
834
-
835
- /**
836
- * One-time migration: import state.json + snapshot JSON files + blob files
837
- * into the SQLite database. Renames originals to `.bak`.
838
- */
839
- private migrateJsonToSqlite(): void {
840
- if (!this._db || !this._stateFilePath) return;
841
- const db = this._db;
842
-
843
- if (isDbPopulated(this._db)) return; // DB already initialized
844
-
845
- if (existsSync(this._stateFilePath)) {
846
- try {
847
- const raw = readFileSync(this._stateFilePath, 'utf-8');
848
- const parsed = JSON.parse(raw) as PersistedCanvasState;
849
- if (parsed && parsed.version === 1) {
850
- saveStateToDB(db, parsed);
851
- renameSync(this._stateFilePath, `${this._stateFilePath}.bak`);
852
- }
853
- } catch (error) {
854
- logCanvasStateWarning('migrate state.json to sqlite failed', error, {
855
- path: this._stateFilePath,
856
- });
857
- }
858
- }
859
-
860
- // Migrate snapshot JSON files
861
- const snapshotsDir = this.snapshotsDir;
862
- if (snapshotsDir && existsSync(snapshotsDir)) {
863
- try {
864
- const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json'));
865
- for (const file of files) {
866
- try {
867
- const filePath = join(snapshotsDir, file);
868
- const raw = readFileSync(filePath, 'utf-8');
869
- const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
870
- if (parsed.snapshot && parsed.version === 1) {
871
- saveSnapshotToDB(db, parsed.snapshot, parsed);
872
- renameSync(filePath, `${filePath}.bak`);
873
- }
874
- } catch (error) {
875
- logCanvasStateWarning('migrate snapshot file to sqlite failed', error, { file });
876
- }
877
- }
878
- } catch (error) {
879
- logCanvasStateWarning('migrate snapshots dir failed', error, { snapshotsDir });
880
- }
881
- }
882
-
883
- // Migrate blob files
884
- const blobsDir = this.blobsDir;
885
- if (blobsDir && existsSync(blobsDir)) {
886
- try {
887
- const prefixes = readdirSync(blobsDir).filter((d) => d.length === 2);
888
- for (const prefix of prefixes) {
889
- const prefixDir = join(blobsDir, prefix);
890
- const blobFiles = readdirSync(prefixDir).filter((f) => f.endsWith('.json.gz'));
891
- for (const blobFile of blobFiles) {
892
- try {
893
- const blobPath = join(prefixDir, blobFile);
894
- const sha256 = blobFile.replace('.json.gz', '');
895
- if (!hasBlobInDB(db, sha256)) {
896
- const compressed = readFileSync(blobPath);
897
- const json = gunzipSync(compressed).toString('utf-8');
898
- writeBlobToDB(db, sha256, json);
899
- }
900
- const backupPath = `${blobPath}.bak`;
901
- if (!existsSync(backupPath)) renameSync(blobPath, backupPath);
902
- } catch (error) {
903
- logCanvasStateWarning('migrate blob file to sqlite failed', error, { blobFile });
904
- }
905
- }
906
- }
907
- } catch (error) {
908
- logCanvasStateWarning('migrate blobs dir failed', error, { blobsDir });
909
- }
910
- }
911
- }
912
-
913
812
  getWorkspaceRoot(): string {
914
813
  return this._workspaceRoot;
915
814
  }
@@ -927,11 +826,12 @@ class CanvasStateManager {
927
826
  };
928
827
  }
929
828
 
930
- /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
829
+ /** Load canvas state from SQLite. Call once on server startup. */
931
830
  loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
932
831
  // Host capability lives in its own table (not snapshotted / not in PmxAxState).
933
832
  this.ax.loadHostCapabilityFromDb();
934
- // Try SQLite first (only if DB has been populated)
833
+ // Only a populated DB counts as saved state (legacy `state.json` loading was
834
+ // retired with the 0.4.0 boot-migration removal).
935
835
  if (this._db && isDbPopulated(this._db)) {
936
836
  try {
937
837
  const state = loadStateFromDB(this._db);
@@ -944,21 +844,6 @@ class CanvasStateManager {
944
844
  }
945
845
  }
946
846
 
947
- // Fallback to JSON (for edge cases where migration hasn't happened)
948
- if (this._stateFilePath && existsSync(this._stateFilePath)) {
949
- try {
950
- const raw = readFileSync(this._stateFilePath, 'utf-8');
951
- const parsed = JSON.parse(raw) as PersistedCanvasState;
952
- if (!parsed || parsed.version !== 1) return false;
953
- this.applyPersistedState(parsed);
954
- return true;
955
- } catch (error) {
956
- logCanvasStateWarning('load state from json fallback failed', error, {
957
- path: this._stateFilePath,
958
- });
959
- }
960
- }
961
-
962
847
  if (options.clearExisting) {
963
848
  this.applyPersistedState(this.emptyPersistedState());
964
849
  }
@@ -1015,11 +900,26 @@ class CanvasStateManager {
1015
900
  ax: this.getAxState(),
1016
901
  });
1017
902
  saveStateToDB(this._db, payload);
903
+ this._lastPersistenceError = null;
1018
904
  } catch (error) {
905
+ // Persistence failures are otherwise warn-and-continue (the canvas keeps
906
+ // working from memory) — record the failure so /health can report a
907
+ // degraded persistence state instead of silently losing durability (M4).
908
+ this._lastPersistenceError = {
909
+ message: error instanceof Error ? error.message : String(error),
910
+ at: new Date().toISOString(),
911
+ };
1019
912
  logCanvasStateWarning('save state to sqlite failed', error, {});
1020
913
  }
1021
914
  }
1022
915
 
916
+ private _lastPersistenceError: { message: string; at: string } | null = null;
917
+
918
+ /** Health view of state persistence: ok until a save fails, ok again after the next success. */
919
+ get persistenceHealth(): { ok: boolean; lastError: { message: string; at: string } | null } {
920
+ return { ok: this._lastPersistenceError === null, lastError: this._lastPersistenceError };
921
+ }
922
+
1023
923
  /** Close the SQLite database cleanly. Call on server shutdown. */
1024
924
  close(): void {
1025
925
  if (this._saveTimer) {
@@ -1098,7 +998,7 @@ class CanvasStateManager {
1098
998
  if (result) return result;
1099
999
  }
1100
1000
 
1101
- // Fallback to filesystem (for legacy snapshots not yet migrated)
1001
+ // Fallback to filesystem (legacy on-disk snapshots are still readable in place)
1102
1002
  const dir = this.snapshotsDir;
1103
1003
  if (!dir || !existsSync(dir)) return null;
1104
1004
 
@@ -176,15 +176,9 @@ export class PmxCanvas extends EventEmitter {
176
176
  this._server = base;
177
177
  this._port = getCanvasServerPort() ?? this._port;
178
178
 
179
- // Wire up mutation history recorder
180
- canvasState.onMutation((info) => {
181
- mutationHistory.record({
182
- description: info.description,
183
- operationType: info.operationType,
184
- forward: info.forward,
185
- inverse: info.inverse,
186
- });
187
- });
179
+ // The mutation-history recorder is wired by startCanvasServer above —
180
+ // onMutation is a single slot, so wiring it again here would only
181
+ // re-install an identical forwarder (plan-009 L5).
188
182
 
189
183
  // Wire up prompt handler to emit events
190
184
  setPrimaryWorkbenchCanvasPromptHandler(async (request) => {
@@ -16,27 +16,12 @@
16
16
  * operation (plan-005 slices 1–7 + plan-008 Wave 1) that is now reachable ONLY
17
17
  * through its composite (or `canvas_batch`).
18
18
  *
19
- * Deferred to v0.4: the `canvas_snapshot` composite (still a name collision —
20
- * see below). Its 6 legacy snapshot standalones (`canvas_snapshot`,
21
- * `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`,
22
- * `canvas_gc_snapshots`, `canvas_diff`) stay registered in v0.3.0 but are marked
23
- * deprecated (description-prefixed) per docs/api-stability.md's
24
- * deprecate-one-minor-before-removal rule. (`canvas_webview` shipped in plan-008
25
- * Wave 3 via runner injection; `canvas_app` shipped in Wave 4 — open-mcp-app /
26
- * diagram / build-artifact. Wave 5 folded the last three legacy tools
27
- * deprecate-only — NO per-action input-injection mechanism was needed:
28
- * `canvas_add_html_node` / `canvas_add_html_primitive` → `canvas_node` action
29
- * "add" (type:"html" [+ primitive]); `canvas_refresh_webpage_node` → `canvas_node`
30
- * action "update" (refresh:true). `canvas_screenshot` stays standalone — it
31
- * returns a binary image payload the composite/registry JSON wire shape does not
32
- * model.)
33
- *
34
- * Not shipped here: the `canvas_snapshot` composite (plan-006 #7). Its target
35
- * name is ALREADY a legacy standalone tool (the save-snapshot tool, op
36
- * `snapshot.save`), so it cannot be added additively without a name clash, and
37
- * repurposing `canvas_snapshot` to be action-discriminated now would break
38
- * existing callers. It lands in v0.4, in the same change that removes the 6
39
- * kept-but-deprecated legacy snapshot tools and frees the name.
19
+ * v0.4.0 completed the fold: the `canvas_snapshot` composite shipped (its name
20
+ * was held by the legacy save-snapshot standalone through v0.3.x, which — with
21
+ * the other 5 snapshot standalones — was deprecated in 0.3.0 and removed here
22
+ * per docs/api-stability.md's deprecate-one-minor-before-removal rule).
23
+ * `canvas_screenshot` stays standalone — it returns a binary image payload the
24
+ * composite/registry JSON wire shape does not model.
40
25
  *
41
26
  * This module must never import server.ts or index.ts.
42
27
  */
@@ -138,6 +123,24 @@ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS
138
123
  );
139
124
 
140
125
  export const compositeToolDefinitions: CompositeToolDefinition[] = [
126
+ {
127
+ // v0.4.0: the long-deferred snapshot composite (plan-006 #7). The name was
128
+ // held by the legacy save-snapshot standalone through v0.3.x; listing the
129
+ // composite here suppresses all 6 legacy snapshot standalones via
130
+ // compositeFoldedOpNames and frees the name in the same change.
131
+ toolName: 'canvas_snapshot',
132
+ description:
133
+ 'Canvas snapshots: save the current state, list saved snapshots, restore one, delete one, garbage-collect old ones, or diff the live canvas against one. Action "save" stores a named snapshot; "list" returns saved snapshots (newest first); "restore" replaces the live canvas with a snapshot; "delete" removes one; "gc" keeps the newest N and deletes the rest (dryRun to preview); "diff" shows added/removed/modified nodes and edges versus a snapshot (by name or id).',
134
+ actionSummary: 'save | list | restore | delete | gc | diff',
135
+ actions: {
136
+ save: 'snapshot.save',
137
+ list: 'snapshot.list',
138
+ restore: 'snapshot.restore',
139
+ delete: 'snapshot.delete',
140
+ gc: 'snapshot.gc',
141
+ diff: 'snapshot.diff',
142
+ },
143
+ },
141
144
  {
142
145
  toolName: 'canvas_node',
143
146
  description:
@@ -70,7 +70,7 @@ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>
70
70
  mcp: {
71
71
  toolName: 'canvas_list_snapshots',
72
72
  description:
73
- 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
73
+ 'List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
74
74
  extraShape: {
75
75
  limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
76
76
  query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
@@ -117,7 +117,7 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
117
117
  mcp: {
118
118
  toolName: 'canvas_snapshot',
119
119
  description:
120
- 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
120
+ 'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
121
121
  extraShape: {
122
122
  name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
123
123
  },
@@ -155,7 +155,7 @@ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Re
155
155
  mcp: {
156
156
  toolName: 'canvas_gc_snapshots',
157
157
  description:
158
- 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
158
+ 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
159
159
  extraShape: {
160
160
  keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
161
161
  dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
@@ -206,7 +206,7 @@ const snapshotDiffOperation = defineOperation<z.infer<typeof snapshotDiffSchema>
206
206
  mcp: {
207
207
  toolName: 'canvas_diff',
208
208
  description:
209
- 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
209
+ 'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
210
210
  extraShape: {
211
211
  snapshot: z.string().describe('Snapshot name or ID to compare against'),
212
212
  },
@@ -291,7 +291,7 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
291
291
  mcp: {
292
292
  toolName: 'canvas_restore',
293
293
  description:
294
- 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
294
+ 'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
295
295
  extraShape: {
296
296
  id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
297
297
  },
@@ -346,8 +346,7 @@ const snapshotDeleteOperation = defineOperation<z.infer<typeof snapshotDeleteSch
346
346
  },
347
347
  mcp: {
348
348
  toolName: 'canvas_delete_snapshot',
349
- description:
350
- 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete a saved snapshot by ID.',
349
+ description: 'Delete a saved snapshot by ID.',
351
350
  extraShape: {
352
351
  id: z.string().describe('Snapshot ID to delete'),
353
352
  },
@@ -39,7 +39,10 @@ export function setOperationEventEmitter(emitter: OperationEventEmitter | null):
39
39
  operationEventEmitter = emitter;
40
40
  }
41
41
 
42
- // Depth-counted emit suppression (mirrors canvasState._suppressRecordingDepth).
42
+ // Depth-counted EMIT suppression (same pattern as, but deliberately distinct
43
+ // state from, canvasState's recording suppression: batch suppresses SSE emits
44
+ // while sub-ops still record history; undo/redo suppresses recording while
45
+ // still emitting SSE — merging the two counters would break one or the other).
43
46
  // While > 0, emitOperationEvent is a no-op so a meta-op (canvas.batch) can run
44
47
  // many sub-ops without producing per-entry SSE frames, then emit ONE final
45
48
  // layout frame itself. Both the `mutates` auto-emit and `ctx.emit` route through