pmx-canvas 0.3.3 → 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 (87) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/Readme.md +13 -4
  3. package/dist/canvas/index.js +71 -71
  4. package/dist/json-render/index.js +47 -47
  5. package/dist/types/cli/agent.d.ts +13 -8
  6. package/dist/types/cli/commands/apps.d.ts +1 -0
  7. package/dist/types/cli/commands/ax.d.ts +1 -0
  8. package/dist/types/cli/commands/copilot.d.ts +1 -0
  9. package/dist/types/cli/commands/edges.d.ts +1 -0
  10. package/dist/types/cli/commands/groups.d.ts +1 -0
  11. package/dist/types/cli/commands/history.d.ts +1 -0
  12. package/dist/types/cli/commands/nodes.d.ts +1 -0
  13. package/dist/types/cli/commands/pins.d.ts +1 -0
  14. package/dist/types/cli/commands/query.d.ts +1 -0
  15. package/dist/types/cli/commands/view.d.ts +1 -0
  16. package/dist/types/cli/commands/webview.d.ts +1 -0
  17. package/dist/types/cli/shared.d.ts +73 -0
  18. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
  19. package/dist/types/client/index.d.ts +1 -1
  20. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  21. package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
  22. package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
  23. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
  24. package/dist/types/client/state/node-factory.d.ts +29 -0
  25. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  26. package/dist/types/server/canvas-db.d.ts +0 -1
  27. package/dist/types/server/canvas-operations.d.ts +2 -0
  28. package/dist/types/server/canvas-state.d.ts +14 -15
  29. package/dist/types/server/diagram-presets.d.ts +8 -0
  30. package/dist/types/server/operations/composites.d.ts +6 -30
  31. package/dist/types/server/server.d.ts +1 -0
  32. package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
  33. package/docs/bun-webview-integration.md +5 -0
  34. package/docs/cli.md +5 -0
  35. package/docs/environment.md +48 -0
  36. package/docs/http-api.md +25 -1
  37. package/docs/mcp.md +21 -30
  38. package/docs/node-types.md +5 -0
  39. package/docs/plans/plan-009-tech-debt-backlog.md +34 -15
  40. package/docs/screenshot.png +0 -0
  41. package/docs/tech-debt-assessment-2026-06.md +5 -5
  42. package/package.json +13 -10
  43. package/skills/pmx-canvas/SKILL.md +16 -6
  44. package/skills/pmx-canvas/evals/evals.json +7 -7
  45. package/skills/pmx-canvas/references/full-reference.md +25 -16
  46. package/src/cli/agent.ts +22 -3512
  47. package/src/cli/commands/apps.ts +165 -0
  48. package/src/cli/commands/ax.ts +604 -0
  49. package/src/cli/commands/copilot.ts +43 -0
  50. package/src/cli/commands/edges.ts +73 -0
  51. package/src/cli/commands/groups.ts +81 -0
  52. package/src/cli/commands/history.ts +220 -0
  53. package/src/cli/commands/nodes.ts +984 -0
  54. package/src/cli/commands/pins.ts +42 -0
  55. package/src/cli/commands/query.ts +223 -0
  56. package/src/cli/commands/view.ts +186 -0
  57. package/src/cli/commands/webview.ts +227 -0
  58. package/src/cli/index.ts +8 -2
  59. package/src/cli/shared.ts +861 -0
  60. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
  61. package/src/client/index.tsx +2 -0
  62. package/src/client/nodes/ExtAppFrame.tsx +70 -51
  63. package/src/client/nodes/HtmlNode.tsx +8 -51
  64. package/src/client/nodes/McpAppNode.tsx +12 -47
  65. package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
  66. package/src/client/state/node-factory.ts +72 -0
  67. package/src/client/state/sse-bridge.ts +209 -86
  68. package/src/client/uuid-polyfill.ts +14 -0
  69. package/src/json-render/renderer/index.tsx +3 -2
  70. package/src/mcp/server.ts +0 -47
  71. package/src/server/canvas-db.ts +0 -8
  72. package/src/server/canvas-operations.ts +8 -0
  73. package/src/server/canvas-schema.ts +2 -8
  74. package/src/server/canvas-state.ts +51 -151
  75. package/src/server/diagram-presets.ts +15 -1
  76. package/src/server/html-surface.ts +11 -6
  77. package/src/server/index.ts +3 -9
  78. package/src/server/operations/composites.ts +25 -34
  79. package/src/server/operations/invoker.ts +10 -3
  80. package/src/server/operations/mcp.ts +51 -1
  81. package/src/server/operations/ops/app.ts +5 -0
  82. package/src/server/operations/ops/snapshots.ts +6 -7
  83. package/src/server/operations/registry.ts +4 -1
  84. package/src/server/server.ts +172 -74
  85. package/src/shared/ax-surface-protocol.ts +22 -0
  86. package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
  87. package/src/client/canvas/ContextPinHud.tsx +0 -17
