pmx-canvas 0.1.36 → 0.2.1

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 (91) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/Readme.md +2 -2
  3. package/dist/json-render/index.js +89 -334
  4. package/dist/types/mcp/canvas-access.d.ts +5 -171
  5. package/dist/types/server/ax-state-manager.d.ts +267 -0
  6. package/dist/types/server/ax-state.d.ts +3 -1
  7. package/dist/types/server/canvas-db.d.ts +13 -0
  8. package/dist/types/server/canvas-operations.d.ts +1 -12
  9. package/dist/types/server/canvas-state.d.ts +8 -23
  10. package/dist/types/server/index.d.ts +6 -24
  11. package/dist/types/server/operations/composites.d.ts +121 -0
  12. package/dist/types/server/operations/http.d.ts +7 -0
  13. package/dist/types/server/operations/index.d.ts +8 -0
  14. package/dist/types/server/operations/invoker.d.ts +13 -0
  15. package/dist/types/server/operations/mcp.d.ts +15 -0
  16. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  17. package/dist/types/server/operations/ops/app.d.ts +33 -0
  18. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  19. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  20. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  21. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  22. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  23. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  24. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  25. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  26. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  27. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  28. package/dist/types/server/operations/ops/query.d.ts +2 -0
  29. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  30. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  31. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  32. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  33. package/dist/types/server/operations/registry.d.ts +15 -0
  34. package/dist/types/server/operations/types.d.ts +116 -0
  35. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  36. package/docs/RELEASE.md +5 -0
  37. package/docs/adr-001-bun-only-runtime.md +46 -0
  38. package/docs/api-stability.md +57 -0
  39. package/docs/ax-host-adapter-contract.md +19 -1
  40. package/docs/ax-state-contract.md +72 -0
  41. package/docs/http-api.md +4 -0
  42. package/docs/mcp.md +61 -12
  43. package/docs/plans/plan-005-operation-registry.md +84 -0
  44. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  45. package/docs/plans/plan-007-ax-domain.md +99 -0
  46. package/docs/plans/plan-008-registry-finish.md +91 -0
  47. package/docs/tech-debt-assessment-2026-06.md +90 -0
  48. package/package.json +3 -3
  49. package/skills/pmx-canvas/SKILL.md +221 -193
  50. package/skills/pmx-canvas/evals/evals.json +3 -3
  51. package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
  52. package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
  53. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +26 -11
  54. package/src/cli/agent.ts +52 -31
  55. package/src/mcp/canvas-access.ts +30 -830
  56. package/src/mcp/server.ts +162 -2014
  57. package/src/server/ax-context.ts +8 -1
  58. package/src/server/ax-state-manager.ts +826 -0
  59. package/src/server/ax-state.ts +10 -2
  60. package/src/server/canvas-db.ts +35 -0
  61. package/src/server/canvas-operations.ts +2 -328
  62. package/src/server/canvas-schema.ts +2 -2
  63. package/src/server/canvas-state.ts +103 -465
  64. package/src/server/index.ts +54 -190
  65. package/src/server/operations/composites.ts +355 -0
  66. package/src/server/operations/http.ts +103 -0
  67. package/src/server/operations/index.ts +65 -0
  68. package/src/server/operations/invoker.ts +87 -0
  69. package/src/server/operations/mcp.ts +221 -0
  70. package/src/server/operations/ops/annotation.ts +60 -0
  71. package/src/server/operations/ops/app.ts +447 -0
  72. package/src/server/operations/ops/ax-await.ts +216 -0
  73. package/src/server/operations/ops/ax-shared.ts +38 -0
  74. package/src/server/operations/ops/ax-state.ts +249 -0
  75. package/src/server/operations/ops/ax-timeline.ts +381 -0
  76. package/src/server/operations/ops/ax-work.ts +635 -0
  77. package/src/server/operations/ops/batch.ts +365 -0
  78. package/src/server/operations/ops/edges.ts +166 -0
  79. package/src/server/operations/ops/groups.ts +176 -0
  80. package/src/server/operations/ops/json-render.ts +691 -0
  81. package/src/server/operations/ops/nodes.ts +1047 -0
  82. package/src/server/operations/ops/query.ts +281 -0
  83. package/src/server/operations/ops/snapshots.ts +366 -0
  84. package/src/server/operations/ops/validate.ts +37 -0
  85. package/src/server/operations/ops/viewport.ts +219 -0
  86. package/src/server/operations/ops/webview.ts +339 -0
  87. package/src/server/operations/registry.ts +79 -0
  88. package/src/server/operations/types.ts +150 -0
  89. package/src/server/operations/webview-runner.ts +77 -0
  90. package/src/server/server.ts +158 -2255
  91. 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
  {
@@ -0,0 +1,93 @@
1
+ # AX HTML Control Surface — a blessed, copy-paste-safe recipe
2
+
3
+ A canvas `html` node can be a live **AX control surface**: it emits AX interactions
4
+ (create work, request approval, steer the agent, …) and reflects the current AX board
5
+ back, all from inside the sandboxed iframe. The bridge is already injected for you —
6
+ you do **not** need to read `axToken` from the URL or hand-post `pmx-canvas-ax`
7
+ messages. Just opt the node in and use `window.PMX_AX`.
8
+
9
+ ## Opt in
10
+
11
+ ```js
12
+ // MCP / SDK
13
+ canvas_add_html_node({
14
+ title: "AX Control Room",
15
+ html: "<!-- see below -->",
16
+ axCapabilities: { enabled: true, allowed: ["ax.work.create", "ax.steer"] },
17
+ });
18
+ // HTTP: POST /api/canvas/node { type:"html", title, html, axCapabilities } (top-level html + axCapabilities both accepted)
19
+ ```
20
+
21
+ Without `axCapabilities.enabled = true`, `window.PMX_AX` is **not** injected — the node
22
+ renders but can't emit. `allowed` narrows what it may emit (never escalates the type's
23
+ ceiling). Flip an existing node on with `canvas_node({ action: "update", id, axCapabilities: { enabled: true, allowed: [...] } })`.
24
+
25
+ ## Three footguns (this is why a hand-rolled node looks "inert")
26
+
27
+ 1. **The iframe is sandboxed opaque-origin** (no `allow-same-origin`). `localStorage`,
28
+ `sessionStorage`, and `document.cookie` **throw** — and an uncaught throw at script
29
+ start aborts the whole script, so the node renders blank/inert. Keep state in plain
30
+ JS variables (or `window.PMX_AX.state`); if you must touch storage, wrap it in
31
+ `try/catch`.
32
+ 2. **`window.PMX_AX.emit(type, payload)` is async.** It returns a Promise that resolves
33
+ with the result once the canvas acks it. `await` it (or `.then` / `window.PMX_AX.on('ack', cb)`).
34
+ Reading a result synchronously gets you `undefined`.
35
+ 3. **`ax.steer` is recorded, not delivered.** A successful emit (`{ ok: true }`) means
36
+ the steer was **queued** on the timeline — it does **not** wake or notify the active
37
+ agent. A cooperating host adapter must drain the delivery queue and call its native
38
+ send to create a visible turn (host-owned). Otherwise the steer is picked up on the
39
+ human's next turn. Label steering buttons honestly ("Queued for the agent's next turn").
40
+
41
+ ## Drop-in template (work + steer, with ack + live reflection)
42
+
43
+ ```html
44
+ <style>
45
+ body { font: 13px system-ui; margin: 0; padding: 12px; }
46
+ button { cursor: pointer; }
47
+ #s { margin-left: 8px; color: #6b7280; }
48
+ ul { padding-left: 18px; }
49
+ </style>
50
+ <button id="add">+ Work item</button>
51
+ <button id="steer">Steer agent</button>
52
+ <span id="s"></span>
53
+ <ul id="q"></ul>
54
+ <script>
55
+ // In-memory only — NO localStorage/sessionStorage/cookies (sandboxed: they throw).
56
+ const $ = (id) => document.getElementById(id);
57
+ const flash = (msg) => { $('s').textContent = msg; setTimeout(() => { $('s').textContent = ''; }, 1500); };
58
+
59
+ function render(state) {
60
+ const items = (state && state.workItems) || [];
61
+ $('q').innerHTML = items.map((w) => '<li>[' + w.status + '] ' + w.title + '</li>').join('');
62
+ }
63
+
64
+ $('add').onclick = async () => {
65
+ const r = await window.PMX_AX.emit('ax.work.create', { title: 'New task' });
66
+ flash(r && r.ok ? 'queued ✓' : ('failed: ' + (r && (r.error || r.code))));
67
+ };
68
+ $('steer').onclick = async () => {
69
+ const r = await window.PMX_AX.emit('ax.steer', { message: 'Prioritize the auth refactor' });
70
+ // Honest: recorded/queued, NOT delivered to the live agent.
71
+ flash(r && r.ok ? 'queued for next turn ✓' : 'failed');
72
+ };
73
+
74
+ // Reflect: seeded once at load, then live via the pmx-ax-update event.
75
+ render(window.PMX_AX && window.PMX_AX.state);
76
+ window.addEventListener('pmx-ax-update', (e) => render(e.detail));
77
+ </script>
78
+ ```
79
+
80
+ ## API surface (injected by PMX when opted in)
81
+
82
+ - `window.PMX_AX.emit(type, payload) → Promise<{ ok, primitive?, status?, code?, error? }>`
83
+ — `ok:true` on accept; `ok:false` with `code`/`error` on reject; falls back to an
84
+ `ax-ack-timeout` result after 10s so `await` never hangs.
85
+ - `window.PMX_AX.on('ack', (result, interaction) => …)` — also fires a `pmx-ax-ack`
86
+ CustomEvent; use instead of `await` if you prefer a listener.
87
+ - `window.PMX_AX.state` — compact board snapshot `{ focus, workItems, approvalGates,
88
+ reviewAnnotations, elicitations, modeRequests, policy }` (human review text redacted).
89
+ - `pmx-ax-update` window event — fires with the fresh snapshot on every AX change.
90
+
91
+ Allowed `type`s are gated per node capability (see the node-capability matrix in
92
+ `SKILL.md`). Emits are clamped to the surface's own node; the server re-validates every
93
+ interaction — the bridge is convenience, not a trust boundary.
@@ -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,8 +80,6 @@ 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:
@@ -89,31 +88,31 @@ Codex agents should treat PMX AX context as host-native working context:
89
88
  - `canvas://ax-context` combines pins, focus, and surface metadata, plus a compact
