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.
- package/CHANGELOG.md +447 -0
- package/Readme.md +2 -2
- package/dist/json-render/index.js +89 -334
- package/dist/types/mcp/canvas-access.d.ts +5 -171
- package/dist/types/server/ax-state-manager.d.ts +267 -0
- package/dist/types/server/ax-state.d.ts +3 -1
- package/dist/types/server/canvas-db.d.ts +13 -0
- package/dist/types/server/canvas-operations.d.ts +1 -12
- package/dist/types/server/canvas-state.d.ts +8 -23
- package/dist/types/server/index.d.ts +6 -24
- package/dist/types/server/operations/composites.d.ts +121 -0
- package/dist/types/server/operations/http.d.ts +7 -0
- package/dist/types/server/operations/index.d.ts +8 -0
- package/dist/types/server/operations/invoker.d.ts +13 -0
- package/dist/types/server/operations/mcp.d.ts +15 -0
- package/dist/types/server/operations/ops/annotation.d.ts +2 -0
- package/dist/types/server/operations/ops/app.d.ts +33 -0
- package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
- package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +19 -0
- package/dist/types/server/operations/ops/edges.d.ts +2 -0
- package/dist/types/server/operations/ops/groups.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +31 -0
- package/dist/types/server/operations/ops/nodes.d.ts +62 -0
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
- package/dist/types/server/operations/ops/validate.d.ts +2 -0
- package/dist/types/server/operations/ops/viewport.d.ts +2 -0
- package/dist/types/server/operations/ops/webview.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +15 -0
- package/dist/types/server/operations/types.d.ts +116 -0
- package/dist/types/server/operations/webview-runner.d.ts +69 -0
- package/docs/RELEASE.md +5 -0
- package/docs/adr-001-bun-only-runtime.md +46 -0
- package/docs/api-stability.md +57 -0
- package/docs/ax-host-adapter-contract.md +19 -1
- package/docs/ax-state-contract.md +72 -0
- package/docs/http-api.md +4 -0
- package/docs/mcp.md +61 -12
- package/docs/plans/plan-005-operation-registry.md +84 -0
- package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
- package/docs/plans/plan-007-ax-domain.md +99 -0
- package/docs/plans/plan-008-registry-finish.md +91 -0
- package/docs/tech-debt-assessment-2026-06.md +90 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +221 -193
- package/skills/pmx-canvas/evals/evals.json +3 -3
- package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
- package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +26 -11
- package/src/cli/agent.ts +52 -31
- package/src/mcp/canvas-access.ts +30 -830
- package/src/mcp/server.ts +162 -2014
- package/src/server/ax-context.ts +8 -1
- package/src/server/ax-state-manager.ts +826 -0
- package/src/server/ax-state.ts +10 -2
- package/src/server/canvas-db.ts +35 -0
- package/src/server/canvas-operations.ts +2 -328
- package/src/server/canvas-schema.ts +2 -2
- package/src/server/canvas-state.ts +103 -465
- package/src/server/index.ts +54 -190
- package/src/server/operations/composites.ts +355 -0
- package/src/server/operations/http.ts +103 -0
- package/src/server/operations/index.ts +65 -0
- package/src/server/operations/invoker.ts +87 -0
- package/src/server/operations/mcp.ts +221 -0
- package/src/server/operations/ops/annotation.ts +60 -0
- package/src/server/operations/ops/app.ts +447 -0
- package/src/server/operations/ops/ax-await.ts +216 -0
- package/src/server/operations/ops/ax-shared.ts +38 -0
- package/src/server/operations/ops/ax-state.ts +249 -0
- package/src/server/operations/ops/ax-timeline.ts +381 -0
- package/src/server/operations/ops/ax-work.ts +635 -0
- package/src/server/operations/ops/batch.ts +365 -0
- package/src/server/operations/ops/edges.ts +166 -0
- package/src/server/operations/ops/groups.ts +176 -0
- package/src/server/operations/ops/json-render.ts +691 -0
- package/src/server/operations/ops/nodes.ts +1047 -0
- package/src/server/operations/ops/query.ts +281 -0
- package/src/server/operations/ops/snapshots.ts +366 -0
- package/src/server/operations/ops/validate.ts +37 -0
- package/src/server/operations/ops/viewport.ts +219 -0
- package/src/server/operations/ops/webview.ts +339 -0
- package/src/server/operations/registry.ts +79 -0
- package/src/server/operations/types.ts +150 -0
- package/src/server/operations/webview-runner.ts +77 -0
- package/src/server/server.ts +158 -2255
- 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
|
|
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
|
|
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
|
|
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 `
|
|
25
|
-
- Sets AX focus through `
|
|
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
|
-
- `
|
|
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
|
-
`
|
|
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 `
|
|
104
|
-
|
|
105
|
-
|
|
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 `
|
|
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 `
|
|
116
|
-
7. Read `
|
|
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 `
|
|
56
|
-
when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
|
|
57
|
-
and it is clipped to a char budget.
|
|
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
|
|
64
|
-
(`
|
|
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
|
|
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.
|
|
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 `
|
|
78
|
-
|
|
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`, `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2463
|
+
const result = await invokeOperation('snapshot.diff', { id: snapshot });
|
|
2443
2464
|
output(result);
|
|
2444
2465
|
}
|
|
2445
2466
|
|