@@ -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
 
@@ -41,6 +41,20 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
41
41
  url: EXCALIDRAW_MCP_URL,
42
42
  };
43
43
 
44
+ /**
45
+ * The diagram preset targets the hosted Excalidraw MCP server. Tests set
46
+ * PMX_CANVAS_EXCALIDRAW_MCP_COMMAND (a stdio command line, e.g.
47
+ * "bun run tests/fixtures/mcp-app-fixture.ts") so the unit suite never does a
48
+ * real network round-trip — the hosted dependency made diagram tests time out
49
+ * under parallel load (plan-009 M8).
50
+ */
51
+ export function resolveExcalidrawTransport(): ExternalMcpTransportConfig {
52
+ const override = process.env.PMX_CANVAS_EXCALIDRAW_MCP_COMMAND?.trim();
53
+ if (!override) return EXCALIDRAW_MCP_TRANSPORT;
54
+ const [command = override, ...args] = override.split(/\s+/);
55
+ return { type: 'stdio', command, args };
56
+ }
57
+
44
58
  export interface DiagramPresetOpenInput {
45
59
  elements: unknown;
46
60
  nodeId?: string;
@@ -379,7 +393,7 @@ export function ensureExcalidrawCheckpointId(
379
393
  export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): ExcalidrawOpenMcpAppInput {
380
394
  const elements = normalizeExcalidrawElementsForToolInput(input.elements);
381
395
  const out: ExcalidrawOpenMcpAppInput = {
382
- transport: EXCALIDRAW_MCP_TRANSPORT,
396
+ transport: resolveExcalidrawTransport(),
383
397
  toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
384
398
  serverName: EXCALIDRAW_SERVER_NAME,
385
399
  toolArguments: { elements },
@@ -15,6 +15,11 @@
15
15
  * postMessage required.
16
16
  */
17
17
 
18
+ import {
19
+ AX_SURFACE_ACK_SOURCE,
20
+ AX_SURFACE_EMIT_SOURCE,
21
+ HTML_SURFACE_PUSH_SOURCE,
22
+ } from '../shared/ax-surface-protocol.js';
18
23
  import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
19
24
 
20
25
  export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
@@ -54,7 +59,7 @@ function buildThemeBridge(themeToken: string): string {
54
59
  const PMX_CANVAS_THEME_TOKEN = ${token};
55
60
  window.addEventListener('message', (event) => {
56
61
  const message = event.data;
57
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
62
+ if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
58
63
  if (typeof message.theme !== 'string') return;
59
64
  document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
60
65
  document.documentElement.setAttribute('data-theme', message.theme);
@@ -74,12 +79,12 @@ function buildPresentationEscapeBridge(exitToken: string): string {
74
79
  const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
75
80
  document.addEventListener('keydown', (event) => {
76
81
  if (event.key === 'Escape') {
77
- window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
82
+ window.parent.postMessage({ source: '${HTML_SURFACE_PUSH_SOURCE}', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
78
83
  }
79
84
  }, true);
80
85
  window.addEventListener('message', (event) => {
81
86
  const message = event.data;
82
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
87
+ if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
83
88
  if (typeof message.key !== 'string') return;
84
89
  if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
85
90
  window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
@@ -131,7 +136,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
131
136
  seq += 1;
132
137
  const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
133
138
  window.parent.postMessage({
134
- source: 'pmx-canvas-ax',
139
+ source: '${AX_SURFACE_EMIT_SOURCE}',
135
140
  token: PMX_AX_TOKEN,
136
141
  nodeId: PMX_AX_NODE_ID,
137
142
  correlationId: correlationId,
@@ -152,7 +157,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
152
157
  };
153
158
  window.addEventListener('message', function (event) {
154
159
  const m = event.data;
155
- if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
160
+ if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
156
161
  const result = m.result || { ok: false };
157
162
  const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
158
163
  if (resolver) { pending.delete(m.correlationId); resolver(result); }
@@ -182,7 +187,7 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
182
187
  window.PMX_AX.state = ${snapshotJson};
183
188
  window.addEventListener('message', function (event) {
184
189
  const m = event.data;
185
- if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
190
+ if (!m || m.source !== '${HTML_SURFACE_PUSH_SOURCE}' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
186
191
  window.PMX_AX.state = m.state;
187
192
  try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
188
193
  });
@@ -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
  */
@@ -94,12 +79,6 @@ export interface CompositeToolDefinition {
94
79
  * raise a loud error instead of silently no-op'ing.
95
80
  */
96
81
  resolveOp?: (input: { kind: string; action: string }) => string | undefined;
97
- /**
98
- * Two-discriminator extension: human-readable `(kind, action)` for a member op
99
- * (the inverse of `resolveOp`), used to build that op's deprecation note. The
100
- * `kind` field-collision is resolved here (see `gateFieldRemap`).
101
- */
102
- describeOp?: (opName: string) => { kind: string; action: string } | undefined;
103
82
  /**
104
83
  * Field-name remap applied to the composite's advertised schema and undone at
105
84
  * dispatch. Resolves a collision between a discriminator name and a member-op
@@ -131,7 +110,7 @@ function resolveGateOp(input: { kind: string; action: string }): string | undefi
131
110
  return undefined;
132
111
  }
133
112
 
134
- /** The 9 gate ops, in (kind, action) order — single source for memberOps + describeOp. */
113
+ /** The 9 gate ops, in (kind, action) order — single source for memberOps. */
135
114
  const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS.flatMap((kind) =>
136
115
  GATE_ACTIONS.map((action) => {
137
116
  const op = resolveGateOp({ kind, action });
@@ -143,12 +122,25 @@ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS
143
122
  }),
144
123
  );
145
124
 
146
- function describeGateOp(opName: string): { kind: string; action: string } | undefined {
147
- const match = GATE_OPS.find((entry) => entry.op === opName);
148
- return match ? { kind: match.kind, action: match.action } : undefined;
149
- }
150
-
151
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
+ },
152
144
  {
153
145
  toolName: 'canvas_node',
154
146
  description:
@@ -289,7 +281,6 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
289
281
  },
290
282
  memberOps: GATE_OPS.map((entry) => entry.op),
291
283
  resolveOp: resolveGateOp,
292
- describeOp: describeGateOp,
293
284
  // ax.approval.request defines its own optional `action` field (a
294
285
  // machine-readable identifier the gate guards) — it collides with the gate
295
286
  // lifecycle `action` discriminator. Namespace it to `approvalAction` in the
@@ -68,19 +68,26 @@ export class HttpOperationInvoker implements OperationInvoker {
68
68
  const response = await fetch(url, init);
69
69
  const text = await response.text();
70
70
  let parsed: unknown = null;
71
+ let parsedAsJson = false;
71
72
  if (text.length > 0) {
72
73
  try {
73
74
  parsed = JSON.parse(text) as unknown;
75
+ parsedAsJson = true;
74
76
  } catch {
75
77
  parsed = { error: text };
76
78
  }
77
79
  }
78
80
  if (!response.ok) {
79
- if (route.errorBodyAsResult) return parsed;
81
+ // errorBodyAsResult carries the route's STRUCTURED partial-failure
82
+ // envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
83
+ // a result. A non-JSON error body (an HTML 502 from a proxy, a foreign
84
+ // service) is not that envelope — fall through to the throw path so the
85
+ // caller still fails loudly with a nonzero exit.
86
+ if (route.errorBodyAsResult && parsedAsJson) return parsed;
80
87
  const message =
81
- parsed !== null && typeof parsed === 'object' && 'error' in parsed
88
+ parsedAsJson && parsed !== null && typeof parsed === 'object' && 'error' in parsed
82
89
  ? String((parsed as { error?: unknown }).error)
83
- : `HTTP ${response.status}`;
90
+ : `HTTP ${response.status}${text.length > 0 ? `: ${text.slice(0, 200)}` : ''}`;
84
91
  throw new OperationError(message, toOperationErrorStatus(response.status));
85
92
  }
86
93
  return parsed;
@@ -11,7 +11,7 @@ import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
11
11
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  import { getOperation, listOperations } from './registry.js';
13
13
  import type { OperationInvoker } from './invoker.js';
14
- import { OperationError, type OperationMcpToolHost } from './types.js';
14
+ import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
15
15
  import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
16
16
 
17
17
  export interface OperationToolHost extends OperationMcpToolHost {
@@ -176,6 +176,43 @@ function stripCompositeDiscriminators(
176
176
  return out;
177
177
  }
178
178
 
179
+ /**
180
+ * Per-op cache of the MCP arg names its extraShape marks REQUIRED (a schema
181
+ * that rejects `undefined`). These are the standalone tools' old contracts,
182
+ * which buildCompositeShape flattens to all-optional — the composite handler
183
+ * re-enforces them at dispatch so a missing required field fails loudly and
184
+ * names the field, instead of failing deep in the handler or silently
185
+ * no-oping (the `q`-vs-`query` zero-results trap from the 0.3.1 test report).
186
+ */
187
+ const requiredFieldsCache = new WeakMap<object, string[]>();
188
+
189
+ // Advertised input aliases that satisfy a required extraShape field: the op's
190
+ // handler accepts either name, so the pre-dispatch check must too. (The only
191
+ // such collision across all 15 composites — edges.ts advertises `edge_id` as a
192
+ // legacy alias for `id` and its handler reads `edge_id ?? id`.)
193
+ const REQUIRED_FIELD_ALIASES: Record<string, Record<string, readonly string[]>> = {
194
+ 'edge.remove': { id: ['edge_id'] },
195
+ };
196
+
197
+ function requiredFieldSatisfied(opName: string, field: string, rest: Record<string, unknown>): boolean {
198
+ if (rest[field] !== undefined) return true;
199
+ const aliases = REQUIRED_FIELD_ALIASES[opName]?.[field] ?? [];
200
+ return aliases.some((alias) => rest[alias] !== undefined);
201
+ }
202
+
203
+ function requiredMcpFields(op: Operation): string[] {
204
+ const tool = op.mcp;
205
+ if (!tool?.extraShape) return [];
206
+ let keys = requiredFieldsCache.get(tool);
207
+ if (!keys) {
208
+ keys = Object.entries(tool.extraShape)
209
+ .filter(([, schema]) => !(schema as ZodTypeAny).safeParse(undefined).success)
210
+ .map(([key]) => key);
211
+ requiredFieldsCache.set(tool, keys);
212
+ }
213
+ return keys;
214
+ }
215
+
179
216
  /**
180
217
  * Register composite (action-discriminated) MCP tools (plan-006). Each action
181
218
  * dispatches to a registered operation, reusing that op's `mcp.buildInput` and
@@ -188,6 +225,12 @@ export function registerCompositeTools(
188
225
  definitions: CompositeToolDefinition[] = compositeToolDefinitions,
189
226
  ): void {
190
227
  for (const def of definitions) {
228
+ // Public composite name for each op field (inverse of fieldRemap), so
229
+ // required-field errors name the argument the agent actually sends.
230
+ const publicFieldName = new Map<string, string>();
231
+ for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
232
+ publicFieldName.set(opField, publicKey);
233
+ }
191
234
  server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
192
235
  try {
193
236
  const host = await getHost();
@@ -197,6 +240,13 @@ export function registerCompositeTools(
197
240
  // and undo any field remap; the rest is the op's raw MCP args — the same
198
241
  // value the standalone tool would receive.
199
242
  const rest = stripCompositeDiscriminators(def, input);
243
+ const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
244
+ if (missing.length > 0) {
245
+ const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
246
+ throw new OperationError(
247
+ `${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
248
+ );
249
+ }
200
250
  const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
201
251
  const result = await host.invoker().invoke(opName, opInput);
202
252
  if (op.mcp?.formatResult) {
@@ -405,6 +405,11 @@ const webArtifactOperation = defineOperation<z.infer<typeof webArtifactSchema>,
405
405
  http: {
406
406
  method: 'POST',
407
407
  path: '/api/canvas/web-artifact',
408
+ // Build failures throw OperationError, served as { ok:false, error }. The
409
+ // CLI prints that failure envelope on STDOUT and exits 1
410
+ // (cli-node.test.ts:2502-2548 pins it), so the HTTP invoker must return
411
+ // the non-2xx body instead of throwing — mirrors canvas.batch/webview.start.
412
+ errorBodyAsResult: true,
408
413
  },
409
414
  mcp: {
410
415
  toolName: 'canvas_build_web_artifact',