90
89
  loop-safe `delivery: { pendingSteering, pendingActivity }` lead block
91
90
  (`GET /api/canvas/ax/context?consumer=codex` filters out Codex-originated items).
92
- - `canvas_get_ax` returns both persisted AX state and agent-ready context.
91
+ - `canvas_ax_state { action: "get" }` returns both persisted AX state and agent-ready context.
93
92
  - Focus is a current attention target, not a command to ignore the rest of the repository.
94
93
 
95
94
  The adapterless MCP+Browser path is poll-based: there is no automatic prompt injection,
96
95
  so a board click does not wake the current turn. Codex agents poll
97
- `canvas_claim_ax_delivery` (steering + `pendingActivity`) and act/ack explicitly. The
96
+ `canvas_ax_delivery { action: "claim" }` (steering + `pendingActivity`) and act/ack explicitly. The
98
97
  loop-closing surfaces work over MCP today even without a dedicated extension:
99
98
 
100
99
  - **Self-report work** with `canvas_ingest_activity` (the board auto-reacts: a failed
101
100
  tool → a blocked work item + review + evidence). Automatic forwarding of Codex's own
102
101
  tool hooks would need a Codex adapter; manual ingestion works now.
103
- - **Block on a decision** with `canvas_await_approval` / `canvas_await_elicitation` /
104
- `canvas_await_mode` (they long-poll PMX until the human resolves the gate in the
105
- Browser or the timeout elapses) instead of looping on `canvas_get_ax`.
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" }`.
106
105
 
