pmx-canvas 0.3.4 → 0.4.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 (51) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/Readme.md +16 -3
  3. package/dist/canvas/index.js +80 -80
  4. package/dist/types/client/index.d.ts +1 -1
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  6. package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
  7. package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
  8. package/dist/types/client/state/canvas-store.d.ts +1 -0
  9. package/dist/types/client/state/iframe-mode.d.ts +25 -0
  10. package/dist/types/client/state/node-factory.d.ts +29 -0
  11. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  12. package/dist/types/server/canvas-db.d.ts +0 -1
  13. package/dist/types/server/canvas-state.d.ts +14 -15
  14. package/dist/types/server/operations/composites.d.ts +6 -21
  15. package/dist/types/server/server.d.ts +1 -0
  16. package/docs/RELEASE.md +10 -4
  17. package/docs/cli.md +5 -0
  18. package/docs/environment.md +2 -2
  19. package/docs/http-api.md +47 -1
  20. package/docs/mcp.md +21 -30
  21. package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
  22. package/docs/screenshot.png +0 -0
  23. package/docs/tech-debt-assessment-2026-06.md +5 -5
  24. package/package.json +7 -7
  25. package/skills/pmx-canvas/SKILL.md +36 -10
  26. package/skills/pmx-canvas/evals/evals.json +2 -2
  27. package/skills/pmx-canvas/references/full-reference.md +17 -15
  28. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  29. package/src/cli/commands/history.ts +1 -1
  30. package/src/cli/commands/nodes.ts +31 -1
  31. package/src/cli/index.ts +8 -2
  32. package/src/cli/shared.ts +1 -0
  33. package/src/client/index.tsx +7 -0
  34. package/src/client/nodes/ExtAppFrame.tsx +50 -2
  35. package/src/client/nodes/HtmlNode.tsx +5 -1
  36. package/src/client/nodes/McpAppNode.tsx +5 -1
  37. package/src/client/nodes/iframe-document-url.ts +62 -9
  38. package/src/client/nodes/use-surface-frame.ts +53 -0
  39. package/src/client/state/canvas-store.ts +5 -0
  40. package/src/client/state/iframe-mode.ts +103 -0
  41. package/src/client/state/node-factory.ts +72 -0
  42. package/src/client/state/sse-bridge.ts +212 -86
  43. package/src/client/uuid-polyfill.ts +14 -0
  44. package/src/server/canvas-db.ts +0 -8
  45. package/src/server/canvas-schema.ts +2 -8
  46. package/src/server/canvas-state.ts +51 -151
  47. package/src/server/index.ts +3 -9
  48. package/src/server/operations/composites.ts +24 -21
  49. package/src/server/operations/ops/snapshots.ts +6 -7
  50. package/src/server/operations/registry.ts +4 -1
  51. package/src/server/server.ts +194 -75
@@ -14,11 +14,11 @@ Code quality is better than the release velocity would suggest: zero `as any` ac
14
14
 
15
15
  Every operation is hand-written four times — `CanvasStateManager` → `PmxCanvas` → HTTP handler in `server.ts` → MCP tool in `src/mcp/server.ts` — each with its own validation and error shapes. The CLI (`src/cli/agent.ts`, 3,300 lines) duplicates it a fifth time with raw fetch calls instead of using the SDK.
16
16
 
17
- Evidence from `.learnings/` that this is actively producing bugs:
17
+ Evidence from `.learnings/` that this is actively producing bugs (the `.learnings/` tree is gitignored, so the substance of each cited entry is inlined here):
18
18
 
