pmx-canvas 0.1.35 → 0.2.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 (100) hide show
  1. package/CHANGELOG.md +461 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/index.js +82 -41
  4. package/dist/json-render/index.js +89 -334
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  6. package/dist/types/mcp/canvas-access.d.ts +12 -159
  7. package/dist/types/server/ax-context.d.ts +1 -1
  8. package/dist/types/server/ax-state-manager.d.ts +256 -0
  9. package/dist/types/server/ax-state.d.ts +29 -1
  10. package/dist/types/server/ax-wait.d.ts +23 -0
  11. package/dist/types/server/canvas-operations.d.ts +1 -12
  12. package/dist/types/server/canvas-state.d.ts +46 -14
  13. package/dist/types/server/html-surface.d.ts +7 -0
  14. package/dist/types/server/index.d.ts +66 -26
  15. package/dist/types/server/operations/composites.d.ts +121 -0
  16. package/dist/types/server/operations/http.d.ts +7 -0
  17. package/dist/types/server/operations/index.d.ts +8 -0
  18. package/dist/types/server/operations/invoker.d.ts +13 -0
  19. package/dist/types/server/operations/mcp.d.ts +15 -0
  20. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  21. package/dist/types/server/operations/ops/app.d.ts +33 -0
  22. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  23. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  24. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  25. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  26. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  27. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  28. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  29. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  30. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  31. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  32. package/dist/types/server/operations/ops/query.d.ts +2 -0
  33. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  34. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  35. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  36. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  37. package/dist/types/server/operations/registry.d.ts +15 -0
  38. package/dist/types/server/operations/types.d.ts +116 -0
  39. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  40. package/docs/RELEASE.md +5 -0
  41. package/docs/adr-001-bun-only-runtime.md +46 -0
  42. package/docs/api-stability.md +57 -0
  43. package/docs/ax-host-adapter-contract.md +65 -0
  44. package/docs/ax-state-contract.md +72 -0
  45. package/docs/http-api.md +34 -2
  46. package/docs/mcp.md +64 -11
  47. package/docs/plans/plan-005-operation-registry.md +84 -0
  48. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  49. package/docs/plans/plan-007-ax-domain.md +99 -0
  50. package/docs/plans/plan-008-registry-finish.md +91 -0
  51. package/docs/screenshot.png +0 -0
  52. package/docs/tech-debt-assessment-2026-06.md +90 -0
  53. package/package.json +3 -3
  54. package/skills/pmx-canvas/SKILL.md +233 -185
  55. package/skills/pmx-canvas/evals/evals.json +3 -3
  56. package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
  57. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
  58. package/src/cli/agent.ts +52 -31
  59. package/src/client/nodes/ExtAppFrame.tsx +73 -5
  60. package/src/client/nodes/HtmlNode.tsx +12 -3
  61. package/src/client/nodes/McpAppNode.tsx +12 -3
  62. package/src/json-render/renderer/index.tsx +3 -0
  63. package/src/mcp/canvas-access.ts +43 -774
  64. package/src/mcp/server.ts +190 -2001
  65. package/src/server/ax-context.ts +7 -1
  66. package/src/server/ax-state-manager.ts +808 -0
  67. package/src/server/ax-state.ts +89 -2
  68. package/src/server/ax-wait.ts +56 -0
  69. package/src/server/canvas-operations.ts +2 -328
  70. package/src/server/canvas-schema.ts +2 -2
  71. package/src/server/canvas-state.ts +140 -382
  72. package/src/server/html-surface.ts +49 -11
  73. package/src/server/index.ts +136 -192
  74. package/src/server/operations/composites.ts +355 -0
  75. package/src/server/operations/http.ts +103 -0
  76. package/src/server/operations/index.ts +65 -0
  77. package/src/server/operations/invoker.ts +87 -0
  78. package/src/server/operations/mcp.ts +221 -0
  79. package/src/server/operations/ops/annotation.ts +60 -0
  80. package/src/server/operations/ops/app.ts +447 -0
  81. package/src/server/operations/ops/ax-await.ts +216 -0
  82. package/src/server/operations/ops/ax-shared.ts +38 -0
  83. package/src/server/operations/ops/ax-state.ts +249 -0
  84. package/src/server/operations/ops/ax-timeline.ts +381 -0
  85. package/src/server/operations/ops/ax-work.ts +635 -0
  86. package/src/server/operations/ops/batch.ts +365 -0
  87. package/src/server/operations/ops/edges.ts +166 -0
  88. package/src/server/operations/ops/groups.ts +176 -0
  89. package/src/server/operations/ops/json-render.ts +691 -0
  90. package/src/server/operations/ops/nodes.ts +1047 -0
  91. package/src/server/operations/ops/query.ts +281 -0
  92. package/src/server/operations/ops/snapshots.ts +366 -0
  93. package/src/server/operations/ops/validate.ts +37 -0
  94. package/src/server/operations/ops/viewport.ts +219 -0
  95. package/src/server/operations/ops/webview.ts +339 -0
  96. package/src/server/operations/registry.ts +79 -0
  97. package/src/server/operations/types.ts +150 -0
  98. package/src/server/operations/webview-runner.ts +77 -0
  99. package/src/server/server.ts +253 -2170
  100. package/src/server/web-artifacts.ts +6 -2