107
106
  ## Live-Test Checklist
108
107
 
109
108
  1. Open `http://127.0.0.1:4313/workbench` in the Codex in-app Browser first so the user can see
110
109
  all later canvas mutations.
111
110
  2. Confirm the PMX MCP server is configured for the workspace.
112
- 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`.
113
112
  4. Add or reuse a node, then pin it from the browser or with `canvas_pin_nodes`.
114
113
  5. Read `canvas://ax-context` and confirm the pinned node appears.
115
- 6. Call `canvas_set_ax_focus` with `source: "codex"` and a real node ID.
116
- 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`.
117
116
  8. Refresh the browser and confirm the workbench still shows the same state.
118
117
 
119
118
  ## Adapter Boundary
@@ -52,20 +52,36 @@ panel.
52
52
  ### Agent behavior — steering is gated, not pushed
53
53
 
54
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:
55
+ items, approval gates, and the compact `delivery` lead block) as hidden context — but
56
+ only when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
57
+ and it is clipped to a char budget. Read steering from **`delivery.pendingSteering`**
58
+ (the compact, count-bearing block — newest-first, capped at 10), not the full
59
+ `timeline.pendingSteering`. Three consequences the adapter/agent must honor:
58
60
 
59
61
  1. A steering board must **stay pinned** (or its button must also emit `ax.focus.set`
60
62
  on the board node) to hold the gate open.
61
63
  2. A sandbox button click does **not** wake a turn — a human message does. The click
62
64
  only enqueues the steer.
63
- 3. The agent must **act on injected `pendingSteering` / `pendingActivity` and then ack**
64
- (`canvas_mark_ax_delivery`), or it re-injects every gated turn.
65
+ 3. The agent must **act on injected `delivery.pendingSteering` / `pendingActivity` and
66
+ then ack** (`canvas_ax_delivery { action: "mark" }`), or it re-injects every gated turn.
65
67
 
66
- To be robust to the char clip, prefer injecting the compact loop-safe lead block from
68
+ To be robust to the char clip, prefer injecting that compact loop-safe lead block from
67
69
  `GET /api/canvas/ax/context?consumer=copilot` (`delivery.pendingSteering` +
68
- `delivery.pendingActivity`) **above** the full dump.
70
+ `delivery.totalPending` / `delivery.omittedPending` + `delivery.pendingActivity`)
71
+ **above** the full dump. When `omittedPending > 0`, drain the full FIFO backlog from
72
+ `canvas_ax_delivery { action: "claim", consumer: "copilot" }` (oldest-first).
73
+
74
+ #### Waking the agent from a canvas steer (#59) — adapter-owned
75
+
76
+ Recording a browser-origin `ax.steer` does **not** wake the active session by itself
77
+ (report #59); PMX only queues it (the `ok:true` emit ack = "recorded", not "delivered").
78
+ To make a canvas **Steer** button actually create a visible turn, the adapter must, on
79
+ its own cadence (e.g. an SSE subscription or poll), **drain**
80
+ `canvas_ax_delivery { action: "claim", consumer: "copilot" }`, call the host's native
81
+ send (`copilotSession.send` / the working `send_instruction` path) with each steer, then
82
+ `canvas_ax_delivery { action: "mark" }` it (loop-safe). This wake is intentionally
83
+ host-owned — PMX never imports the host SDK. Until the adapter wires it, a steering
84
+ button must be labeled "queued for the agent's next turn", not "steer now".
69
85
 
70
86
  ### Closing the loop (optional, recommended)
71
87
 
@@ -74,9 +90,8 @@ To be robust to the char clip, prefer injecting the compact loop-safe lead block
74
90
  `POST /api/canvas/ax/activity` (`canvas_ingest_activity`) so the board reflects the
75
91
  agent's real work automatically (a failed tool → a blocked work item + review +
76
92
  evidence).
77
- - **Await gates** with `canvas_await_approval` / `canvas_await_elicitation` /
78
- `canvas_await_mode` (or surface a native modal and await the PMX result) so an
79
- approval gate actually blocks the agent until the human resolves it.
93
+ - **Await gates** with `canvas_ax_gate { kind, action: "await", id }` (or surface a native modal
94
+ and await the PMX result) so an approval gate actually blocks the agent until the human resolves it.
80
95
 
81
96
  See [`docs/ax-host-adapter-contract.md`](../../../docs/ax-host-adapter-contract.md).
82
97
  - Keeps all persistent PMX state in `.pmx-canvas/canvas.db`; the extension does not own canvas
@@ -158,5 +173,5 @@ global instruction to ignore the rest of the repository.
158
173
  For non-Copilot agents, use the same core primitives directly:
159
174
 
160
175
  - HTTP: `/api/canvas/ax`, `/api/canvas/ax/context`, `/api/canvas/ax/focus`
161
- - MCP: `canvas://ax`, `canvas://ax-context`, `canvas_get_ax`, `canvas_set_ax_focus`
176
+ - MCP: `canvas://ax`, `canvas://ax-context`, `canvas_ax_state { action: "get" }`, `canvas_ax_state { action: "set-focus" }`
162
177
  - 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