19
- - [LRN-20260606-006] Fix #32 applied to only one of two duplicated mutation paths (`updateNode()` vs `applyUpdates()`).
20
- - [LRN-20260607-005] New `json-render` sourceSurface enum member silently took the permissive default because the `scoped` guard in `applyAxInteraction` was not updated. Security near-miss.
21
- - [LRN-20260608-002] `readJson` hardening silently killed the documented bare-array shape of `POST /api/canvas/batch` (#49).
19
+ - [LRN-20260606-006] Fix #32 applied to only one of two duplicated mutation paths (`updateNode()` vs `applyUpdates()`). Both paths carried an identical copy of the group repack-on-resize logic, so fixing only the batch path left single-node `PATCH` updates still repacking siblings. The same entry records the parallel serializer pair drifting: the `nodeId` response alias was added to the MCP `createdNodePayload` but not the HTTP `buildNodeResponse`, so CLI/HTTP `node add --json` lacked it (#31).
20
+ - [LRN-20260607-005] New `json-render` sourceSurface enum member silently took the permissive default because the `scoped` guard in `applyAxInteraction` was not updated. Security near-miss: the sandboxed viewer forwards author-controlled spec action params verbatim, so a prompt-injected spec could have anchored AX state onto arbitrary nodes via caller-supplied `nodeIds` (`on.press → { action: 'ax.work.create', params: { nodeIds: ['victim'] } }`). The fix added `json-render` to the clamp; the durable rule is that any guard switching on an enum-like string is a mandatory co-change site for every new member, or the new member takes the permissive default.
21
+ - [LRN-20260608-002] `readJson` hardening silently killed the documented bare-array shape of `POST /api/canvas/batch` (#49). The #47 hardening normalized every non-object body — including top-level arrays — to `{}`, which turned the handler's `Array.isArray(body)` branch into dead code: bare-array batches returned `{ ok: true, results: [] }` and created nothing. Fixed with an array-preserving reader (`readJsonObjectOrArray`) used only by the batch endpoint.
22
22
 
23
23
  These are not three bugs. They are one architecture failing three times.
24
24
 
@@ -36,7 +36,7 @@ A project whose thesis is agent experience ships a tool surface that consumes a
36
36
 
37
37
  ### 4. E2E is not a CI gate (high)
38
38
 
39
- The Playwright/Bun ESM loader blocker ([ERR-20260508-001]) has been open for weeks. E2E was removed from the publish workflow after the apt-mirror hang ([LRN-20260603-002]) and does not gate PRs. The bugs that matter (iframe blank flicker, literal `\n` in ledger, SVG calc()) were all caught only by browser tests. Green CI can currently ship a broken canvas.
39
+ The Playwright/Bun ESM loader blocker ([ERR-20260508-001]: `bun x playwright test` dies before test discovery because Playwright's transform layer tries to import a nonexistent `playwright.config.ts.esm.preflight` module under Bun; browser tests only run when Playwright is launched through Node with Bun on `PATH`) has been open for weeks. E2E was removed from the publish workflow after the apt-mirror hang ([LRN-20260603-001]: the `playwright install --with-deps chromium` step began reliably hanging ~15 minutes on GitHub runners with zero code change — a runner-image/apt regression — so publish dropped its redundant browser steps and kept build + typecheck + unit + smoke) and does not gate PRs. The bugs that matter (iframe blank flicker, literal `\n` in ledger, SVG calc()) were all caught only by browser tests. Green CI can currently ship a broken canvas.
40
40
 
41
41
  **Fix:** run Playwright via Node's runner in CI permanently, and make headless e2e a hard PR gate.
42
42
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.3.4",
4
- "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
3
+ "version": "0.4.1",
4
+ "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
7
7
  "module": "./src/server/index.ts",
@@ -43,10 +43,10 @@
43
43
  "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
44
44
  "lint": "biome check .",
45
45
  "lint:fix": "biome check --write .",
46
- "test": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
47
- "test:unit": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
46
+ "test": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
47
+ "test:unit": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
48
48
  "test:client": "bun test --preload ./tests/client/setup.ts tests/client",
49
- "test:coverage": "bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage && bun scripts/check-coverage.ts",
49
+ "test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage && bun scripts/check-coverage.ts",
50
50
  "test:web-canvas": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun run build && PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bash scripts/run-playwright.sh",
51
51
  "test:e2e": "bun run test:web-canvas",
52
52
  "test:e2e-cli": "bash scripts/e2e-cli-coverage.sh",
@@ -60,8 +60,6 @@
60
60
  "dependencies": {
61
61
  "@joplin/turndown-plugin-gfm": "^1.0.64",
62
62
  "@json-render/core": "0.19.0",
63
- "@json-render/devtools": "0.19.0",
64
- "@json-render/devtools-react": "0.19.0",
65
63
  "@json-render/directives": "0.19.0",
66
64
  "@json-render/mcp": "0.19.0",
67
65
  "@json-render/react": "0.19.0",
@@ -80,6 +78,8 @@
80
78
  "devDependencies": {
81
79
  "@biomejs/biome": "^2.5.2",
82
80
  "@happy-dom/global-registrator": "^20.10.6",
81
+ "@json-render/devtools": "0.19.0",
82
+ "@json-render/devtools-react": "0.19.0",
83
83
  "@playwright/test": "^1.58.2",
84
84
  "@tailwindcss/cli": "^4.1.18",
85
85
  "@testing-library/preact": "^3.2.4",
@@ -26,8 +26,8 @@ Humans curate agent context by pinning nodes; agents read that curation through
26
26
  3. **Read before write.** Search with `canvas_query { action: "search", query }` before creating
27
27
  nodes. Read the full layout only when necessary. The MCP parameter is `query` — passing the
28
28
  HTTP API's `q` is silently ignored and returns zero results.
29
- 4. **Snapshot before destructive changes.** Use `canvas_snapshot` (deprecated standalone; folds into
30
- the `canvas_snapshot` composite's `save` action in v0.4) before clear, restore, or a major
29
+ 4. **Snapshot before destructive changes.** Use `canvas_snapshot { action: "save", name }` before
30
+ clear, restore, or a major
31
31
  reorganization.
32
32
  5. **Show intent with the Ghost Cursor — by default.** Signal with
33
33
  `canvas_intent { action: "signal", ... }` before every meaningful create, move, connect, remove,
@@ -36,7 +36,7 @@ Humans curate agent context by pinning nodes; agents read that curation through
36
36
  human watches intent form and can veto mid-thought. Skip it only for trivial in-place tweaks or
37
37
  high-frequency batch churn. The default TTL (~8s) expires between agent turns: signal with
38
38
  `ttlMs: 30000` and settle by passing `intentId` on the mutation in the same or next call.
39
- 6. **Mutate through current composites.** Prefer the 15 composite MCP tools below.
39
+ 6. **Mutate through current composites.** Prefer the 16 composite MCP tools below.
40
40
  7. **Arrange and validate.** After batch changes, use `canvas_view { action: "arrange" }` when
41
41
  appropriate and always finish with `canvas_query { action: "validate" }`.
42
42
  8. **Verify context pins.** Pin with `canvas_pin_nodes` or the browser's **Pin as context**, then
@@ -115,6 +115,7 @@ json-render, a graph, or an HTML primitive is sufficient.
115
115
  | `canvas_ax_gate` | `request`, `resolve`, `await` with `approval`, `elicitation`, or `mode` |
116
116
  | `canvas_ax_timeline` | `read`, `record-event`, `add-evidence`, `send-steering` |
117
117
  | `canvas_ax_delivery` | `claim`, `mark` |
118
+ | `canvas_snapshot` | `save`, `list`, `restore`, `delete`, `gc`, `diff` |
118
119
  | `canvas_intent` | `signal`, `update`, `clear` |
119
120
 
120
121
  Important routing:
@@ -131,9 +132,9 @@ Important routing:
131
132
  As of v0.3.0, the 57 legacy single-purpose tools from the v0.2 compatibility window are removed.
132
133
  The composites above plus the retained standalones are now the whole MCP surface: `canvas_batch`,
133
134
  `canvas_pin_nodes`, `canvas_screenshot`, `canvas_ax_interaction`, `canvas_ingest_activity`, and
134
- `canvas_invoke_command`. The 6 snapshot tools (`canvas_snapshot`, `canvas_list_snapshots`,
135
- `canvas_restore`, `canvas_delete_snapshot`, `canvas_gc_snapshots`, `canvas_diff`) are also retained
136
- but deprecated, pending a `canvas_snapshot` composite in v0.4.
135
+ `canvas_invoke_command`. Snapshots are the `canvas_snapshot` composite (actions
136
+ `save | list | restore | delete | gc | diff`); the 6 legacy snapshot standalones were removed in
137
+ v0.4.0 after their deprecated 0.3.x window.
137
138
 
138
139
  ## Spatial Rules
139
140
 
@@ -222,16 +223,41 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
222
223
  time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
223
224
  hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed — the 0.3.2
224
225
  Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
225
- success is not observable from page JS, so retries are bounded). Keep the cautionary behavior:
226
+ success is not observable from page JS, so retries are bounded). Since 0.4.1 the canvas also
227
+ fires a post-boot **repaint nudge** under WebKit (a 1px layout bounce that forces the
228
+ compositor to re-rasterize the frame — the cheap half of the proven enlarge-close unstick;
229
+ shows as `repaint-nudge` in the recovery trail). Keep the cautionary behavior anyway:
226
230
  recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
227
231
  the fullscreen overlay, which always paints), or open the workbench in a normal browser
228
232
  (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
229
233
  fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
230
234
  what the recovery queue actually did in that panel.
235
+ - Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
236
+ while a panel stays open leaves ext-app tiles on dead frame URLs (`Frame document not found`,
237
+ 0.4.0 report Finding S) until a full workbench reload. Since 0.4.1 the browser revalidates its
238
+ frame documents on every reconnect and re-mints them against the new process automatically —
239
+ if a post-restart tile still looks blank on an older install, reload the workbench page.
240
+ - A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
241
+ upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
242
+ This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
243
+ background override ships but cannot reach the app's inner root). Keep ext-app tiles near the
244
+ app's aspect (the 960x738 default, or the same ratio), or expand the node — the fullscreen
245
+ overlay renders full-bleed. The durable fix is upstream in the excalidraw-mcp app.
246
+ - Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
247
+ back from SSE to a polling transport within ~3s, so the board still boots and stays live.
248
+ Force a mode with `/workbench?transport=poll` (or `transport=sse`) when diagnosing.
249
+ - Nested-iframe embeds (e.g. the Amp orb portal renders the canvas page inside an ampcode.com
250
+ iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
251
+ with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
252
+ same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
253
+ documents). External app URLs cannot be inlined (cross-origin) and may stay blocked in such
254
+ hosts. Force a mode with `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when
255
+ diagnosing.
231
256
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
232
- reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
233
- Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
234
- stale until reload — use a system browser for separate full-page viewing.
257
+ reflow on a live window resize in a normal browser. Some single-tab host browsers historically
258
+ didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
259
+ graph reflow on a native viewport resize), so if a resized standalone chart looks stale,
260
+ reload it or use a system browser for separate full-page viewing.
235
261
  - Some hosts cannot automate inside sandboxed workbench iframes. Verify those interactions in a
236
262
  system browser or through server-side AX state.
237
263
  - `pmx-canvas screenshot` requires an active WebView. Start it with
@@ -172,7 +172,7 @@
172
172
  "id": 7,
173
173
  "name": "snapshot-before-changes",
174
174
  "prompt": "I want to reorganize the canvas to explore a different approach, but I don't want to lose what's there now. Can you save the current state, clear it, and set up a fresh workspace for the new approach? If it doesn't work out I want to go back.",
175
- "expected_output": "Takes a named snapshot of current state, then clears the canvas. Mentions that canvas_restore can bring back the old state. Does NOT just clear without snapshotting.",
175
+ "expected_output": "Takes a named snapshot of current state, then clears the canvas. Mentions that canvas_snapshot { action: \"restore\" } can bring back the old state. Does NOT just clear without snapshotting.",
176
176
  "assertions": [
177
177
  {
178
178
  "name": "takes-snapshot",
@@ -186,7 +186,7 @@
186
186
  },
187
187
  {
188
188
  "name": "mentions-restore",
189
- "description": "Tells the user how to restore (canvas_restore) if they want to go back",
189
+ "description": "Tells the user how to restore (canvas_snapshot action restore) if they want to go back",
190
190
  "type": "output_check"
191
191
  }
192
192
  ]
@@ -73,7 +73,7 @@ section below.
73
73
  3. **Read before write** — `canvas_query { action: "search", query }` (or `pmx-canvas search`)
74
74
  to find existing nodes; avoid duplicates. Use `canvas_query { action: "layout" }` only when
75
75
  you need the full board.
76
- 4. **Snapshot before destructive work** — `canvas_snapshot { name }` before clear/major
76
+ 4. **Snapshot before destructive work** — `canvas_snapshot { action: "save", name }` before clear/major
77
77
  reorg; restore if needed.
78
78
  5. **Signal then mutate (default behavior)** — signal with `canvas_intent { action: "signal", … }`
79
79
  to telegraph the move before nearly every mutation, then create with the right composite: `canvas_node` (markdown/status/file/webpage/html
@@ -315,6 +315,7 @@ pmx-canvas spatial
315
315
  - `group create|add|remove` — manage groups
316
316
  - `clear --yes` — destructive clear with explicit confirmation
317
317
  - `validate spec` — validate json-render specs and graph payloads without creating nodes
318
+ - `node update` — returns a compact `{ ok, id, nodeId, position, size, updatedFields }` envelope by default; pass `--full` for the complete node payload. Full ext-app/mcp-app payloads embed the bundled app HTML and tool results (hundreds of KB — a no-op ext-app resize once returned ~940 KB), so in agent turns keep the default envelope and read content via `node get --summary`
318
319
  - `web-artifact build` — build bundled React/Tailwind HTML artifacts; use `--deps` for extra packages and `--include-logs` only when raw logs are useful. The build pipeline runs bash scripts (POSIX-only) — it is not supported on native Windows; already-built artifacts render everywhere
319
320
  - `external-app add --kind excalidraw` — create the hosted Excalidraw preset; response includes `id` and `nodeId` aliases for the same canvas node
320
321
  - `serve status|stop` — inspect and stop daemonized servers started with `serve --daemon`
@@ -429,7 +430,7 @@ single-purpose tools behind an `action` (and, for `canvas_ax_gate`, a `kind`) di
429
430
  > registered — use the composites instead. The authoritative legacy→composite mapping table lives
430
431
  > in [`docs/mcp.md`](../../../docs/mcp.md) — this skill does not re-enumerate the removed names.
431
432
 
432
- ### The 15 composites
433
+ ### The 16 composites
433
434
 
434
435
  | Composite | `action` values | What it does |
435
436
  |-----------|-----------------|--------------|
@@ -438,6 +439,7 @@ single-purpose tools behind an `action` (and, for `canvas_ax_gate`, a `kind`) di
438
439
  | `canvas_edge` | `add` · `remove` | Connect / disconnect nodes |
439
440
  | `canvas_group` | `create` · `add` · `ungroup` | Manage spatial group containers |
440
441
  | `canvas_history` | `undo` · `redo` | Time travel through the mutation ring buffer |
442
+ | `canvas_snapshot` | `save` · `list` · `restore` · `delete` · `gc` · `diff` | Named snapshots: save/list/restore/delete, garbage-collect old ones, diff current canvas vs a snapshot (`diff` takes `snapshot`, not `id`) |
441
443
  | `canvas_view` | `arrange` · `focus` · `fit` · `clear` · `remove-annotation` | Auto-arrange, pan-to-node, fit viewport, clear the board, delete a human-drawn annotation by id |
442
444
  | `canvas_query` | `search` · `layout` · `validate` | Find nodes by keyword, read full layout, or **`validate`** the board for node collisions / group-containment / dangling edges |
443
445
  | `canvas_app` | `open-mcp-app` · `diagram` · `build-artifact` | Hosted MCP apps, the Excalidraw diagram preset, and bundled web artifacts (folds `canvas_open_mcp_app` / `canvas_add_diagram` / `canvas_build_web_artifact`) |
@@ -505,10 +507,9 @@ investigation board" intent, then run the batch with that linked `intentId` (use
505
507
 
506
508
  These stay separate by design (trust-boundary, firehose, execution-intent, or not-yet-consolidated
507
509
  surfaces): `canvas_batch`, `canvas_pin_nodes`, `canvas_screenshot`, `canvas_ax_interaction`,
508
- `canvas_ingest_activity`, `canvas_invoke_command`, and the snapshot tools (`canvas_snapshot`,
509
- `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`, `canvas_gc_snapshots`,
510
- `canvas_diff` — deprecated pending a `canvas_snapshot` composite in v0.4; the name collides with
511
- the current save-snapshot tool, so the composite cannot land additively).
510
+ `canvas_ingest_activity`, `canvas_invoke_command`. Snapshots moved into the `canvas_snapshot`
511
+ composite in v0.4.0 (actions `save | list | restore | delete | gc | diff`); the 6 legacy snapshot
512
+ standalones were removed after their deprecated 0.3.x window.
512
513
 
513
514
  `canvas_batch` supports exactly these ops: `node.add`, `node.update`, `node.remove`, `graph.add`,
514
515
  `edge.add`, `edge.remove`, `group.create`, `group.add`, `group.remove`, `pin.set`/`pin.add`/
@@ -650,7 +651,7 @@ ID extraction for mixed tool responses:
650
651
 
651
652
  **`canvas_pin_nodes`** (standalone) — Set, add, or remove pinned context nodes. Use `{ nodeIds: [...] }` — the field is `nodeIds`, not `ids`.
652
653
 
653
- **`canvas_diff`** (standalone) — Compare current canvas state with a saved snapshot. Requires `{ snapshot: "<snapshot-id-or-name>" }`; there is no implicit previous-snapshot default.
654
+ **`canvas_snapshot { action: "diff" }`** — Compare current canvas state with a saved snapshot. Requires `{ snapshot: "<snapshot-id-or-name>" }`; there is no implicit previous-snapshot default.
654
655
 
655
656
  **`canvas_render { action: "describe-schema" }`** — Inspect the running server's create schemas and
656
657
  canonical examples. Use before generating structured payloads when you need the authoritative current
@@ -766,13 +767,14 @@ ranked matches with content snippets. Use instead of parsing the full layout to
766
767
  ### History & Snapshots
767
768
 
768
769
  - **`canvas_history { action: "undo" }`** / **`canvas_history { action: "redo" }`** — step the mutation ring buffer.
769
- - **`canvas_snapshot`** (standalone) — save a named snapshot; `name` required. Returns `{ ok, id, snapshot }` (flat `id` aliases `snapshot.id`).
770
- - **`canvas_restore`** (standalone) — restore from a snapshot `id`.
771
- - **`canvas_diff`** (standalone) — compare current canvas against a saved snapshot (added/removed/modified nodes & edges).
770
+ - **`canvas_snapshot { action: "save", name }`** — save a named snapshot. Returns `{ ok, id, snapshot }` (flat `id` aliases `snapshot.id`).
771
+ - **`canvas_snapshot { action: "list" }`** / **`{ action: "gc", keep }`** / **`{ action: "delete", id }`** — manage saved snapshots.
772
+ - **`canvas_snapshot { action: "restore", id }`** — restore from a snapshot.
773
+ - **`canvas_snapshot { action: "diff", snapshot }`** — compare current canvas against a saved snapshot (added/removed/modified nodes & edges). Note the field is `snapshot` (id or name), not `id`.
772
774
 
773
775
  ### Canvas Management
774
776
 
775
- **`canvas_view { action: "clear" }`** — Remove all nodes and edges. **Always `canvas_snapshot` first** —
777
+ **`canvas_view { action: "clear" }`** — Remove all nodes and edges. **Always `canvas_snapshot { action: "save" }` first** —
776
778
  this is irreversible without a prior snapshot.
777
779
 
778
780
  ### Browser Automation (WebView)
@@ -1405,19 +1407,19 @@ Monitor ongoing processes:
1405
1407
 
1406
1408
  Show two states side by side for the human to compare:
1407
1409
 
1408
- 1. Take a snapshot before changes: `canvas_snapshot` with name "before-X"
1410
+ 1. Take a snapshot before changes: `canvas_snapshot { action: "save" }` with name "before-X"
1409
1411
  2. Make changes to the canvas
1410
- 3. Use `canvas_diff` to show what changed
1412
+ 3. Use `canvas_snapshot { action: "diff", snapshot }` to show what changed
1411
1413
  4. Or: create two groups ("Before" and "After") with corresponding nodes
1412
1414
 
1413
1415
  ### Save and Start Fresh
1414
1416
 
1415
1417
  When the human wants to explore a different approach without losing current work:
1416
1418
 
1417
- 1. **First**, save the current state: `canvas_snapshot` with a descriptive name
1419
+ 1. **First**, save the current state: `canvas_snapshot { action: "save" }` with a descriptive name
1418
1420
  2. **Then** clear: `canvas_view { action: "clear" }` (never clear without snapshotting first)
1419
1421
  3. Set up the new workspace with initial nodes
1420
- 4. Tell the human the snapshot name and that `canvas_restore` can bring everything back
1422
+ 4. Tell the human the snapshot name and that `canvas_snapshot { action: "restore" }` can bring everything back
1421
1423
 
1422
1424
  ## Best Practices
1423
1425
 
@@ -71,6 +71,9 @@ Prefer extending the existing suites before inventing a one-off script.
71
71
  - Avoid brittle selectors. Prefer stable text, roles, titles, or deliberate component hooks
72
72
  - If a change spans server and client, add at least one server-side assertion and one browser or
73
73
  API-level proof
74
+ - MCP harnesses using the official SDK must wind down BOTH halves: call `client.close()` AND
75
+ `transport.close()` on the `StdioClientTransport`. Closing only the client leaves the spawned
76
+ stdio server process alive after the test exits (0.4.0 cycle finding)
74
77
 
75
78
  ## Layout And Embedded Content Checks
76
79
 
@@ -193,7 +193,7 @@ cmd('snapshot delete', 'Delete a saved snapshot', ['pmx-canvas snapshot delete <
193
193
  });
194
194
 
195
195
  async function runSnapshotDiff(args: string[]): Promise<void> {
196
- const { positional, flags } = parseFlags(args);
196
+ const { positional } = parseFlags(args);
197
197
  const snapshot = positional[0];
198
198
  if (!snapshot) die('Missing snapshot ID or name', 'pmx-canvas snapshot diff <snapshot-id-or-name>');
199
199
  const result = await invokeOperation('snapshot.diff', { id: snapshot });
@@ -830,6 +830,7 @@ cmd(
830
830
  'pmx-canvas node update <node-id> --dock-position right',
831
831
  'pmx-canvas node update <node-id> --dock-position none # undock back to the canvas',
832
832
  'pmx-canvas node update <node-id> --lock-arrange',
833
+ 'pmx-canvas node update <node-id> --width 840 --full # full node payload instead of the compact envelope',
833
834
  ],
834
835
  async (args) => {
835
836
  const { positional, flags } = parseFlags(args);
@@ -932,7 +933,36 @@ cmd(
932
933
  }
933
934
 
934
935
  const result = await invokeOperation('node.update', { id, ...body });
935
- output(result);
936
+ if (flags.full || flags.verbose) {
937
+ output(result);
938
+ return;
939
+ }
940
+ // Finding R (0.3.4 report): the raw update result echoes the FULL node —
941
+ // ~940 KB for an ext-app (bundled html + tool result) — which can consume a
942
+ // large share of an agent turn for a simple resize. Default to a compact
943
+ // envelope; --full restores the complete response.
944
+ const updatedNode = isRecord(result) && isRecord(result.node) ? result.node : undefined;
945
+ // Raw geometry flags also land in the body alongside their structured
946
+ // position/size forms (the graph-patch builder copies them through); report
947
+ // only the canonical field once.
948
+ const structuredDupes: Record<string, string> = {
949
+ width: 'size',
950
+ height: 'size',
951
+ nodeHeight: 'size',
952
+ x: 'position',
953
+ y: 'position',
954
+ };
955
+ const updatedFields = Object.keys(body)
956
+ .filter((key) => !(structuredDupes[key] && body[structuredDupes[key]] !== undefined))
957
+ .sort();
958
+ output({
959
+ ok: isRecord(result) && result.ok !== undefined ? result.ok : true,
960
+ id,
961
+ nodeId: id,
962
+ ...(updatedNode && isRecord(updatedNode.position) ? { position: updatedNode.position } : {}),
963
+ ...(updatedNode && isRecord(updatedNode.size) ? { size: updatedNode.size } : {}),
964
+ updatedFields,
965
+ });
936
966
  },
937
967
  );
938
968
 
package/src/cli/index.ts CHANGED
@@ -163,7 +163,10 @@ function runMcpServerProcess(): Promise<void> {
163
163
  const serveSubcommand = firstArg === 'serve' ? (args[1] ?? '') : '';
164
164
 
165
165
  if (firstArg === 'serve' && (serveSubcommand === 'status' || serveSubcommand === 'stop')) {
166
- const port = parseInt(readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? '4313');
166
+ const port = parseInt(
167
+ readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
168
+ 10,
169
+ );
167
170
  const daemonLogFile = resolve(readOption('log-file') ?? `.pmx-canvas/daemon-${port}.log`);
168
171
  const daemonPidFile = resolve(readOption('pid-file') ?? `.pmx-canvas/daemon-${port}.pid`);
169
172
  const daemonWaitMs = readNumberOption('wait-ms') ?? 10_000;
@@ -208,7 +211,10 @@ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
208
211
  await runMcpServerProcess();
209
212
  } else {
210
213
  // "serve" is also accessible via flags (backward compat)
211
- const port = parseInt(readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? '4313');
214
+ const port = parseInt(
215
+ readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
216
+ 10,
217
+ );
212
218
  const demo = hasFlag('demo');
213
219
  const noOpen = hasFlag('no-open');
214
220
  const daemon = hasFlag('daemon');
package/src/cli/shared.ts CHANGED
@@ -147,6 +147,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
147
147
  'json',
148
148
  'compact',
149
149
  'verbose',
150
+ 'full',
150
151
  'include-logs',
151
152
  'no-pan',
152
153
  'schema',
@@ -1,5 +1,12 @@
1
+ // Must run before any module that mints per-mount nonces at import/render time.
2
+ import './uuid-polyfill';
1
3
  import { render } from 'preact';
2
4
  import { App } from './App';
5
+ import { resolveIframeMode } from './state/iframe-mode';
6
+
7
+ // Start the embed probe before first paint so iframe-backed nodes know whether
8
+ // src-URL iframes work in this context (Amp orb portals block them).
9
+ void resolveIframeMode();
3
10
 
4
11
  const root = document.getElementById('app');
5
12
  if (root) {
@@ -253,6 +253,19 @@ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JS
253
253
  </script>`;
254
254
  }
255
255
 
256
+ /**
257
+ * Base styles injected into every ext-app document (Finding Q, 0.3.4 report):
258
+ * apps aspect-fit their drawn surface to the frame width, and on narrow/tall
259
+ * tiles the leftover region shows the app document's own body background —
260
+ * pure black for the hosted Excalidraw bundle, in every engine. Making the
261
+ * app document transparent lets the host iframe's theme background
262
+ * (var(--c-panel)) show through the letterbox instead. Background only — app
263
+ * content, its drawn scene, and its own foreground styling are untouched.
264
+ */
265
+ export function buildExtAppSurfaceBaseStyles(): string {
266
+ return '<style data-pmx-canvas-ext-app-base>html, body { background: transparent !important; }</style>';
267
+ }
268
+
256
269
  export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
257
270
  if (!axBridgeScript) return html;
258
271
  const headMatch = /<head\b[^>]*>/i.exec(html);
@@ -359,7 +372,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
359
372
  const bootBeaconScript =
360
373
  typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
361
374
  const iframeDocument = useIframeDocument(
362
- injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
375
+ injectExtAppAxBridgeScript(html ?? '', buildExtAppSurfaceBaseStyles() + bootBeaconScript + axBridgeScript),
363
376
  iframeSandbox,
364
377
  );
365
378
 
@@ -566,6 +579,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
566
579
  if (!iframe) return;
567
580
  let disposed = false;
568
581
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
582
+ let repaintNudgeTimer: ReturnType<typeof setTimeout> | null = null;
569
583
  let hostContextResizeObserver: ResizeObserver | null = null;
570
584
  let hostContextRaf: number | null = null;
571
585
  let readyNudgeRaf: number | null = null;
@@ -641,6 +655,32 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
641
655
  });
642
656
  };