@@ -93,7 +93,7 @@
93
93
  "assertions": [
94
94
  {
95
95
  "name": "reads-pinned-context",
96
- "description": "Reads the canvas://pinned-context MCP resource (not just canvas_get_layout)",
96
+ "description": "Reads the canvas://pinned-context MCP resource (not just canvas_query action:layout)",
97
97
  "type": "output_check"
98
98
  },
99
99
  {
@@ -135,7 +135,7 @@
135
135
  "id": 6,
136
136
  "name": "status-dashboard-updates",
137
137
  "prompt": "I'm running a deployment pipeline. Create a dashboard on the canvas showing: Build (passing), Unit Tests (running), Integration Tests (queued), Deploy to Staging (queued). Then update it - unit tests just passed and integration tests are now running.",
138
- "expected_output": "Creates status nodes with semantic colors, arranges as grid dashboard, then updates nodes in place using canvas_update_node (not delete+recreate) to reflect new state.",
138
+ "expected_output": "Creates status nodes with semantic colors, arranges as grid dashboard, then updates nodes in place using canvas_node action:update (not delete+recreate) to reflect new state.",
139
139
  "assertions": [
140
140
  {
141
141
  "name": "initial-dashboard",
@@ -144,7 +144,7 @@
144
144
  },
145
145
  {
146
146
  "name": "updates-in-place",
147
- "description": "Uses canvas_update_node to change status (not remove+add)",
147
+ "description": "Uses canvas_node action:update to change status (not remove+add)",
148
148
  "type": "output_check"
149
149
  },
150
150
  {
@@ -21,9 +21,9 @@ tools, resources, and `canvas://ax-context`; use the CLI only as a fallback or f
21
21
 
22
22
  - Opens the live PMX workbench in the Codex in-app Browser.
23
23
  - Uses MCP tools/resources for all agent-side operations.
24
- - Reads `canvas://ax-context` or `canvas_get_ax` for pinned and focused context.
25
- - Sets AX focus through `canvas_set_ax_focus` with `source: "codex"` when the focus change comes
26
- from Codex-hosted steering.
24
+ - Reads `canvas://ax-context` or `canvas_ax_state { action: "get" }` for pinned and focused context.
25
+ - Sets AX focus through `canvas_ax_state { action: "set-focus" }` with `source: "codex"` when the
26
+ focus change comes from Codex-hosted steering.
27
27
  - Keeps all persistent PMX state in `.pmx-canvas/canvas.db`; Codex does not own canvas state.
28
28
 
29
29
  ## Setup
@@ -70,7 +70,8 @@ in the Codex in-app Browser, usually `http://127.0.0.1:4313/workbench` or
70
70
  inspect rendered artifacts.
71
71
  4. Use MCP tools for agent operations: create/update nodes, pin nodes, read layout, and read AX
72
72
  context.
73
- 5. When Codex wants to mark the current attention target, call:
73
+ 5. When Codex wants to mark the current attention target, call
74
+ `canvas_ax_state { action: "set-focus", … }` with:
74
75
 
75
76
  ```json
76
77
  {
@@ -79,27 +80,39 @@ in the Codex in-app Browser, usually `http://127.0.0.1:4313/workbench` or
79
80
  }
80
81
  ```
81
82
 
82
- against `canvas_set_ax_focus`.
83
-
84
83
  ## Context Contract
85
84
 
86
85
  Codex agents should treat PMX AX context as host-native working context:
87
86
 
88
87
  - `canvas://pinned-context` is the explicit human-curated node set.
89
- - `canvas://ax-context` combines pins, focus, and surface metadata.
90
- - `canvas_get_ax` returns both persisted AX state and agent-ready context.
88
+ - `canvas://ax-context` combines pins, focus, and surface metadata, plus a compact
89
+ loop-safe `delivery: { pendingSteering, pendingActivity }` lead block
90
+ (`GET /api/canvas/ax/context?consumer=codex` filters out Codex-originated items).
91
+ - `canvas_ax_state { action: "get" }` returns both persisted AX state and agent-ready context.
91
92
  - Focus is a current attention target, not a command to ignore the rest of the repository.
92
93
 
94
+ The adapterless MCP+Browser path is poll-based: there is no automatic prompt injection,
95
+ so a board click does not wake the current turn. Codex agents poll
96
+ `canvas_ax_delivery { action: "claim" }` (steering + `pendingActivity`) and act/ack explicitly. The
97
+ loop-closing surfaces work over MCP today even without a dedicated extension:
98
+
99
+ - **Self-report work** with `canvas_ingest_activity` (the board auto-reacts: a failed
100
+ tool → a blocked work item + review + evidence). Automatic forwarding of Codex's own
101
+ tool hooks would need a Codex adapter; manual ingestion works now.
102
+ - **Block on a decision** with `canvas_ax_gate { kind, action: "await", id }` (it long-polls PMX
103
+ until the human resolves the gate in the Browser or the timeout elapses) instead of looping on
104
+ `canvas_ax_state { action: "get" }`.
105
+
93
106
  ## Live-Test Checklist
94
107
 
95
108
  1. Open `http://127.0.0.1:4313/workbench` in the Codex in-app Browser first so the user can see
96
109
  all later canvas mutations.
97
110
  2. Confirm the PMX MCP server is configured for the workspace.
98
- 3. Call `canvas_get_ax` and confirm it returns `ok: true`.
111
+ 3. Call `canvas_ax_state { action: "get" }` and confirm it returns `ok: true`.
99
112
  4. Add or reuse a node, then pin it from the browser or with `canvas_pin_nodes`.
100
113
  5. Read `canvas://ax-context` and confirm the pinned node appears.
101
- 6. Call `canvas_set_ax_focus` with `source: "codex"` and a real node ID.
102
- 7. Read `canvas_get_ax` again and confirm `state.focus.source` is `codex`.
114
+ 6. Call `canvas_ax_state { action: "set-focus", source: "codex" }` with a real node ID.
115
+ 7. Read `canvas_ax_state { action: "get" }` again and confirm `state.focus.source` is `codex`.
103
116
  8. Refresh the browser and confirm the workbench still shows the same state.
104
117
 
105
118
  ## Adapter Boundary
@@ -48,6 +48,36 @@ panel.
48
48
  - Reads `/api/canvas/ax/context` and injects pinned/focused context from
49
49
  `onUserPromptSubmitted`.
50
50
  - Exposes adapter actions for status, AX context refresh, AX focus, and explicit session steering.
51
+
52
+ ### Agent behavior — steering is gated, not pushed
53
+
54
+ `onUserPromptSubmitted` injects the whole `/api/canvas/ax/context` (pins, focus, work
55
+ items, approval gates, and `timeline.pendingSteering`) as hidden context — but only
56
+ when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
57
+ and it is clipped to a char budget. Three consequences the adapter/agent must honor:
58
+
59
+ 1. A steering board must **stay pinned** (or its button must also emit `ax.focus.set`
60
+ on the board node) to hold the gate open.
61
+ 2. A sandbox button click does **not** wake a turn — a human message does. The click
62
+ only enqueues the steer.
63
+ 3. The agent must **act on injected `pendingSteering` / `pendingActivity` and then ack**
64
+ (`canvas_ax_delivery { action: "mark" }`), or it re-injects every gated turn.
65
+
66
+ To be robust to the char clip, prefer injecting the compact loop-safe lead block from
67
+ `GET /api/canvas/ax/context?consumer=copilot` (`delivery.pendingSteering` +
68
+ `delivery.pendingActivity`) **above** the full dump.
69
+
70
+ ### Closing the loop (optional, recommended)
71
+
72
+ - **Forward tool/session hooks** (`onPreToolUse` / `onPostToolUse` /
73
+ `onPostToolUseFailure` / `onSessionStart` / `onSessionEnd` / `onErrorOccurred`) to
74
+ `POST /api/canvas/ax/activity` (`canvas_ingest_activity`) so the board reflects the
75
+ agent's real work automatically (a failed tool → a blocked work item + review +
76
+ evidence).
77
+ - **Await gates** with `canvas_ax_gate { kind, action: "await", id }` (or surface a native modal
78
+ and await the PMX result) so an approval gate actually blocks the agent until the human resolves it.
79
+
80
+ See [`docs/ax-host-adapter-contract.md`](../../../docs/ax-host-adapter-contract.md).
51
81
  - Keeps all persistent PMX state in `.pmx-canvas/canvas.db`; the extension does not own canvas
52
82
  state.
53
83
 
@@ -127,5 +157,5 @@ global instruction to ignore the rest of the repository.
127
157
  For non-Copilot agents, use the same core primitives directly:
128
158
 
129
159
  - HTTP: `/api/canvas/ax`, `/api/canvas/ax/context`, `/api/canvas/ax/focus`
130
- - MCP: `canvas://ax`, `canvas://ax-context`, `canvas_get_ax`, `canvas_set_ax_focus`
160
+ - MCP: `canvas://ax`, `canvas://ax-context`, `canvas_ax_state { action: "get" }`, `canvas_ax_state { action: "set-focus" }`
131
161
  - CLI: `pmx-canvas ax status|context|focus`
package/src/cli/agent.ts CHANGED
@@ -15,6 +15,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { openUrlInExternalBrowser, wrapCanvasAutomationScript } from '../server/server.js';
18
+ import { HttpOperationInvoker, OperationError } from '../server/operations/index.js';
18
19
  import { DEFAULT_EXCALIDRAW_ELEMENTS } from '../server/diagram-presets.js';
19
20
  import {
20
21
  ALL_SEMANTIC_WATCH_EVENT_TYPES,
@@ -158,6 +159,26 @@ async function api(
158
159
  return json;
159
160
  }
160
161
 
162
+ // Operation-registry invoker (plan-005): node CRUD, layout reads, edge,
163
+ // pin/search/history/undo/redo, and snapshot commands build their HTTP request
164
+ // from the shared route table instead of hand-written paths.
165
+ // Error handling mirrors api(): operation failures and connection failures
166
+ // die with the same JSON error shape.
167
+ async function invokeOperation(name: string, input: Record<string, unknown>): Promise<unknown> {
168
+ const base = getBaseUrl();
169
+ try {
170
+ return await new HttpOperationInvoker(base).invoke(name, input);
171
+ } catch (error) {
172
+ if (error instanceof OperationError) {
173
+ die(error.message);
174
+ }
175
+ die(
176
+ `Cannot connect to pmx-canvas at ${base}: ${error instanceof Error ? error.message : String(error)}`,
177
+ `Start the server first: pmx-canvas --no-open`,
178
+ );
179
+ }
180
+ }
181
+
161
182
  // ── Flag parsing ─────────────────────────────────────────────
162
183
 
163
184
  function parseFlags(args: string[]): { positional: string[]; flags: Record<string, string | true> } {
@@ -1162,13 +1183,13 @@ cmd('node add', 'Add a node to the canvas', [
1162
1183
  const type = (flags.type as string) || 'markdown';
1163
1184
 
1164
1185
  if (type === 'json-render') {
1165
- const result = await api('POST', '/api/canvas/json-render', await buildJsonRenderRequestBody(flags));
1186
+ const result = await invokeOperation('jsonrender.add', await buildJsonRenderRequestBody(flags));
1166
1187
  output(result);
1167
1188
  return;
1168
1189
  }
1169
1190
 
1170
1191
  if (type === 'graph') {
1171
- const result = await api('POST', '/api/canvas/graph', await buildGraphRequestBody(flags));
1192
+ const result = await invokeOperation('graph.add', await buildGraphRequestBody(flags));
1172
1193
  output(result);
1173
1194
  return;
1174
1195
  }
@@ -1179,13 +1200,13 @@ cmd('node add', 'Add a node to the canvas', [
1179
1200
  }
1180
1201
 
1181
1202
  if (type === 'html-primitive') {
1182
- const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
1203
+ const result = await invokeOperation('node.add', await buildHtmlPrimitiveRequestBody(flags));
1183
1204
  output(result);
1184
1205
  return;
1185
1206
  }
1186
1207
 
1187
1208
  if (type === 'html' && getStringFlag(flags, 'primitive', 'kind')) {
1188
- const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
1209
+ const result = await invokeOperation('node.add', await buildHtmlPrimitiveRequestBody(flags));
1189
1210
  output(result);
1190
1211
  return;
1191
1212
  }
@@ -1245,7 +1266,7 @@ cmd('node add', 'Add a node to the canvas', [
1245
1266
  }
1246
1267
  }
1247
1268
 
1248
- const result = await api('POST', '/api/canvas/node', body);
1269
+ const result = await invokeOperation('node.add', body);
1249
1270
  output(result);
1250
1271
  });
1251
1272
 
@@ -1310,7 +1331,7 @@ cmd('graph add', 'Add a graph node to the canvas', [
1310
1331
  const { flags } = parseFlags(args);
1311
1332
  if (flags.help || flags.h) return showCommandHelp('graph add');
1312
1333
 
1313
- const result = await api('POST', '/api/canvas/graph', await buildGraphRequestBody(flags));
1334
+ const result = await invokeOperation('graph.add', await buildGraphRequestBody(flags));
1314
1335
  output(result);
1315
1336
  });
1316
1337
 
@@ -1383,7 +1404,7 @@ cmd('node list', 'List all nodes on the canvas', [
1383
1404
  const { flags } = parseFlags(args);
1384
1405
  if (flags.help || flags.h) return showCommandHelp('node list');
1385
1406
 
1386
- const layout = (await api('GET', '/api/canvas/state')) as { nodes: Array<Record<string, unknown>> };
1407
+ const layout = (await invokeOperation('layout.get', {})) as { nodes: Array<Record<string, unknown>> };
1387
1408
  let nodes = layout.nodes;
1388
1409
 
1389
1410
  if (flags.type && flags.type !== true) {
@@ -1414,7 +1435,7 @@ cmd('node get', 'Get a node by ID', [
1414
1435
  const id = positional[0];
1415
1436
  if (!id) die('Missing node ID', 'pmx-canvas node get <node-id>');
1416
1437
 
1417
- const result = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as Record<string, unknown>;
1438
+ const result = await invokeOperation('node.get', { id }) as Record<string, unknown>;
1418
1439
  const requestedFields = collectRequestedFields(args, flags);
1419
1440
  if (requestedFields.length > 0) {
1420
1441
  const picked = Object.fromEntries(requestedFields.map((field) => [field, resolveNodeFieldValue(result, field)]));
@@ -1496,7 +1517,7 @@ cmd('node update', 'Update a node by ID', [
1496
1517
  }
1497
1518
 
1498
1519
  if (x !== undefined || y !== undefined || width !== undefined || frameHeight !== undefined || arrangeLocked !== undefined) {
1499
- const existing = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as {
1520
+ const existing = await invokeOperation('node.get', { id }) as {
1500
1521
  position: { x: number; y: number };
1501
1522
  size: { width: number; height: number };
1502
1523
  data: Record<string, unknown>;
@@ -1545,7 +1566,7 @@ cmd('node update', 'Update a node by ID', [
1545
1566
  );
1546
1567
  }
1547
1568
 
1548
- const result = await api('PATCH', `/api/canvas/node/${encodeURIComponent(id)}`, body);
1569
+ const result = await invokeOperation('node.update', { id, ...body });
1549
1570
  output(result);
1550
1571
  });
1551
1572
 
@@ -1560,7 +1581,7 @@ cmd('node remove', 'Remove a node from the canvas', [
1560
1581
  const id = positional[0];
1561
1582
  if (!id) die('Missing node ID', 'pmx-canvas node remove <node-id>');
1562
1583
 
1563
- const result = await api('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
1584
+ const result = await invokeOperation('node.remove', { id });
1564
1585
  output(result);
1565
1586
  });
1566
1587
 
@@ -1604,7 +1625,7 @@ cmd('edge add', 'Add an edge between two nodes', [
1604
1625
  if (typeof flags.style === 'string') body.style = flags.style;
1605
1626
  if (flags.animated) body.animated = true;
1606
1627
 
1607
- const result = await api('POST', '/api/canvas/edge', body);
1628
+ const result = await invokeOperation('edge.add', body);
1608
1629
  output(result);
1609
1630
  });
1610
1631
 
@@ -1629,7 +1650,7 @@ cmd('edge remove', 'Remove an edge by ID', [
1629
1650
  const id = positional[0];
1630
1651
  if (!id) die('Missing edge ID', 'pmx-canvas edge remove <edge-id>');
1631
1652
 
1632
- const result = await api('DELETE', '/api/canvas/edge', { edge_id: id });
1653
+ const result = await invokeOperation('edge.remove', { edge_id: id });
1633
1654
  output(result);
1634
1655
  });
1635
1656
 
@@ -1644,7 +1665,7 @@ cmd('search', 'Search nodes by title or content', [
1644
1665
  const query = positional[0] || (typeof flags.query === 'string' ? flags.query : '');
1645
1666
  if (!query) die('Missing search query', 'pmx-canvas search "query"');
1646
1667
 
1647
- const result = await api('GET', `/api/canvas/search?q=${encodeURIComponent(query)}`);
1668
+ const result = await invokeOperation('search', { q: query });
1648
1669
  output(result);
1649
1670
  });
1650
1671
 
@@ -1815,14 +1836,14 @@ cmd('pin', 'Manage context pins', [
1815
1836
  }
1816
1837
 
1817
1838
  if (flags.clear) {
1818
- const result = await api('POST', '/api/canvas/context-pins', { nodeIds: [] });
1839
+ const result = await invokeOperation('pin.set', { nodeIds: [] });
1819
1840
  output(result);
1820
1841
  return;
1821
1842
  }
1822
1843
 
1823
1844
  // --set: positional args are node IDs
1824
1845
  if (positional.length > 0 || flags.set) {
1825
- const result = await api('POST', '/api/canvas/context-pins', { nodeIds: positional });
1846
+ const result = await invokeOperation('pin.set', { nodeIds: positional });
1826
1847
  output(result);
1827
1848
  return;
1828
1849
  }
@@ -2314,7 +2335,7 @@ cmd('undo', 'Undo the last canvas mutation', [
2314
2335
  const { flags } = parseFlags(args);
2315
2336
  if (flags.help || flags.h) return showCommandHelp('undo');
2316
2337
 
2317
- const result = await api('POST', '/api/canvas/undo');
2338
+ const result = await invokeOperation('canvas.undo', {});
2318
2339
  output(result);
2319
2340
  });
2320
2341
 
@@ -2325,7 +2346,7 @@ cmd('redo', 'Redo the last undone mutation', [
2325
2346
  const { flags } = parseFlags(args);
2326
2347
  if (flags.help || flags.h) return showCommandHelp('redo');
2327
2348
 
2328
- const result = await api('POST', '/api/canvas/redo');
2349
+ const result = await invokeOperation('canvas.redo', {});
2329
2350
  output(result);
2330
2351
  });
2331
2352
 
@@ -2338,7 +2359,7 @@ cmd('history', 'Show canvas mutation history', [
2338
2359
  const { flags } = parseFlags(args);
2339
2360
  if (flags.help || flags.h) return showCommandHelp('history');
2340
2361
 
2341
- const result = await api('GET', '/api/canvas/history') as Record<string, unknown>;
2362
+ const result = await invokeOperation('history.get', {}) as Record<string, unknown>;
2342
2363
  if (flags.summary) {
2343
2364
  output(summarizeHistoryResult(result));
2344
2365
  return;
@@ -2359,7 +2380,7 @@ cmd('snapshot save', 'Save a named snapshot of the current canvas', [
2359
2380
  if (flags.help || flags.h) return showCommandHelp('snapshot save');
2360
2381
 
2361
2382
  const name = requireFlag(flags, 'name', 'pmx-canvas snapshot save --name "my-snapshot"');
2362
- const result = await api('POST', '/api/canvas/snapshots', { name });
2383
+ const result = await invokeOperation('snapshot.save', { name });
2363
2384
  output(result);
2364
2385
  });
2365
2386
 
@@ -2373,17 +2394,17 @@ cmd('snapshot list', 'List saved snapshots', [
2373
2394
  const { flags } = parseFlags(args);
2374
2395
  if (flags.help || flags.h) return showCommandHelp('snapshot list');
2375
2396
 
2376
- const params = new URLSearchParams();
2377
2397
  const limit = optionalNumberFlag(flags, 'limit', 'Use a positive integer, e.g. --limit 50');
2378
2398
  const query = getStringFlag(flags, 'query', 'q');
2379
2399
  const before = getStringFlag(flags, 'before');
2380
2400
  const after = getStringFlag(flags, 'after');
2381
- if (limit !== undefined) params.set('limit', String(limit));
2382
- if (query) params.set('q', query);
2383
- if (before) params.set('before', before);
2384
- if (after) params.set('after', after);
2385
- if (flags.all) params.set('all', 'true');
2386
- const result = await api('GET', `/api/canvas/snapshots${params.size > 0 ? `?${params.toString()}` : ''}`);
2401
+ const result = await invokeOperation('snapshot.list', {
2402
+ ...(limit !== undefined ? { limit } : {}),
2403
+ ...(query ? { q: query } : {}),
2404
+ ...(before ? { before } : {}),
2405
+ ...(after ? { after } : {}),
2406
+ ...(flags.all ? { all: true } : {}),
2407
+ });
2387
2408
  output(result);
2388
2409
  });
2389
2410
 
@@ -2400,7 +2421,7 @@ cmd('snapshot gc', 'Delete old snapshots, keeping the newest N', [
2400
2421
  if (!dryRun && !flags.yes) {
2401
2422
  die('Destructive operation requires --yes flag', 'Preview with: pmx-canvas snapshot gc --keep 20 --dry-run');
2402
2423
  }
2403
- const result = await api('POST', '/api/canvas/snapshots/gc', {
2424
+ const result = await invokeOperation('snapshot.gc', {
2404
2425
  ...(keep !== undefined ? { keep } : {}),
2405
2426
  dryRun,
2406
2427
  });
@@ -2417,7 +2438,7 @@ cmd('snapshot restore', 'Restore canvas from a snapshot', [
2417
2438
  const id = positional[0];
2418
2439
  if (!id) die('Missing snapshot ID or name', 'pmx-canvas snapshot restore <snapshot-id-or-name>');
2419
2440
 
2420
- const result = await api('POST', `/api/canvas/snapshots/${encodeURIComponent(id)}`);
2441
+ const result = await invokeOperation('snapshot.restore', { id });
2421
2442
  output(result);
2422
2443
  });
2423
2444
 
@@ -2431,7 +2452,7 @@ cmd('snapshot delete', 'Delete a saved snapshot', [
2431
2452
  const id = positional[0];
2432
2453
  if (!id) die('Missing snapshot ID', 'pmx-canvas snapshot delete <snapshot-id>');
2433
2454
 
2434
- const result = await api('DELETE', `/api/canvas/snapshots/${encodeURIComponent(id)}`);
2455
+ const result = await invokeOperation('snapshot.delete', { id });
2435
2456
  output(result);
2436
2457
  });
2437
2458
 
@@ -2439,7 +2460,7 @@ async function runSnapshotDiff(args: string[]): Promise<void> {
2439
2460
  const { positional, flags } = parseFlags(args);
2440
2461
  const snapshot = positional[0];
2441
2462
  if (!snapshot) die('Missing snapshot ID or name', 'pmx-canvas snapshot diff <snapshot-id-or-name>');
2442
- const result = await api('GET', `/api/canvas/snapshots/${encodeURIComponent(snapshot)}/diff`);
2463
+ const result = await invokeOperation('snapshot.diff', { id: snapshot });
2443
2464
  output(result);
2444
2465
  }
2445
2466
 
@@ -97,6 +97,66 @@ export function resolveExtAppSandbox(value: unknown): string {
97
97
  : DEFAULT_EXT_APP_SANDBOX;
98
98
  }
99
99
 
100
+ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string {
101
+ return `<script data-pmx-canvas-ax-bridge>
102
+ (function () {
103
+ const PMX_AX_TOKEN = ${JSON.stringify(axToken)};
104
+ const PMX_AX_NODE_ID = ${JSON.stringify(nodeId)};
105
+ window.PMX_AX = window.PMX_AX || {};
106
+ const pending = new Map();
107
+ const ackListeners = [];
108
+ let seq = 0;
109
+ window.PMX_AX.emit = function (type, payload) {
110
+ seq += 1;
111
+ const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
112
+ return new Promise(function (resolve) {
113
+ const timer = setTimeout(function () {
114
+ pending.delete(correlationId);
115
+ resolve({ ok: false, status: 504, code: 'ax-ack-timeout', error: 'ax-ack-timeout' });
116
+ }, 10000);
117
+ pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
118
+ window.parent.postMessage({
119
+ source: 'pmx-canvas-ax',
120
+ token: PMX_AX_TOKEN,
121
+ nodeId: PMX_AX_NODE_ID,
122
+ correlationId: correlationId,
123
+ interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
124
+ }, '*');
125
+ });
126
+ };
127
+ window.PMX_AX.on = function (eventType, cb) {
128
+ if (eventType === 'ack' && typeof cb === 'function') ackListeners.push(cb);
129
+ };
130
+ window.addEventListener('message', function (event) {
131
+ const m = event.data;
132
+ if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
133
+ const result = m.result || { ok: false };
134
+ const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
135
+ if (resolver) { pending.delete(m.correlationId); resolver(result); }
136
+ for (let i = 0; i < ackListeners.length; i += 1) {
137
+ try { ackListeners[i](result, m.interaction || null); } catch (e) {}
138
+ }
139
+ try { window.dispatchEvent(new CustomEvent('pmx-ax-ack', { detail: { result: result, interaction: m.interaction || null } })); } catch (e) {}
140
+ });
141
+ })();
142
+ </script>`;
143
+ }
144
+
145
+ export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
146
+ if (!axBridgeScript) return html;
147
+ const headMatch = /<head\b[^>]*>/i.exec(html);
148
+ if (headMatch?.index !== undefined) {
149
+ const insertAt = headMatch.index + headMatch[0].length;
150
+ return `${html.slice(0, insertAt)}${axBridgeScript}${html.slice(insertAt)}`;
151
+ }
152
+ const bodyMatch = /<body\b[^>]*>/i.exec(html);
153
+ if (bodyMatch?.index !== undefined) {
154
+ const insertAt = bodyMatch.index + bodyMatch[0].length;
155
+ return `${html.slice(0, insertAt)}${axBridgeScript}${html.slice(insertAt)}`;
156
+ }
157
+ return `${axBridgeScript}${html}`;
158
+ }
159
+
100
160
  function positiveDimension(value: number, fallback: number): number {
101
161
  if (Number.isFinite(value) && value > 0) return Math.round(value);
102
162
  if (Number.isFinite(fallback) && fallback > 0) return Math.round(fallback);
@@ -164,31 +224,39 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
164
224
  const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
165
225
  const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
166
226
  const axBridgeScript = axEnabled
167
- ? `<script data-pmx-canvas-ax-bridge>window.PMX_AX={emit:function(t,p){window.parent.postMessage({source:'pmx-canvas-ax',token:${JSON.stringify(axToken)},nodeId:${JSON.stringify(nodeId)},interaction:{type:String(t),payload:p&&typeof p==='object'?p:{}}},'*');}};</script>`
227
+ ? buildExtAppAxBridgeScript(axToken, nodeId)
168
228
  : '';
169
- const iframeDocument = useIframeDocument((html ?? '') + axBridgeScript, iframeSandbox);
229
+ const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
170
230
 
171
231
  useEffect(() => {
172
232
  if (!axEnabled) return;
173
233
  function onAxMessage(event: MessageEvent) {
174
234
  if (event.source !== iframeRef.current?.contentWindow) return;
175
235
  const data = event.data as {
176
- source?: string; token?: string; nodeId?: string;
236
+ source?: string; token?: string; nodeId?: string; correlationId?: string;
177
237
  interaction?: { type?: unknown; payload?: unknown };
178
238
  } | null;
179
239
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
180
240
  const interaction = data.interaction;
181
241
  if (!interaction || typeof interaction.type !== 'string') return;
242
+ const interactionType = interaction.type;
182
243
  void submitAxInteractionFromClient({
183
- type: interaction.type,
244
+ type: interactionType,
184
245
  sourceNodeId: nodeId,
185
246
  sourceSurface: 'mcp-app',
186
247
  ...(interaction.payload && typeof interaction.payload === 'object'
187
248
  ? { payload: interaction.payload as Record<string, unknown> }
188
249
  : {}),
189
250
  }).then((res) => {
190
- if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [nodeId]);
251
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
191
252
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
253
+ iframeRef.current?.contentWindow?.postMessage({
254
+ source: 'pmx-canvas-ax-ack',
255
+ token: axToken,
256
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
257
+ interaction: { type: interactionType },
258
+ result: res,
259
+ }, '*');
192
260
  });
193
261
  }
194
262
  window.addEventListener('message', onAxMessage);
@@ -57,22 +57,31 @@ export function HtmlNode({
57
57
  // nodeId are a second gate, not the only one.
58
58
  if (event.source !== iframeRef.current?.contentWindow) return;
59
59
  const data = event.data as {
60
- source?: string; token?: string; nodeId?: string;
60
+ source?: string; token?: string; nodeId?: string; correlationId?: string;
61
61
  interaction?: { type?: unknown; payload?: unknown };
62
62
  } | null;
63
63
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
64
64
  const interaction = data.interaction;
65
65
  if (!interaction || typeof interaction.type !== 'string') return;
66
+ const interactionType = interaction.type;
66
67
  void submitAxInteractionFromClient({
67
- type: interaction.type,
68
+ type: interactionType,
68
69
  sourceNodeId: node.id,
69
70
  sourceSurface: 'html-node',
70
71
  ...(interaction.payload && typeof interaction.payload === 'object'
71
72
  ? { payload: interaction.payload as Record<string, unknown> }
72
73
  : {}),
73
74
  }).then((res) => {
74
- if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
75
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
75
76
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
77
+ // Report #55: ack back to the surface so it can self-confirm (e.g. "queued ✓").
78
+ iframeRef.current?.contentWindow?.postMessage({
79
+ source: 'pmx-canvas-ax-ack',
80
+ token: axToken,
81
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
82
+ interaction: { type: interactionType },
83
+ result: res,
84
+ }, '*');
76
85
  });
77
86
  }
78
87
  window.addEventListener('message', onAxMessage);
@@ -89,22 +89,31 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
89
89
  function onAxMessage(event: MessageEvent) {
90
90
  if (event.source !== iframeRef.current?.contentWindow) return;
91
91
  const data = event.data as {
92
- source?: string; token?: string; nodeId?: string;
92
+ source?: string; token?: string; nodeId?: string; correlationId?: string;
93
93
  interaction?: { type?: unknown; payload?: unknown };
94
94
  } | null;
95
95
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
96
96
  const interaction = data.interaction;
97
97
  if (!interaction || typeof interaction.type !== 'string') return;
98
+ const interactionType = interaction.type;
98
99
  void submitAxInteractionFromClient({
99
- type: interaction.type,
100
+ type: interactionType,
100
101
  sourceNodeId: node.id,
101
102
  sourceSurface: axSurface,
102
103
  ...(interaction.payload && typeof interaction.payload === 'object'
103
104
  ? { payload: interaction.payload as Record<string, unknown> }
104
105
  : {}),
105
106
  }).then((res) => {
106
- if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
107
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
107
108
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
109
+ // Report #55: ack back to the viewer so the surface can self-confirm.
110
+ iframeRef.current?.contentWindow?.postMessage({
111
+ source: 'pmx-canvas-ax-ack',
112
+ token: axToken,
113
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
114
+ interaction: { type: interactionType },
115
+ result: res,
116
+ }, '*');
108
117
  });
109
118
  }
110
119
  window.addEventListener('message', onAxMessage);
@@ -121,6 +121,9 @@ function buildAxHandlers(): Record<string, (params: Record<string, unknown>) =>
121
121
  const token = window.__PMX_CANVAS_AX_TOKEN__;
122
122
  const handlers: Record<string, (params: Record<string, unknown>) => void> = {};
123
123
  if (!nodeId || !token) return handlers;
124
+ // Declarative json-render boards are reflect-only: a spec action is fire-and-forget
125
+ // and confirmation arrives as a live `pmx-ax-update` (the work item appears). There
126
+ // is no JS surface for a Promise-style ack here, so we don't stamp a correlationId.
124
127
  for (const type of AX_INTERACTION_HANDLER_NAMES) {
125
128
  handlers[type] = (params: Record<string, unknown>) => {
126
129
  window.parent.postMessage({