pmx-canvas 0.4.6 → 0.4.8

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 (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +93 -89
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  10. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  11. package/dist/types/client/state/canvas-store.d.ts +10 -0
  12. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  13. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  14. package/dist/types/server/ax-interaction.d.ts +21 -2
  15. package/dist/types/server/canvas-db.d.ts +14 -0
  16. package/dist/types/server/canvas-operations.d.ts +5 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -0
  18. package/dist/types/server/canvas-validation.d.ts +15 -21
  19. package/dist/types/server/file-content.d.ts +46 -0
  20. package/dist/types/server/html-primitives.d.ts +13 -1
  21. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  22. package/dist/types/server/server.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +14 -0
  24. package/dist/types/shared/ax-flow.d.ts +51 -0
  25. package/dist/types/shared/node-sizes.d.ts +33 -0
  26. package/docs/RELEASE.md +48 -0
  27. package/docs/cli.md +19 -2
  28. package/docs/environment.md +1 -0
  29. package/docs/http-api.md +17 -0
  30. package/docs/mcp.md +12 -0
  31. package/docs/node-types.md +45 -1
  32. package/docs/screenshot.png +0 -0
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +74 -9
  35. package/skills/pmx-canvas/evals/evals.json +54 -3
  36. package/skills/pmx-canvas/references/full-reference.md +11 -6
  37. package/skills/pmx-canvas-testing/SKILL.md +40 -0
  38. package/src/cli/commands/history.ts +1 -1
  39. package/src/cli/commands/nodes.ts +2 -2
  40. package/src/cli/commands/query.ts +1 -1
  41. package/src/cli/commands/view.ts +1 -1
  42. package/src/cli/index.ts +6 -0
  43. package/src/cli/shared.ts +34 -5
  44. package/src/client/App.tsx +28 -15
  45. package/src/client/canvas/CanvasNode.tsx +54 -5
  46. package/src/client/canvas/EdgeLayer.tsx +44 -24
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  48. package/src/client/canvas/auto-fit.ts +10 -1
  49. package/src/client/canvas/use-pan-zoom.ts +16 -1
  50. package/src/client/nodes/AxStepControls.tsx +226 -0
  51. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  52. package/src/client/nodes/FileNode.tsx +249 -51
  53. package/src/client/state/canvas-store.ts +19 -0
  54. package/src/client/state/intent-bridge.ts +32 -0
  55. package/src/client/state/sse-bridge.ts +4 -1
  56. package/src/client/theme/global.css +101 -3
  57. package/src/server/ax-flow-loop.ts +114 -0
  58. package/src/server/ax-interaction.ts +299 -4
  59. package/src/server/canvas-db.ts +138 -32
  60. package/src/server/canvas-operations.ts +44 -17
  61. package/src/server/canvas-state.ts +20 -10
  62. package/src/server/canvas-validation.ts +50 -41
  63. package/src/server/demo-state.json +4605 -523
  64. package/src/server/demo.ts +24 -0
  65. package/src/server/file-content.ts +152 -0
  66. package/src/server/file-watcher.ts +7 -10
  67. package/src/server/html-primitives.ts +636 -0
  68. package/src/server/operations/ops/ax-work.ts +5 -0
  69. package/src/server/operations/ops/nodes.ts +15 -1
  70. package/src/server/operations/ops/viewport.ts +7 -1
  71. package/src/server/server.ts +106 -5
  72. package/src/server/web-artifacts.ts +109 -0
  73. package/src/shared/ax-flow.ts +102 -0
  74. package/src/shared/node-sizes.ts +52 -0
  75. package/dist/types/server/chart-template.d.ts +0 -29
  76. package/src/server/chart-template.ts +0 -448
package/docs/http-api.md CHANGED
@@ -64,6 +64,23 @@ A node creation request must resolve a `type` — pass it in the body (`{ "type"
64
64
  ... }`) or as a `?type=` query param. An empty / type-less body returns `400`
65
65
  rather than silently creating a markdown node.
66
66
 
67
+ ### File node content and bytes
68
+
69
+ A `file` node reads its file server-side. Text is stored on the node as
70
+ `data.fileContent` (with `data.lineCount`); `data.byteSize` is always set.
71
+ Non-text files are never decoded into mojibake — they carry `data.binary: true`
72
+ with no `fileContent`, plus `data.mimeType` when the type is known
73
+ (`application/pdf` for PDFs). A text file larger than 2 MB is stored truncated
74
+ with `data.truncated: true`.
75
+
76
+ ```bash
77
+ # Raw bytes for a binary file node (PDF viewer, downloads)
78
+ curl http://localhost:4313/api/canvas/file-bytes?nodeId=node-abc123
79
+ ```
80
+
81
+ The byte route is workspace-scoped: a file node whose path resolves outside the
82
+ workspace root returns `403`, and an unknown node id returns `404`.
83
+
67
84
  ## Edges
68
85
 
69
86
  ```bash
package/docs/mcp.md CHANGED
@@ -227,6 +227,18 @@ interaction against the node's effective capabilities.
227
227
  - **Commands:** `canvas_invoke_command` runs a registry command (`pmx.plan`,
228
228
  `pmx.execute`, `pmx.promote-context`, `pmx.summarize`, `pmx.review`); unknown
229
229
  names are rejected and a successful call records a `command` agent-event.
230
+ Commands *record* a signal for the agent — they do not execute anything
231
+ server-side.
232
+ - **`ax.flow.materialize`:** the one interaction that creates canvas nodes, and
233
+ it is deliberately narrow. The caller supplies text only — up to 12 steps with
234
+ a title (<= 120 chars) and optional detail — and the SERVER owns the resulting
235
+ shape: one `markdown` node per step, `flow` edges between them, a dashed
236
+ `references` loop edge when looping, and one work item per step linked to its
237
+ node (so the existing status-chip mirroring shows progress). The surface never
238
+ names a node type, geometry, or node data. Re-materializing replaces the
239
+ previous flow: the source node keeps a manifest of the ids it created, and
240
+ only nodes on that manifest that still carry the matching flow id are removed,
241
+ so a surface can never delete anything it did not create.
230
242
 
231
243
  ## Change notifications
232
244
 
@@ -14,7 +14,7 @@ see [MCP tools](mcp.md), [HTTP API](http-api.md), and [SDK](sdk.md).
14
14
  | `context` | Context cards, token usage, workspace grounding |
15
15
  | `ledger` | Execution ledger summary |
16
16
  | `trace` | Agent trace pills (tool calls, subagent activity) |
17
- | `file` | Live file viewer with auto-update on disk changes |
17
+ | `file` | Live file viewer with auto-update on disk changes — also renders CSV/TSV as tables and PDFs inline |
18
18
  | `diff` | Unified diff viewer: per-file headers, hunk rows, +/− colored lines |
19
19
  | `mermaid` | Mermaid diagram rendered client-side from text (flowchart, sequence, state, …) |
20
20
  | `image` | Image viewer (file paths, data URIs, URLs) |
@@ -50,6 +50,20 @@ automatically via `fs.watch()`.
50
50
  canvas_node({ action: 'add', type: 'file', content: 'src/server/index.ts' })
51
51
  ```
52
52
 
53
+ The same node type handles non-source files, so you never have to pick a
54
+ different type by extension:
55
+
56
+ - **`.csv` / `.tsv`** render as a real table (quoted fields, embedded newlines,
57
+ and escaped quotes are parsed properly) instead of raw delimited text. Very
58
+ long files render a bounded number of rows and say how many were omitted.
59
+ - **`.pdf`** renders in an embedded viewer, served from
60
+ `GET /api/canvas/file-bytes?nodeId=…`. In hosts that block src-URL iframes
61
+ (Amp orb portals) the node offers an "Open PDF" link instead of a blank frame.
62
+ - **Other binary files** show a placeholder with the name and size rather than
63
+ decoding bytes into mojibake.
64
+ - **Text files over 2 MB** render truncated with a banner saying so, so a huge
65
+ log can never exhaust memory.
66
+
53
67
  ## Image nodes
54
68
 
55
69
  Image nodes display local paths, remote URLs, and data URIs. File-backed and
@@ -221,6 +235,36 @@ component gallery, interaction prototype, flowchart, SVG illustration set,
221
235
  presentation, explainer, status report, incident report, triage board, config
222
236
  editor, or prompt tuner.
223
237
 
238
+ Two kinds are **live AX control surfaces** rather than static documents, and are
239
+ the only primitives created with AX enabled:
240
+
241
+ - **`ax-board`** — give the agent tasks, watch their status change as it works,
242
+ send steering, and run a bounded task loop. Task rows read from live AX state,
243
+ so a status the agent sets anywhere shows up here.
244
+ - **`ax-flow`** — the same controls drawn as a task flow with an explicit
245
+ loop-back rail, plus **Materialize to board**: the flow is laid out as real
246
+ canvas nodes joined by `flow` edges (and a dashed `references` loop edge),
247
+ each linked to a work item so the node's status chip tracks progress.
248
+ Re-materializing replaces the previous flow instead of duplicating it.
249
+
250
+ A steer emitted from either surface is *queued* on the timeline — it reaches the
251
+ agent on its next turn rather than waking it. Both stop their loop if the panel
252
+ reloads; the work items are the durable record.
253
+
254
+ ### The flow on ordinary nodes
255
+
256
+ You do not need the panel to drive a flow. A materialized step node carries
257
+ `data.axStep`, and the canvas renders native controls on it — **Start / Done /
258
+ Blocked** on every step, plus **Run loop / Stop** and a steer box on the first
259
+ one. These are canvas chrome, not sandboxed content, so they work on any node
260
+ type carrying the stamp.
261
+
262
+ The native loop runs **server-side**, which is the one real difference from the
263
+ panel: it advances when a step's work item is completed, survives a browser
264
+ reload, and keeps going while the tab is closed. Same bounds either way — it only
265
+ advances while running, stops at its run cap (hard limit 20), and Stop is
266
+ persisted immediately. `blocked` or `cancelled` halts it without disarming.
267
+
224
268
  ```ts
225
269
  canvas_node({
226
270
  action: 'add',
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Spatial canvas workbench for coding agents — 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",
@@ -46,7 +46,7 @@
46
46
  "test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
47
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": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
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",
49
+ "test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 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",
@@ -105,6 +105,7 @@ exit 1 on failure.
105
105
  | Persistent context cards | `context` via `canvas_node` |
106
106
  | Event/check stream | `ledger` or `trace` via `canvas_node` |
107
107
  | Local source with live updates | `file` via `canvas_node` |
108
+ | Tabular data (`.csv`/`.tsv`), a PDF, or any other file on disk (0.4.6+) | `file` via `canvas_node` — CSV/TSV render as tables, PDFs render inline, other binaries show a size placeholder. Never paste a CSV into a markdown fence. |
108
109
  | Code review / unified diff (0.4.6+) | `diff` via `canvas_node` (content = diff text; link to its file node with a `references` edge) |
109
110
  | Flowchart / sequence / state diagram (0.4.6+) | `mermaid` via `canvas_node` (content = mermaid source; renders client-side, no hosted app) |
110
111
  | Image | `image` via `canvas_node` |
@@ -113,6 +114,9 @@ exit 1 on failure.
113
114
  | Chart | `graph` via `canvas_render` |
114
115
  | Live work-item board (0.4.6+) | `canvas_render { action: "workboard" }` — one board node, auto-refreshes on work-item changes |
115
116
  | Generated communication surface | HTML primitive via `canvas_node` |
117
+ | Give the agent tasks + watch progress + loop (0.4.7+) | `ax-board` HTML primitive — a live AX control surface, created AX-enabled |
118
+ | The same, drawn as a task flow, and materializable to real nodes+edges (0.4.7+) | `ax-flow` HTML primitive — **Materialize to board** lays the steps out as nodes joined by `flow` edges with a loop-back edge, each linked to a work item |
119
+ | Drive a flow WITHOUT the panel (0.4.7+) | Materialized step nodes carry native Start/Done/Blocked controls, and the anchor adds Run loop/Stop + steer. The native loop runs server-side, so it survives a browser reload and keeps advancing while the tab is closed. |
116
120
  | Self-contained HTML/JS | `html` via `canvas_node` |
117
121
  | Hosted interactive MCP app | `canvas_app { action: "open-mcp-app" }` |
118
122
  | Excalidraw diagram (interactive/human drawing) | `canvas_app { action: "diagram" }` — prefer `mermaid` for agent-authored diagrams |
@@ -190,10 +194,23 @@ distant nodes, an auto-placed node lands off-camera.
190
194
  3. Never fit the whole board to "show" new work — on a board with outliers that miniaturizes
191
195
  everything. Always pass explicit `nodeIds` to `fit`.
192
196
  4. Never leave user-facing output at far coordinates without a focus/fit.
197
+ 5. **Do not hand-compute a whole board's coordinates.** Create the nodes (omitting x/y), then
198
+ `canvas_view { action: "arrange", layout: "grid" | "column" | "flow" }` and finish with a
199
+ `fit` over the new ids. Manual pixel math is what produces long-line, unbalanced boards.
200
+ 6. `fit` sizes itself to the connected browser window (0.4.6+) — you do not need to guess
201
+ `width`/`height`. Pass them only to fit for a window other than the human's.
202
+ 7. **Docking is explicit (0.4.6+).** Nodes render on the canvas where you put them; a node is
203
+ only a HUD pill if you set `dockPosition`. An edge whose endpoint is docked cannot be drawn —
204
+ `validate` reports it as `hiddenEdgeEndpoints` and fails `ok`, because the edge visually
205
+ trails off into empty canvas.
193
206
 
194
207
  **Size for content.** Omitting `width`/`height` gives readable per-type defaults — prefer them:
195
208
  markdown 640×420, status 360×200, file 520×360, diff 640×420, mermaid 640×460, html 720×640,
196
- graph 760×520, mcp-app 960×600, web-artifact 960×720. Since 0.4.6 the server clamps explicit creation sizes UP to per-type
209
+ graph 760×520, mcp-app 960×600, web-artifact 960×720. A *hosted* app opened with
210
+ `canvas_app { action: "open-mcp-app" | "diagram" }` — including the Excalidraw diagram preset — is
211
+ the exception: it opens at 720×500, not 960×600 (960×600 is the default for an `mcp-app` node you
212
+ create directly). Pass `width`/`height` to that action when you want a bigger diagram tile.
213
+ Since 0.4.6 the server clamps explicit creation sizes UP to per-type
197
214
  readability floors (e.g. markdown 360×180, graph/json-render/html 420×280, mcp-app 480×320) —
198
215
  a tiny probe size silently becomes the floor. `strictSize: true` is the only opt-out (a fixed
199
216
  scrolling frame you genuinely want small). `canvas_query { action: "validate" }` additionally
@@ -258,6 +275,49 @@ or host actions.
258
275
  Read [AX HTML control surfaces](references/ax-html-control-surface.md) before building an
259
276
  interactive AX-enabled HTML node.
260
277
 
278
+ ### Ready-made control surfaces (0.4.7+) — prefer these over hand-rolling
279
+
280
+ Two HTML primitives ship AX-enabled, so you do not need to author a control surface or pass
281
+ `axCapabilities` yourself. They are the ONLY primitives created with AX on; every other kind is
282
+ still a static document.
283
+
284
+ - **`ax-board`** — task list: create tasks, watch status change live, steer, bounded loop.
285
+ - **`ax-flow`** — the same controls drawn as a task flow with a loop-back rail, plus
286
+ **Materialize to board**.
287
+
288
+ **`ax.flow.materialize`** is the one interaction that creates canvas nodes, and it is deliberately
289
+ narrow: you supply TEXT ONLY (<= 12 steps, title <= 120 chars) and the server owns the result —
290
+ one `markdown` node per step, `flow` edges between them, a dashed `references` loop edge, and one
291
+ work item per step linked to its node. Re-materializing REPLACES the previous flow (the source
292
+ node keeps a manifest of what it created), so it is safe to call repeatedly.
293
+
294
+ **The flow also works without the panel.** Materialized step nodes carry `data.axStep`, and the
295
+ canvas renders native Start / Done / Blocked controls on them, with Run loop / Stop and a steer box
296
+ on the anchor (first) step. Those controls also follow the node into focus mode. The native loop
297
+ runs **server-side**: it advances when a step's work item completes, survives a browser reload, and
298
+ keeps going while the tab is closed — unlike the panel's loop, which dies with the iframe. Bounds
299
+ are the same either way: advances only while running, hard cap 20 runs, Stop persists immediately,
300
+ and `blocked`/`cancelled` halts it.
301
+
302
+ When you want the agent to work through a checklist the human can watch and steer, reach for
303
+ `ax-flow` + materialize rather than a markdown to-do list — the status chips and the loop come free.
304
+
305
+ ### Working a flow: keep the board honest as you go
306
+
307
+ A materialized flow is only useful if it tracks reality. While you work one:
308
+
309
+ 1. **Move the step to `in-progress` BEFORE you start it, and to `done` when it is actually done** —
310
+ not in a batch at the end. The human is watching the chips to know where you are; a board that
311
+ only updates on completion tells them nothing while it matters.
312
+ 2. **Pin the in-progress step as context** (`canvas_pin_nodes` with just that node, or
313
+ `POST /api/canvas/context-pins` mode `set`). This makes the active step the one thing in
314
+ `canvas://pinned-context`, so the board's "what am I working on" and your own context are the
315
+ same answer instead of drifting apart. Re-pin as you advance.
316
+ 3. **Mark `blocked` rather than going quiet** when you are stuck — a stalled `in-progress` chip is
317
+ indistinguishable from a crashed agent.
318
+ 4. Read steering back with `canvas_ax_delivery` as you go; a human watching a live flow steers
319
+ mid-run, and a steer you never claim is a correction you ignored.
320
+
261
321
  ## Resources
262
322
 
263
323
  Read the smallest resource that answers the question:
@@ -296,8 +356,12 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
296
356
  affordance. The connecting overlay stays up until paint is confirmed, so a black layer is
297
357
  never presented as ready. The recovery trail
298
358
  (`GET /api/canvas/debug/ext-app-recovery` / `window.__PMX_EXTAPP_LOG`) now records
299
- `mount-slot`, `paint-ok`, `paint-fail`, `soft-expand-cycle`, and `recovery-exhausted` — when
300
- diagnosing, trust `paint-ok`/`paint-fail`, and never assert health from `settled` alone. If a
359
+ `mount-slot`, `paint-ok`, `paint-fail`, `soft-expand-cycle`, `recovery-exhausted`, and
360
+ `assume-visible-rearm` — when diagnosing, trust `paint-ok`/`paint-fail`, and never assert
361
+ health from `settled` alone. Exception: in a host that reports the document hidden (the
362
+ GitHub Copilot panel does so continuously), `paint-ok` is recorded as
363
+ `paint-ok (unverified: host hidden)` — the app answered the paint probe, but nothing is
364
+ proven composited, so do not treat it as a verified paint. If a
301
365
  tile still shows the Retry affordance, click Retry (fresh recovery budget) or expand-then-close;
302
366
  attach the recovery trail when reporting.
303
367
  - Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
@@ -308,8 +372,8 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
308
372
  - A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
309
373
  upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
310
374
  This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
311
- background override ships but cannot reach the app's inner root). Keep ext-app tiles near the
312
- app's aspect (the 960x738 default, or the same ratio), or expand the node — the fullscreen
375
+ background override ships but cannot reach the app's inner root). Keep ext-app tiles landscape —
376
+ at or above the 720x500 they open at, or the same ratio — or expand the node: the fullscreen
313
377
  overlay renders full-bleed. The durable fix is upstream in the excalidraw-mcp app.
314
378
  - Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
315
379
  back from SSE to a polling transport within ~3s, so the board still boots and stays live.
@@ -327,10 +391,11 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
327
391
  URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
328
392
  `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
329
393
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
330
- reflow on a live window resize in a normal browser. Some single-tab host browsers historically
331
- didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
332
- graph reflow on a native viewport resize), so if a resized standalone chart looks stale,
333
- reload it or use a system browser for separate full-page viewing.
394
+ reflow on a live window resize. Some single-tab host browsers historically didn't deliver
395
+ live-resize events; current Codex builds do — the 0.4.6 pass watched a standalone graph reflow
396
+ live (SVG 1550×783 → 850×483 on a 1600×900 → 900×600 window resize) with no reload. Do not
397
+ present a system browser as a workaround for stale resizing; it is only a preference for viewing
398
+ a full page beside the canvas.
334
399
  - Some hosts cannot automate inside sandboxed workbench iframes. Verify those interactions in a
335
400
  system browser or through server-side AX state.
336
401
  - `pmx-canvas screenshot` requires an active WebView. Start it with
@@ -417,7 +417,7 @@
417
417
  "id": 17,
418
418
  "name": "standalone-surface-open-as-site",
419
419
  "prompt": "Open the bar-chart graph node as a standalone full-page site for a screenshot, and also try to open the Excalidraw diagram node the same way. Tell me the correct surface URL for each.",
420
- "expected_output": "For the graph/json-render node, uses the stable surface URL /api/canvas/surface/<id> which redirects to the full-viewport display=site viewer (reads surfaceUrl from the node, does not fabricate a path). Recognizes the hosted Excalidraw/ext-app node is NOT an open-as-site target (its surface route returns a clean 404 \u2014 it renders only with the in-canvas host bridge) and does not claim a standalone tab will work. Notes that some host browsers (e.g. the Codex in-app single-tab browser) don't deliver live-resize events, so a system browser is recommended for separate full-page viewing.",
420
+ "expected_output": "For the graph/json-render node, uses the stable surface URL /api/canvas/surface/<id> which redirects to the full-viewport display=site viewer (reads surfaceUrl from the node, does not fabricate a path). Recognizes the hosted Excalidraw/ext-app node is NOT an open-as-site target (its surface route returns a clean 404 \u2014 it renders only with the in-canvas host bridge, and open-external refuses it with the same message) and does not claim a standalone tab will work. Describes the standalone surface as reflowing live on a window resize; does not claim host browsers leave a resized chart stale until reload, and does not prescribe a system browser as a workaround.",
421
421
  "assertions": [
422
422
  {
423
423
  "name": "graph-site-surface-url",
@@ -430,8 +430,59 @@
430
430
  "type": "output_check"
431
431
  },
432
432
  {
433
- "name": "host-browser-caveat",
434
- "description": "Recommends a system browser for separate full-page viewing and/or notes single-tab host browsers may not reflow on live resize",
433
+ "name": "no-stale-resize-claim",
434
+ "description": "Does not assert that host browsers fail to deliver live resize (current Codex builds do — the 0.4.6 pass saw a standalone graph reflow live with no reload); a system browser may be offered as a viewing preference, never as a resize workaround",
435
+ "type": "output_check"
436
+ }
437
+ ]
438
+ },
439
+ {
440
+ "id": 18,
441
+ "name": "in-view-placement-after-create",
442
+ "prompt": "The board already has a cluster of nodes from this morning, way off to the far right. Add a markdown summary of the incident and a status node for the follow-up, and make sure I can actually see them.",
443
+ "expected_output": "Places the new nodes near the human's attention (pinned/focused neighborhood or beside the last user-facing output) instead of trusting board-relative auto-placement on a spread-out board, then pans the camera: canvas_view {action:focus} for a single node, or canvas_view {action:fit, nodeIds:[...]} with exactly the new ids for the pair. Does not hand-compute the whole board's coordinates and does not fit the whole board to 'show' the new work — an outlier cluster would miniaturize everything.",
444
+ "assertions": [
445
+ {
446
+ "name": "ends-with-focus-or-fit",
447
+ "description": "Finishes with canvas_view {action:focus} (single node) or {action:fit} after creating the nodes, rather than leaving them off-camera",
448
+ "type": "output_check"
449
+ },
450
+ {
451
+ "name": "fit-targets-new-ids-only",
452
+ "description": "Passes exactly the new node ids to fit instead of fitting the whole board (a distant cluster would shrink everything)",
453
+ "type": "output_check"
454
+ },
455
+ {
456
+ "name": "no-whole-board-coordinate-math",
457
+ "description": "Places relative to the human's attention or uses create-then-arrange; does not hand-compute a full board layout in pixels",
458
+ "type": "output_check"
459
+ }
460
+ ]
461
+ },
462
+ {
463
+ "id": 19,
464
+ "name": "node-size-discipline",
465
+ "prompt": "Add a markdown write-up of the outage and a bar chart of the error counts. Keep them tight — I don't want huge tiles.",
466
+ "expected_output": "Omits width/height and takes the per-type defaults (markdown 640×420, graph 760×520) rather than passing small probe sizes for human-readable content. Uses strictSize:true only for a deliberately fixed small frame, not to dodge the floor. Reads the size back from the create result or the layout before any layout math, because the server clamps an undersized explicit frame UP to the per-type floor (markdown 360×180, graph 420×280) — the returned size can differ from what was requested.",
467
+ "assertions": [
468
+ {
469
+ "name": "uses-type-defaults",
470
+ "description": "Omits width/height (or passes a readable size) instead of guessing a frame for readable content",
471
+ "type": "output_check"
472
+ },
473
+ {
474
+ "name": "no-tiny-probe-sizes",
475
+ "description": "Does not create human-readable nodes at tiny probe sizes that the floor would silently raise",
476
+ "type": "output_check"
477
+ },
478
+ {
479
+ "name": "strict-size-only-when-deliberate",
480
+ "description": "Reaches for strictSize:true only for an intentionally fixed small frame, not as a way around the readability floor",
481
+ "type": "output_check"
482
+ },
483
+ {
484
+ "name": "reads-back-created-size",
485
+ "description": "Reads the actual created size from the create result / canvas_query layout before positioning or sizing anything else, rather than assuming the requested size was kept",
435
486
  "type": "output_check"
436
487
  }
437
488
  ]
@@ -952,12 +952,15 @@ the host's embedded browser (e.g. Codex) opens `_blank` tabs in-place.
952
952
  renders with the in-canvas host bridge — a bare tab has no peer, so the surface route
953
953
  returns `404` ("This MCP app renders in the canvas and cannot be opened as a standalone
954
954
  site"); view/edit them in the canvas, or open them externally through their own app
955
- (report #61). Only bundled `web-artifact` apps (redirect to `/artifact`) and URL-backed
956
- `mcp-app` / `webpage` viewers redirect to their external site.
955
+ (report #61). `POST /api/canvas/open-external` refuses those nodes with the same message
956
+ instead of launching a browser at a URL that 404s. Only bundled `web-artifact` apps
957
+ (redirect to `/artifact`) and URL-backed `mcp-app` / `webpage` viewers redirect to their
958
+ external site.
957
959
  - `graph` / `json-render` nodes redirect to the full-viewport `display=site` viewer; the chart
958
- fills the window and reflows on a live resize in a normal browser. Single-tab host browsers
959
- that don't deliver live-resize events (e.g. the Codex in-app browser) can leave a resized chart
960
- stale until reload — recommend a system browser for separate full-page viewing (report #67).
960
+ fills the window and reflows on a live resize. Current Codex builds deliver live-resize events —
961
+ the 0.4.6 pass watched a standalone graph reflow live (SVG 1550×783 → 850×483 on a
962
+ 1600×900 → 900×600 window resize) with no reload — so do not present a system browser as a
963
+ workaround for stale resizing (report #67).
961
964
  - This is additive — opening a site never evicts or replaces canvas nodes.
962
965
 
963
966
  ### Choosing the Right Visual Tier
@@ -1112,7 +1115,7 @@ AX interactions are gated per node type. The lists below are each type's **ceili
1112
1115
 
1113
1116
  | Node type | Allowed AX interaction types |
1114
1117
  |-----------|------------------------------|
1115
- | `markdown` | `ax.steer`, `ax.work.create`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
1118
+ | `markdown` | `ax.steer`, `ax.work.create`, `ax.work.update`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
1116
1119
  | `context` | `ax.focus.set`, `ax.steer`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
1117
1120
  | `status` | `ax.work.create`, `ax.work.update`, `ax.approval.request`, `ax.mode.request`, `ax.event.record` |
1118
1121
  | `file` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
@@ -1121,6 +1124,8 @@ AX interactions are gated per node type. The lists below are each type's **ceili
1121
1124
  | `ledger` | `ax.evidence.add`, `ax.event.record` |
1122
1125
  | `trace` | `ax.evidence.add`, `ax.event.record` |
1123
1126
  | `image` | `ax.evidence.add`, `ax.review.add` |
1127
+ | `diff` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
1128
+ | `mermaid` | `ax.steer`, `ax.work.create`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
1124
1129
  | `webpage` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
1125
1130
  | `group` | `ax.focus.set`, `ax.work.create`, `ax.command.invoke`, `ax.event.record` |
1126
1131
 
@@ -30,6 +30,16 @@ bun run test:web-canvas # Browser smoke against a real running app
30
30
  bun run test:all # Bun suite + browser smoke
31
31
  ```
32
32
 
33
+ **Direct `bun test` is side-effect-guarded by `tests/preload.ts`** (wired via `bunfig.toml`
34
+ `[test].preload`): it defaults `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` so the suite's open-as-site
35
+ tests cannot launch the developer's real browser, whichever way the tests are invoked. The
36
+ preload covers `bun test` only — when you boot a server yourself (`bun run src/cli/index.ts`),
37
+ pass `--no-open` and set `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` explicitly.
38
+
39
+ **Never pipe a gating command through `tail`, `head`, or `grep`.** The pipeline reports the exit
40
+ code of the LAST command, so `bun run test | tail -5` exits 0 on a red suite. Redirect to a file
41
+ and check `$?`, then read the file.
42
+
33
43
  ## Which Command To Run
34
44
 
35
45
  - Server/state/API-only changes: run `bun run test`
@@ -79,6 +89,30 @@ Prefer extending the existing suites before inventing a one-off script.
79
89
  `pmx-canvas skills sync --yes` to refresh them (whole trees, whatever agent layout owns them) before
80
90
  trusting skill-guided results
81
91
 
92
+ ## Tests That Cannot Fail
93
+
94
+ A test that passes against the broken code is worse than no test — it certifies the bug. Every
95
+ regression test must be shown to discriminate: run it against the unfixed code (or hard-code the
96
+ broken value) and watch it go red BEFORE you trust the green.
97
+
98
+ Four ways a test silently stops discriminating in this repo:
99
+
100
+ - **Polled assertions pass on the first sample.** `expect.poll(fn).toBeGreaterThanOrEqual(180)`
101
+ succeeds the instant one sample qualifies, so it never observes the failure window. Wait for the
102
+ settle condition, then assert once.
103
+ - **Committed fixtures embed generated output.** `src/server/demo-state.json` carries each
104
+ primitive's generated markup; count and coverage assertions are satisfied by stale markup just
105
+ as well as fresh. Guard generated fixtures by rebuilding from the generator's INPUT and
106
+ byte-comparing against the current renderer.
107
+ - **Absolute assertions about globally-wired side effects are order-dependent.** Anything flowing
108
+ through a single-slot listener (architecture rule 8 in `CLAUDE.md`) is wired or not depending on
109
+ whether an earlier test file booted a server — so the test passes alone and fails in the suite,
110
+ or vice versa. Assert DIFFERENTIALLY: run the action disarmed and armed, require the deltas to
111
+ match. Always run the full suite before trusting a new test; a single-file run hides this.
112
+ - **A new node type passes every test and renders nothing.** Server, API, and client-unit
113
+ assertions all pass while `isCanvasNodeType` drops the type during layout apply. Only a DOM
114
+ census on a live board catches it.
115
+
82
116
  ## Layout And Embedded Content Checks
83
117
 
84
118
  - For seeded or generated boards, add API-level geometry assertions: expected node/edge counts,
@@ -95,6 +129,12 @@ Prefer extending the existing suites before inventing a one-off script.
95
129
  - When checking embedded frame fit manually, start from a clean seeded state, rebuild stale bundles,
96
130
  and inspect the actual iframe document in a browser. Server dimensions can look correct while the
97
131
  embedded content is still clipped.
132
+ - **A node's stored content is not render evidence.** An html/primitive node's stored HTML holds
133
+ every conditional branch — hidden banners, error states, empty states — so a content-level read
134
+ (search hit, pinned context, `node get`, any text summary) quotes strings the user never saw.
135
+ The 0.4.7 report filed an "AX bridge unavailable" banner as a live product gap on exactly this
136
+ basis; the served surface had the bridge injected ahead of the check and the banner was `hidden`
137
+ in the document. Before filing a rendering bug, fetch the served surface or inspect the DOM.
98
138
  - User-facing creation flows must end with the new nodes visible: after the create (plus the
99
139
  skill-mandated focus/fit), a screenshot must show them without any manual pan. A `panned: true`
100
140
  API result alone is not proof — verify the frame is on-screen and unobscured.
@@ -81,7 +81,7 @@ cmd(
81
81
  'Show canvas mutation history',
82
82
  ['pmx-canvas history', 'pmx-canvas history --summary', 'pmx-canvas history --compact'],
83
83
  async (args) => {
84
- const { flags } = parseFlags(args);
84
+ const { flags } = parseFlags(args, { boolFlags: ['summary'] });
85
85
  if (flags.help || flags.h) return showCommandHelp('history');
86
86
 
87
87
  const result = (await invokeOperation('history.get', {})) as Record<string, unknown>;
@@ -691,7 +691,7 @@ cmd(
691
691
  'pmx-canvas node schema --summary',
692
692
  ],
693
693
  async (args) => {
694
- const { flags } = parseFlags(args);
694
+ const { flags } = parseFlags(args, { boolFlags: ['summary'] });
695
695
  if (flags.help || flags.h) return showCommandHelp('node schema');
696
696
 
697
697
  const result = await loadCanvasSchema();
@@ -783,7 +783,7 @@ cmd(
783
783
  'pmx-canvas node get node-abc123 --field title --field graphConfig',
784
784
  ],
785
785
  async (args) => {
786
- const { positional, flags } = parseFlags(args);
786
+ const { positional, flags } = parseFlags(args, { boolFlags: ['summary'] });
787
787
  if (flags.help || flags.h) return showCommandHelp('node get');
788
788
 
789
789
  const id = positional[0];
@@ -65,7 +65,7 @@ cmd(
65
65
  'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary',
66
66
  ],
67
67
  async (args) => {
68
- const { flags } = parseFlags(args);
68
+ const { flags } = parseFlags(args, { boolFlags: ['summary'] });
69
69
  if (flags.help || flags.h) return showCommandHelp('validate spec');
70
70
 
71
71
  const type = getStringFlag(flags, 'type');
@@ -44,7 +44,7 @@ cmd(
44
44
  'Get the full canvas layout (nodes, edges, viewport)',
45
45
  ['pmx-canvas layout', 'pmx-canvas layout --summary'],
46
46
  async (args) => {
47
- const { flags } = parseFlags(args);
47
+ const { flags } = parseFlags(args, { boolFlags: ['summary'] });
48
48
  if (flags.help || flags.h) return showCommandHelp('layout');
49
49
 
50
50
  if (flags.summary || flags.compact) {
package/src/cli/index.ts CHANGED
@@ -298,19 +298,25 @@ Agent CLI (works against running server):
298
298
  validate spec Validate json-render/graph payloads without creating nodes
299
299
  watch [--json] [--events ...] Watch low-token semantic canvas changes
300
300
  focus <node-id> Pan to node
301
+ fit [node-id ...] Fit the viewport to all nodes or a subset
301
302
  ax status|context|focus Inspect AX state and focus
302
303
  external-app add Add hosted external apps like Excalidraw
303
304
  diagram add Add an Excalidraw diagram node
305
+ html primitive add|schema Add or inspect HTML communication primitives
304
306
  pin <ids...> | --list | --clear Manage context pins
305
307
  undo / redo / history Time travel
306
308
  snapshot save|list|restore|diff|delete
307
309
  Manage snapshots
310
+ diff <snapshot-id> Compare the canvas against a snapshot
308
311
  group create|add|remove Manage groups
309
312
  web-artifact build Build bundled web artifacts
310
313
  clear --yes Clear canvas
311
314
  code-graph File dependencies
312
315
  spatial Spatial analysis
313
316
  watch Semantic watch stream
317
+ smoke Check that a running canvas works here
318
+ skills sync Sync installed copies of the bundled skills
319
+ copilot install-extension Install the GitHub Copilot app extension
314
320
 
315
321
  Run any command with --help for details and examples:
316
322
  pmx-canvas node add --help
package/src/cli/shared.ts CHANGED
@@ -47,6 +47,13 @@ export function extractGlobalTargetFlags(args: string[]): string[] {
47
47
  const arg = args[i];
48
48
  let name: 'port' | 'server-url' | null = null;
49
49
  let value: string | undefined;
50
+ if (arg === '--') {
51
+ // End of flags: everything after a bare `--` is an operand, so a literal
52
+ // `--port`-looking value is never mistaken for the global target flag.
53
+ // The separator itself is kept so parseFlags stops there too.
54
+ rest.push(...args.slice(i));
55
+ break;
56
+ }
50
57
  if (arg === '--port' || arg === '--server-url') {
51
58
  name = arg.slice(2) as 'port' | 'server-url';
52
59
  value = args[i + 1];
@@ -125,11 +132,25 @@ async function invokeOperation(name: string, input: Record<string, unknown>): Pr
125
132
 
126
133
  // ── Flag parsing ─────────────────────────────────────────────
127
134
 
128
- function parseFlags(args: string[]): { positional: string[]; flags: Record<string, string | true> } {
135
+ /**
136
+ * `options.boolFlags` marks flags that are boolean FOR THIS COMMAND. Value
137
+ * flags consume the next token verbatim (so a unified diff beginning with
138
+ * `--- a/file` survives), which makes a flag that is boolean here and
139
+ * value-taking elsewhere ambiguous — `--summary` is boolean in `history` /
140
+ * `layout` / `node get` / `validate spec` but carries text in `node add`.
141
+ * Without this per-command override, `history --summary --limit 5` would
142
+ * swallow `--limit` as the summary's value. The global set below holds flags
143
+ * that are boolean everywhere.
144
+ */
145
+ function parseFlags(
146
+ args: string[],
147
+ options: { boolFlags?: readonly string[] } = {},
148
+ ): { positional: string[]; flags: Record<string, string | true> } {
129
149
  const positional: string[] = [];
130
150
  const flags: Record<string, string | true> = {};
131
151
  // Boolean-only flags (never take a value argument)
132
- const BOOL_FLAGS = new Set([
152
+ const BOOL_FLAGS = new Set<string>([
153
+ ...(options.boolFlags ?? []),
133
154
  'help',
134
155
  'h',
135
156
  'ids',
@@ -168,14 +189,22 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
168
189
  ]);
169
190
  for (let i = 0; i < args.length; i++) {
170
191
  const arg = args[i];
192
+ // Conventional end-of-flags separator: the rest is positional, never flags.
193
+ if (arg === '--') {
194
+ positional.push(...args.slice(i + 1));
195
+ break;
196
+ }
171
197
  if (arg.startsWith('--')) {
172
198
  const eq = arg.indexOf('=');
173
199
  if (eq !== -1) {
174
200
  flags[arg.slice(2, eq)] = arg.slice(eq + 1);
175
201
  } else {
176
202
  const key = arg.slice(2);
177
- // If not a boolean flag and next arg exists and isn't a flag, consume it as value
178
- if (!BOOL_FLAGS.has(key) && i + 1 < args.length && !args[i + 1].startsWith('-')) {
203
+ // A value flag consumes the very next token VERBATIM — including one
204
+ // that starts with `-`/`--`. Unified diffs legitimately begin with
205
+ // `--- a/file`, and the old "next token must not look like a flag"
206
+ // heuristic silently dropped that content.
207
+ if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
179
208
  flags[key] = args[++i];
180
209
  } else {
181
210
  flags[key] = true;
@@ -183,7 +212,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
183
212
  }
184
213
  } else if (arg.startsWith('-') && arg.length === 2) {
185
214
  const key = arg.slice(1);
186
- if (!BOOL_FLAGS.has(key) && i + 1 < args.length && !args[i + 1].startsWith('-')) {
215
+ if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
187
216
  flags[key] = args[++i];
188
217
  } else {
189
218
  flags[key] = true;