643
657
 
658
+ // Finding N best-effort: WKWebView can keep compositing a stale (black)
659
+ // texture for the doubly-nested app iframe even after a genuine settled
660
+ // boot — the user-proven unstick is enlarge+close, i.e. a layout-size
661
+ // change. Mimic the cheap half of that after the scene draw: bounce the
662
+ // iframe's height by 1px for two frames so WebKit must re-rasterize the
663
+ // layer. No reload, no app state loss; a healthy frame repaints
664
+ // identically. Scheduled past the remount queue's settle pause so it
665
+ // lands after the app has actually drawn.
666
+ const scheduleWebkitRepaintNudge = () => {
667
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
668
+ if (repaintNudgeTimer) clearTimeout(repaintNudgeTimer);
669
+ repaintNudgeTimer = setTimeout(() => {
670
+ repaintNudgeTimer = null;
671
+ const frame = iframeRef.current;
672
+ if (disposed || !frame) return;
673
+ const previous = frame.style.height;
674
+ frame.style.height = 'calc(100% - 1px)';
675
+ requestAnimationFrame(() => {
676
+ requestAnimationFrame(() => {
677
+ frame.style.height = previous;
678
+ extAppRecoveryLog(nodeId, 'repaint-nudge');
679
+ });
680
+ });
681
+ }, WEBKIT_REMOUNT_SETTLE_MS + 200);
682
+ };
683
+
644
684
  const bridge = new AppBridge(
645
685
  null,
646
686
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -688,7 +728,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
688
728
 
689
729
  bridge.onsandboxready = async () => {
690
730
  await bridge.sendSandboxResourceReady({
691
- html,
731
+ // Same Finding Q base styles for the sandbox-proxy path: the inner
732
+ // resource document is the one that letterboxes on narrow tiles.
733
+ html: injectExtAppAxBridgeScript(html, buildExtAppSurfaceBaseStyles()),
692
734
  sandbox: DEFAULT_EXT_APP_SANDBOX,
693
735
  ...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
694
736
  ...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
@@ -790,6 +832,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
790
832
  extAppRecoveryLog(nodeId, 'settled');
791
833
  for (const waiter of bootWaitersRef.current.splice(0)) waiter();
792
834
  nudgeHostContextAfterLayout();
835
+ scheduleWebkitRepaintNudge();
793
836
  })
794
837
  .catch((err) => {
795
838
  const msg = err instanceof Error ? err.message : String(err);
@@ -816,6 +859,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
816
859
  setStatus(bootstrapToolResult ? 'done' : 'ready');
817
860
  setError(null);
818
861
  nudgeHostContextAfterLayout();
862
+ scheduleWebkitRepaintNudge();
819
863
  })
820
864
  .catch((err) => {
821
865
  const msg = err instanceof Error ? err.message : String(err);
@@ -863,6 +907,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
863
907
  return () => {
864
908
  disposed = true;
865
909
  clearFallbackTimer();
910
+ if (repaintNudgeTimer) {
911
+ clearTimeout(repaintNudgeTimer);
912
+ repaintNudgeTimer = null;
913
+ }
866
914
  hostContextResizeObserver?.disconnect();
867
915
  hostContextResizeObserver = null;
868
916
  if (hostContextRaf !== null) {
@@ -5,6 +5,7 @@ import type { CanvasNodeState } from '../types';
5
5
  import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
6
6
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
7
7
  import { useIframeContentHeight } from './use-iframe-content-height';
8
+ import { useSurfaceFrame } from './use-surface-frame';
8
9
 
9
10
  export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
10
11
  return node.type === 'html' && node.data.presentation === true;
@@ -60,6 +61,9 @@ export function HtmlNode({
60
61
  [html, presentation, presentationExitToken, themeToken, v, node.id, axToken, frameToken],
61
62
  );
62
63
 
64
+ // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
65
+ const surfaceFrame = useSurfaceFrame(surfaceSrc);
66
+
63
67
  // Grow the node to fit the surface's reported content height (grow-only, gated).
64
68
  // Never in the expanded overlay — there the surface fills the large overlay frame.
65
69
  useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
@@ -149,7 +153,7 @@ export function HtmlNode({
149
153
  class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
150
154
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
151
155
  sandbox="allow-scripts"
152
- src={surfaceSrc}
156
+ {...surfaceFrame}
153
157
  tabIndex={autoFocus ? 0 : undefined}
154
158
  onLoad={handleFrameLoad}
155
159
  style={{
@@ -6,6 +6,7 @@ import { shouldContentFitIframeNode } from '../canvas/auto-fit';
6
6
  import { ExtAppFrame } from './ExtAppFrame';
7
7
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
8
8
  import { useIframeContentHeight } from './use-iframe-content-height';
9
+ import { useSurfaceFrame } from './use-surface-frame';
9
10
 
10
11
  function withViewerParams(
11
12
  url: string,
@@ -117,6 +118,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
117
118
  frameToken || undefined,
118
119
  contentFit,
119
120
  );
121
+ // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
122
+ // External viewer URLs always stay src — the hook passes them through.
123
+ const surfaceFrame = useSurfaceFrame(url);
120
124
  const sourceServer = (node.data.sourceServer as string) || '';
121
125
  const hostMode = (node.data.hostMode as string) || 'hosted';
122
126
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -174,7 +178,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
174
178
  app/host RPC and broader capabilities. */}
175
179
  <iframe
176
180
  ref={iframeRef}
177
- src={url}
181
+ {...surfaceFrame}
178
182
  class="mcp-app-frame"
179
183
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
180
184
  allow="clipboard-read; clipboard-write"