pmx-canvas 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/Readme.md +11 -3
  3. package/dist/canvas/index.js +67 -67
  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/state/node-factory.d.ts +29 -0
  7. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  8. package/dist/types/server/canvas-db.d.ts +0 -1
  9. package/dist/types/server/canvas-state.d.ts +14 -15
  10. package/dist/types/server/operations/composites.d.ts +6 -21
  11. package/dist/types/server/server.d.ts +1 -0
  12. package/docs/cli.md +5 -0
  13. package/docs/environment.md +2 -2
  14. package/docs/http-api.md +25 -1
  15. package/docs/mcp.md +21 -30
  16. package/docs/plans/plan-009-tech-debt-backlog.md +13 -12
  17. package/docs/screenshot.png +0 -0
  18. package/docs/tech-debt-assessment-2026-06.md +5 -5
  19. package/package.json +7 -7
  20. package/skills/pmx-canvas/SKILL.md +16 -6
  21. package/skills/pmx-canvas/evals/evals.json +2 -2
  22. package/skills/pmx-canvas/references/full-reference.md +17 -15
  23. package/src/cli/commands/history.ts +1 -1
  24. package/src/cli/commands/nodes.ts +31 -1
  25. package/src/cli/index.ts +8 -2
  26. package/src/cli/shared.ts +1 -0
  27. package/src/client/index.tsx +2 -0
  28. package/src/client/nodes/ExtAppFrame.tsx +17 -2
  29. package/src/client/state/node-factory.ts +72 -0
  30. package/src/client/state/sse-bridge.ts +209 -86
  31. package/src/client/uuid-polyfill.ts +14 -0
  32. package/src/server/canvas-db.ts +0 -8
  33. package/src/server/canvas-schema.ts +2 -8
  34. package/src/server/canvas-state.ts +51 -151
  35. package/src/server/index.ts +3 -9
  36. package/src/server/operations/composites.ts +24 -21
  37. package/src/server/operations/ops/snapshots.ts +6 -7
  38. package/src/server/operations/registry.ts +4 -1
  39. package/src/server/server.ts +170 -74
@@ -1 +1 @@
1
- export {};
1
+ import './uuid-polyfill';
@@ -51,6 +51,16 @@ export declare function buildExtAppAxBridgeScript(axToken: string, nodeId: strin
51
51
  * the host also checks event.source against the live iframe.
52
52
  */
53
53
  export declare function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string;
54
+ /**
55
+ * Base styles injected into every ext-app document (Finding Q, 0.3.4 report):
56
+ * apps aspect-fit their drawn surface to the frame width, and on narrow/tall
57
+ * tiles the leftover region shows the app document's own body background —
58
+ * pure black for the hosted Excalidraw bundle, in every engine. Making the
59
+ * app document transparent lets the host iframe's theme background
60
+ * (var(--c-panel)) show through the letterbox instead. Background only — app
61
+ * content, its drawn scene, and its own foreground styling are untouched.
62
+ */
63
+ export declare function buildExtAppSurfaceBaseStyles(): string;
54
64
  export declare function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string;
55
65
  export declare function resolveExtAppContainerDimensions(target: ExtAppHostDimensionsTarget | null | undefined, fallback: {
56
66
  width: number;
@@ -0,0 +1,29 @@
1
+ import type { CanvasNodeState } from '../types';
2
+ export declare const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], {
3
+ x: number;
4
+ y: number;
5
+ w: number;
6
+ h: number;
7
+ }> & Record<'prompt' | 'response', {
8
+ x: number;
9
+ y: number;
10
+ w: number;
11
+ h: number;
12
+ }>;
13
+ /**
14
+ * Build a canvas node with the shared client defaults. `position`/`size`
15
+ * default to the type's DEFAULT_POSITIONS entry; callers that compute
16
+ * placement (auto-placement, event-supplied geometry) pass explicit values.
17
+ * Status nodes sit at zIndex 0 (background chrome), everything else at 1.
18
+ */
19
+ export declare function makeNodeState(id: string, type: CanvasNodeState['type'], data: Record<string, unknown>, options?: {
20
+ position?: {
21
+ x: number;
22
+ y: number;
23
+ };
24
+ size?: {
25
+ width: number;
26
+ height: number;
27
+ };
28
+ dockPosition?: 'left' | 'right' | null;
29
+ }): CanvasNodeState;
File without changes
@@ -35,7 +35,6 @@ export declare function listSnapshotsFromDB(db: Database, options?: CanvasSnapsh
35
35
  export declare function deleteSnapshotFromDB(db: Database, id: string): boolean;
36
36
  export declare function writeBlobToDB(db: Database, sha256: string, jsonValue: string): number;
37
37
  export declare function readBlobFromDB(db: Database, sha256: string): string | null;
38
- export declare function hasBlobInDB(db: Database, sha256: string): boolean;
39
38
  export interface AxTimelineQuery {
40
39
  limit?: number;
41
40
  sessionId?: string;
@@ -7,7 +7,9 @@
7
7
  *
8
8
  * Persistence: canvas state auto-saves to `.pmx-canvas/canvas.db` (SQLite WAL mode)
9
9
  * in the workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
- * Legacy `.pmx-canvas/state.json` is auto-migrated on first boot.
10
+ * Legacy pre-0.2 formats (`.pmx-canvas/state.json`, `.pmx-canvas.json`,
11
+ * `.pmx-canvas-snapshots/`, loose blob files) are no longer imported as of 0.4.0 —
12
+ * the one-shot boot migration into SQLite was retired.
11
13
  */
12
14
  import { type PersistedCanvasState, type CanvasTheme, type AxTimelineQuery } from './canvas-db.js';
13
15
  import { type PmxAxActivityKind, type PmxAxElicitation, type PmxAxModeRequest, type PmxAxMode, type PmxAxCommandDescriptor, type PmxAxPolicy, type PmxAxFocusState, type PmxAxSource, type PmxAxState, type PmxAxWorkItem, type PmxAxWorkItemStatus, type PmxAxApprovalGate, type PmxAxReviewAnnotation, type PmxAxReviewKind, type PmxAxReviewSeverity, type PmxAxReviewStatus, type PmxAxReviewAnchorType, type PmxAxReviewRegion, type PmxAxEvent, type PmxAxEventKind, type PmxAxEvidence, type PmxAxEvidenceKind, type PmxAxSteeringMessage, type PmxAxHostCapability, type PmxAxTimelineSummary } from './ax-state.js';
@@ -155,7 +157,7 @@ declare class CanvasStateManager {
155
157
  private notifyChange;
156
158
  private _mutationRecorder;
157
159
  private _suppressRecordingDepth;
158
- /** Register a mutation recorder. Used by mutation-history to capture undo/redo closures. */
160
+ /** Register THE mutation recorder (single slot, last-write-wins — see note above). */
159
161
  onMutation(cb: (info: MutationRecordInfo) => void): void;
160
162
  /** Run a function with mutation recording suppressed (for undo/redo replay and computed edges). */
161
163
  withSuppressedRecording(fn: () => void): void;
@@ -171,7 +173,6 @@ declare class CanvasStateManager {
171
173
  private translateGroupChildren;
172
174
  private recomputeParentGroupBounds;
173
175
  private compactGroupChildren;
174
- private _stateFilePath;
175
176
  private _db;
176
177
  private _saveTimer;
177
178
  /** Workspace root backing persistence and workspace-relative path resolution. */
@@ -188,20 +189,9 @@ declare class CanvasStateManager {
188
189
  isBlobReference(value: unknown): value is PersistedBlobRef;
189
190
  resolveBlobReference(value: unknown): unknown;
190
191
  private externalizePersistedStateBlobs;
191
- /**
192
- * One-time migration: rename files from the pre-consolidation layout
193
- * (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
194
- * No-op when the new layout already exists.
195
- */
196
- private migrateLegacyLayout;
197
- /**
198
- * One-time migration: import state.json + snapshot JSON files + blob files
199
- * into the SQLite database. Renames originals to `.bak`.
200
- */
201
- private migrateJsonToSqlite;
202
192
  getWorkspaceRoot(): string;
203
193
  private emptyPersistedState;
204
- /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
194
+ /** Load canvas state from SQLite. Call once on server startup. */
205
195
  loadFromDisk(options?: LoadFromDiskOptions): boolean;
206
196
  /**
207
197
  * Whether this workspace's canvas DB already holds saved state. Used to gate
@@ -215,6 +205,15 @@ declare class CanvasStateManager {
215
205
  flushToDisk(): void;
216
206
  /** Write current state to SQLite immediately. */
217
207
  private saveToDisk;
208
+ private _lastPersistenceError;
209
+ /** Health view of state persistence: ok until a save fails, ok again after the next success. */
210
+ get persistenceHealth(): {
211
+ ok: boolean;
212
+ lastError: {
213
+ message: string;
214
+ at: string;
215
+ } | null;
216
+ };
218
217
  /** Close the SQLite database cleanly. Call on server shutdown. */
219
218
  close(): void;
220
219
  private get snapshotsDir();
@@ -16,27 +16,12 @@
16
16
  * operation (plan-005 slices 1–7 + plan-008 Wave 1) that is now reachable ONLY
17
17
  * through its composite (or `canvas_batch`).
18
18
  *
19
- * Deferred to v0.4: the `canvas_snapshot` composite (still a name collision —
20
- * see below). Its 6 legacy snapshot standalones (`canvas_snapshot`,
21
- * `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`,
22
- * `canvas_gc_snapshots`, `canvas_diff`) stay registered in v0.3.0 but are marked
23
- * deprecated (description-prefixed) per docs/api-stability.md's
24
- * deprecate-one-minor-before-removal rule. (`canvas_webview` shipped in plan-008
25
- * Wave 3 via runner injection; `canvas_app` shipped in Wave 4 — open-mcp-app /
26
- * diagram / build-artifact. Wave 5 folded the last three legacy tools
27
- * deprecate-only — NO per-action input-injection mechanism was needed:
28
- * `canvas_add_html_node` / `canvas_add_html_primitive` → `canvas_node` action
29
- * "add" (type:"html" [+ primitive]); `canvas_refresh_webpage_node` → `canvas_node`
30
- * action "update" (refresh:true). `canvas_screenshot` stays standalone — it
31
- * returns a binary image payload the composite/registry JSON wire shape does not
32
- * model.)
33
- *
34
- * Not shipped here: the `canvas_snapshot` composite (plan-006 #7). Its target
35
- * name is ALREADY a legacy standalone tool (the save-snapshot tool, op
36
- * `snapshot.save`), so it cannot be added additively without a name clash, and
37
- * repurposing `canvas_snapshot` to be action-discriminated now would break
38
- * existing callers. It lands in v0.4, in the same change that removes the 6
39
- * kept-but-deprecated legacy snapshot tools and frees the name.
19
+ * v0.4.0 completed the fold: the `canvas_snapshot` composite shipped (its name
20
+ * was held by the legacy save-snapshot standalone through v0.3.x, which — with
21
+ * the other 5 snapshot standalones — was deprecated in 0.3.0 and removed here
22
+ * per docs/api-stability.md's deprecate-one-minor-before-removal rule).
23
+ * `canvas_screenshot` stays standalone — it returns a binary image payload the
24
+ * composite/registry JSON wire shape does not model.
40
25
  *
41
26
  * This module must never import server.ts or index.ts.
42
27
  */
@@ -24,6 +24,7 @@
24
24
  * - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
25
25
  * - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
26
26
  * - GET /api/workbench/events -> SSE event stream
27
+ * - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
27
28
  * - GET /api/workbench/state -> workbench state snapshot
28
29
  * - POST /api/workbench/intent -> workbench intents
29
30
  * - GET /api/workbench/webview -> Bun.WebView automation status
package/docs/cli.md CHANGED
@@ -51,6 +51,11 @@ pmx-canvas node add --help --type webpage --json
51
51
  pmx-canvas external-app add --kind excalidraw --title "Diagram"
52
52
 
53
53
  pmx-canvas edge add --from-search "DVT O3 — GitOps" --to-search "deep work trend" --type relation
54
+
55
+ # Updates return a compact { ok, id, position, size, updatedFields } envelope;
56
+ # pass --full for the complete node payload (ext-app payloads can be huge).
57
+ pmx-canvas node update <node-id> --width 840 --height 620
58
+ pmx-canvas node update <node-id> --title "Renamed" --full
54
59
  ```
55
60
 
56
61
  `--from-search` / `--to-search` must each resolve to exactly one node — broad
@@ -8,7 +8,7 @@ optional; defaults are listed.
8
8
  | Variable | Read by | Effect |
9
9
  |---|---|---|
10
10
  | `PMX_WEB_CANVAS_PORT` | server, CLI `serve` | Preferred server port (default `4313`). CLI `--port` wins over it. |
11
- | `PMX_CANVAS_PORT` | agent CLI, MCP transport | Client-side default target port for CLI commands and the `--mcp` same-workspace lookup. Does not change what the server binds. |
11
+ | `PMX_CANVAS_PORT` | all entry points | Client-side target port for CLI commands and the `--mcp` lookup; as of 0.4.0 the server also falls back to it when `--port`/`PMX_WEB_CANVAS_PORT` are unset, so one var can drive the whole stack. |
12
12
  | `PMX_CANVAS_URL` | agent CLI, MCP transport | Full base URL target (wins over `PMX_CANVAS_PORT`). |
13
13
  | `PMX_CANVAS_WORKSPACE_ROOT` | server, MCP transport | Pins the workspace root for daemon binds and the MCP same-workspace lookup, overriding the launch cwd. Set it when a host spawns `--mcp` from an incidental dir. |
14
14
  | `PMX_CANVAS_ALLOW_WORKSPACE_SPLIT` | MCP transport | `1` forces a separate canvas instead of attaching to a different-workspace daemon on the preferred port. |
@@ -18,7 +18,7 @@ optional; defaults are listed.
18
18
  | Variable | Read by | Effect |
19
19
  |---|---|---|
20
20
  | `PMX_CANVAS_DB_PATH` | server | Overrides the SQLite path (default `.pmx-canvas/canvas.db`). |
21
- | `PMX_CANVAS_STATE_FILE` | server | Legacy JSON state path (back-compat; a `.db` value is treated as the DB path). |
21
+ | `PMX_CANVAS_STATE_FILE` | server | `.db`-path alias only as of 0.4.0 (non-`.db` values are ignored with a boot warning; the pre-0.2 JSON import was removed). |
22
22
  | `PMX_CANVAS_BLOB_THRESHOLD_BYTES` | server | Node payloads above this many JSON bytes are stored as blobs (default `2048`). |
23
23
 
24
24
  ## Server behavior
package/docs/http-api.md CHANGED
@@ -5,7 +5,9 @@ any language. Default base URL: `http://localhost:4313`.
5
5
 
6
6
  A non-empty request body that is not valid JSON returns
7
7
  `400 { "ok": false, "error": "Malformed JSON body." }` on every route; empty
8
- bodies are treated as an empty request.
8
+ bodies are treated as an empty request. As of 0.4.0 every error response is a
9
+ JSON envelope of that same shape (`{ ok: false, error }`, status unchanged) —
10
+ no plain-text errors remain.
9
11
 
10
12
  ## Canvas state
11
13
 
@@ -321,6 +323,28 @@ curl -N http://localhost:4313/api/workbench/events
321
323
  The browser, the CLI `watch` command, and the MCP resource notifications
322
324
  all consume this stream. Auto-reconnect with exponential backoff.
323
325
 
326
+ ## Polling transport (proxy-safe)
327
+
328
+ Some proxies (e.g. the Amp orb portal) buffer streaming responses and only
329
+ flush them on close, so the SSE stream above delivers nothing. The polling
330
+ transport returns the same events as short-lived JSON responses that pass
331
+ through any proxy:
332
+
333
+ ```bash
334
+ # No `since`: a full connect snapshot (same events an SSE connect sends) + the
335
+ # current cursor. With `since=<seq>`: only events after that cursor.
336
+ curl "http://localhost:4313/api/workbench/poll"
337
+ curl "http://localhost:4313/api/workbench/poll?since=42"
338
+ ```
339
+
340
+ Responses are `{ ok, transport: "poll", snapshot, seq, events }` — poll again
341
+ with `since=<seq>` every couple of seconds. A `since` from a previous server
342
+ run or one that fell off the bounded event ring recovers with a fresh
343
+ snapshot. The browser client uses this automatically: if the SSE stream
344
+ produces no event within 3 seconds of connecting, it switches to polling.
345
+ Force a transport with `/workbench?transport=poll` (or `transport=sse` to
346
+ disable the fallback).
347
+
324
348
  ## Time travel
325
349
 
326
350
  ```bash
package/docs/mcp.md CHANGED
@@ -1,19 +1,17 @@
1
1
  # MCP reference
2
2
 
3
- PMX Canvas ships an MCP stdio server with **27 tools** + **14 core resources**,
3
+ PMX Canvas ships an MCP stdio server with **22 tools** + **14 core resources**,
4
4
  plus per-skill resources at `canvas://skills/<name>`. The server emits
5
5
  `notifications/resources/updated` when canvas state changes — humans pin
6
6
  nodes in the browser, agents are notified immediately.
7
7
 
8
- > **Consolidation completed in v0.3.0 (plan-006/008).** The MCP surface shrank
9
- > from 84 tools to 27: 15 action-discriminated **composites** (recommended —
10
- > see below) plus 12 standalone tools. The 57 legacy single-purpose tools that
11
- > the composites replaced were removed in v0.3.0 per
12
- > [`api-stability.md`](api-stability.md); their descriptions carried
13
- > `Deprecated:` prefixes for one minor before removal. The 6 snapshot tools
14
- > remain registered as deprecated standalones — they fold into a
15
- > `canvas_snapshot` composite in v0.4 (see [Standalone tools](#standalone-tools)
16
- > below). **Prefer the composites.**
8
+ > **Consolidation complete (plan-006/008).** The MCP surface shrank from 84
9
+ > tools to 22: 16 action-discriminated **composites** (recommended — see
10
+ > below) plus 6 standalone tools. The 57 legacy single-purpose tools the
11
+ > composites replaced were removed in v0.3.0, and v0.4.0 finished the fold by
12
+ > shipping the `canvas_snapshot` composite and removing the 6 deprecated
13
+ > snapshot standalones — each step per [`api-stability.md`](api-stability.md)'s
14
+ > deprecate-one-minor-before-removal rule. **Prefer the composites.**
17
15
 
18
16
  ## Connect
19
17
 
@@ -54,6 +52,7 @@ its `action` to the same operation the legacy tool used, so results are identica
54
52
  | `canvas_ax_timeline` | `read` · `record-event` · `add-evidence` · `send-steering` | `canvas_get_ax_timeline`, `canvas_record_ax_event`, `canvas_add_evidence`, `canvas_send_steering` |
55
53
  | `canvas_ax_delivery` | `claim` · `mark` | `canvas_claim_ax_delivery`, `canvas_mark_ax_delivery` |
56
54
  | `canvas_intent` | `signal` · `update` · `clear` | _(new — Ghost Cursor of Intent; no legacy standalone tool)_ |
55
+ | `canvas_snapshot` | `save` · `list` · `restore` · `delete` · `gc` · `diff` | `canvas_snapshot` (legacy save tool), `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`, `canvas_gc_snapshots`, `canvas_diff` — removed in v0.4.0 after one deprecated minor |
57
56
 
58
57
  ### `canvas_intent` — Ghost Cursor of Intent
59
58
 
@@ -89,20 +88,16 @@ tools). `canvas_screenshot` also stays standalone — it returns a binary image
89
88
  the composite/registry JSON wire shape does not model. (Wave 5 folded
90
89
  `canvas_refresh_webpage_node` → `canvas_node { action: "update", refresh: true }` after
91
90
  fixing `node.update`'s `formatResult` to surface a FAILED refresh as `isError` +
92
- `{ ok:false, error }` instead of masking it as a false `{ ok:true }`.) The 6
93
- snapshot tools are the one deferred fold: a `canvas_snapshot` composite is
94
- planned for v0.4 (the name collides with the legacy `canvas_snapshot` save
95
- tool, and [`api-stability.md`](api-stability.md) requires deprecating a tool
96
- for one minor before removing it) — they stay registered as deprecated
97
- standalones until then.
91
+ `{ ok:false, error }` instead of masking it as a false `{ ok:true }`.) The snapshot
92
+ fold completed in v0.4.0: `canvas_snapshot { action: "save" | "list" | "restore" |
93
+ "delete" | "gc" | "diff" }` replaced the 6 deprecated snapshot standalones.
98
94
 
99
95
  ## Standalone tools
100
96
 
101
- 12 tools that intentionally stay outside the composites — either because
102
- folding them would hurt (distinct callers, binary payloads, trust-boundary or
103
- execution-intent semantics), or because they're mid-deprecation pending a
104
- v0.4 composite. See [Migration reference](#migration-reference) below for the
105
- 57 tools these composites replaced.
97
+ 6 tools that intentionally stay outside the composites — folding them would
98
+ hurt (distinct callers, binary payloads, trust-boundary or execution-intent
99
+ semantics). See [Migration reference](#migration-reference) below for the
100
+ legacy tools the composites replaced.
106
101
 
107
102
  | Tool | Description |
108
103
  |------|-------------|
@@ -112,12 +107,6 @@ v0.4 composite. See [Migration reference](#migration-reference) below for the
112
107
  | `canvas_ax_interaction` | Submit one capability-gated AX interaction envelope (`{ type, sourceNodeId, payload }`) that maps onto an AX operation; the server re-validates and clamps sandboxed surfaces to their own node |
113
108
  | `canvas_ingest_activity` | Ingest a harness-forwarded agent activity (tool/session event); the board auto-reacts with kind-driven, overridable defaults (failure → work item + review + evidence; `tool-result`+success → evidence). Makes AX bidirectional |
114
109
  | `canvas_screenshot` | Capture a screenshot from the active workbench automation session |
115
- | `canvas_snapshot` | **Deprecated** → folds into the `canvas_snapshot` composite in v0.4 (actions `save\|list\|restore\|delete\|gc\|diff`). Save current canvas as a named snapshot |
116
- | `canvas_list_snapshots` | **Deprecated** → folds into the `canvas_snapshot` composite in v0.4 (actions `save\|list\|restore\|delete\|gc\|diff`). List saved snapshots, bounded to the newest 20 by default |
117
- | `canvas_restore` | **Deprecated** → folds into the `canvas_snapshot` composite in v0.4 (actions `save\|list\|restore\|delete\|gc\|diff`). Restore canvas from a saved snapshot |
118
- | `canvas_delete_snapshot` | **Deprecated** → folds into the `canvas_snapshot` composite in v0.4 (actions `save\|list\|restore\|delete\|gc\|diff`). Delete a saved snapshot |
119
- | `canvas_gc_snapshots` | **Deprecated** → folds into the `canvas_snapshot` composite in v0.4 (actions `save\|list\|restore\|delete\|gc\|diff`). Delete old snapshots while keeping the newest N |
120
- | `canvas_diff` | **Deprecated** → folds into the `canvas_snapshot` composite in v0.4 (actions `save\|list\|restore\|delete\|gc\|diff`). Compare current canvas vs a saved snapshot |
121
110
 
122
111
  `canvas_node { action: "add", type: "html" }` accepts optional `summary`, `agentSummary`,
123
112
  `embeddedNodeIds`, and `embeddedUrls`. PMX also derives a bounded text summary from visible
@@ -125,7 +114,9 @@ HTML, so rich HTML nodes stay searchable and readable in pinned/spatial context.
125
114
 
126
115
  ## Migration reference
127
116
 
128
- The 57 legacy single-purpose tools below were removed in v0.3.0. Each row is
117
+ The 57 legacy single-purpose tools below were removed in v0.3.0, and v0.4.0
118
+ additionally removed the 6 deprecated snapshot standalones (see the
119
+ `canvas_snapshot` composite row above for their replacements). Each row is
129
120
  the composite call that replaces it — kept as a lookup table for anyone
130
121
  migrating an older integration.
131
122
 
@@ -304,5 +295,5 @@ CLI and MCP are kept aligned for the main canvas operations: node and edge
304
295
  creation, graph/json-render/html/html-primitive nodes, web artifacts, external apps, groups,
305
296
  batch builds, layout validation, snapshots, search, focus, pins, undo/redo,
306
297
  semantic watch streams, WebView automation, and daemon/server control where
307
- it applies. A few agent-native capabilities — resource subscriptions and
308
- `canvas_diff` — remain MCP-only.
298
+ it applies. A few agent-native capabilities — such as resource
299
+ subscriptions — remain MCP-only.
@@ -11,7 +11,8 @@ Each item is tagged by shape: **project** (multi-day, own plan/PR), **decision**
11
11
  The remaining items were re-analyzed with pros/cons and tiered; the maintainer approved this ordering:
12
12
 
13
13
  - **Tier 1 (do):** CLI raw fetches → operation invoker **together with** the H3 parser/module split (same file, one project); H7 test coverage **as a prerequisite** for any client refactor; M2 AX-bridge dedup (first client task after H7); the M8 flake subset (mock the Excalidraw network tests, share `getAvailablePort`, Playwright `retries`/`forbidOnly`, `e2e-cli` into CI). *C1b done (2026-07-08):* all 60 raw `api()` calls migrated onto the invoker (9-agent verified mapping; `webartifact.build` gained `errorBodyAsResult`; batch failure prints the envelope + exits 1; the `api()` helper is deleted; two deliberate raw `fetch` sites remain — binary screenshot, SSE watch). *H3 module split done (2026-07-09):* `agent.ts` is a 227-line entry over `src/cli/shared.ts` (cycle-free core: registry, parser, transport) + 11 `src/cli/commands/*` domain modules, moved byte-identically; ax-parity reads the whole cli tree. **Parser sub-item reshaped:** `parseFlags`/`BOOL_FLAGS` is now single-sited in shared.ts; `index.ts` keeps its intentionally tiny fixed-flag launcher reader (different domain — server launch, not agent commands). The spec-driven per-command flag parser (`defineCommand`) remains the ideal but is a redesign of all 83 command surfaces — deferred to a future CLI UX change, not worth manufactured churn now.
14
- - **Tier 2 (reshaped):** the PmxCanvas→executeOperation collapse is **not** mechanical — `executeOperation` is async while 57/76 SDK methods are sync with typed returns, so a full collapse is a breaking SDK change. Verdict: migrate only the already-async/void methods now; decide the full async-SDK question as a **v0.4 breaking item** with the `canvas_snapshot` composite. Post-registry the sync methods are thin presentation over the shared cores (single logic site) — not logic duplication. H5 (sse-bridge factory) proceeds **after H7**. M6 gets the middle path: runtime per-action required-field errors, no `oneOf` schema break (many MCP hosts handle `oneOf` poorly). M1 gets the minimum four (minify, devtools→devDeps, lazy-load, replace the python mtime gate); the React-stack decision stays open.
14
+ - **Tier 2 — async-SDK question DECIDED NO (2026-08-03, v0.4.0):** the sync `PmxCanvas` facade is the contract. Post-registry the 57 sync methods are thin presentation over the shared operation cores (single logic site — the tier's own analysis), so forcing them async would break every SDK consumer to remove zero duplication. The already-async/void methods dispatch through `executeOperation`; the rest stay sync by design. No further collapse is planned.
15
+ - **Tier 2 (original analysis):** the PmxCanvas→executeOperation collapse is **not** mechanical — `executeOperation` is async while 57/76 SDK methods are sync with typed returns, so a full collapse is a breaking SDK change. Verdict: migrate only the already-async/void methods now; decide the full async-SDK question as a **v0.4 breaking item** with the `canvas_snapshot` composite. Post-registry the sync methods are thin presentation over the shared cores (single logic site) — not logic duplication. H5 (sse-bridge factory) proceeds **after H7**. M6 gets the middle path: runtime per-action required-field errors, no `oneOf` schema break (many MCP hosts handle `oneOf` poorly). M1 gets the minimum four (minify, devtools→devDeps, lazy-load, replace the python mtime gate); the React-stack decision stays open.
15
16
  - **Tier 3 (cheap batch):** CI check that CLAUDE.md/AGENTS.md stay identical (instead of doc generation); document `PMX_DATA` + the `PMX_CANVAS_JSON_RENDER_*`/`PMX_MCP_APP_HOST_*` env families; publish.yml verifies the tag SHA passed test.yml; `@types/turndown` → devDependencies; L1 dead `ContextPinHud`; L4 one-shot migration marker; L6 timer-leak fixes.
16
17
  - **Tier 4 (closed as by-design / parked):** the six server-coupled routes (nothing to dedup — injection machinery for single-copy handlers isn't worth it); M3 ExtAppFrame refactor (the timing hacks are load-bearing WebKit fixes — document invariants, enter only when a feature forces it); M7 notification fan-out, L2, L3, `noUncheckedIndexedAccess` (parked); M10 packaging closed after the `@types` one-liner (`bin`→`src` under Bun and shipped `dist/` are both load-bearing by design).
17
18
 
@@ -19,10 +20,10 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
19
20
 
20
21
  | Item | Source | What's open | Shape |
21
22
  |---|---|---|---|
22
- | Complete the registry migration | C1 | Fold the remaining hand-written routes in `server.ts` into operations; collapse the 76-method `PmxCanvas` onto `executeOperation`; migrate the CLI's 60 raw `fetch` calls onto the operation invoker. **Slice 1 done post-0.3.0:** the 7 `/api/ext-app/*` routes moved to `ops/ext-app.ts` (~290 lines out of `server.ts`, the 5 clone handlers — old M5 — collapsed to one parametrized definition; wire envelopes byte-identical, no new MCP tools). **Slice 2 done:** the 15 ax wire routes (8 GET lists + `ax/context` + `ax/surface-snapshot` + `pinned-context` + `code-graph` + `ax/activity` + `ax/interaction` + legacy `PATCH /api/canvas/ax`) moved to `ops/ax-read.ts` (~370 more lines out of `server.ts`); `open-external` stays hand-written (coupled to the live server port + system browser opener). **Slice 3 done:** theme GET/POST, canvas update, viewport, annotation POST, and node `:id/refresh` moved to the registry (`ops/canvas-wire.ts`, plus additions to `ops/viewport.ts`, `ops/annotation.ts`, `ops/nodes.ts`); the two dispatch gates unified into one `/api/` gate; `server.ts` is at ~2,980 lines (from 3,873 at the audit). **Remaining routes (~20)** are the deliberate poor fits (SSE, binary, HTML/static, file bodies) plus four server-coupled handlers: `prompt` + workbench `state`/`open`/`intent` (module-local `primaryWorkbench*` session state), `render` (Excalidraw URL-normalizer chain), `open-external` (live port + system browser). Those need state injection like the webview runner — a deliberate follow-up, not mechanical. After routes, the bigger halves remain: `PmxCanvas` (76 methods) onto `executeOperation`, and the CLI's raw fetches onto the invoker. | project (in progress) |
23
- | One HTTP envelope | M4 residual | Registry ops return bare bodies, legacy ext-app handlers `{ok:true,result}`, 404s plaintext. **Reclassified v0.4-breaking (2026-08-03):** unifying changes response shapes that the Copilot adapter and the external test harnesses parse — it cannot ship in a patch. Bundle with the other v0.4 breaking items (`canvas_snapshot` composite, async-SDK decision). The `/health` degraded flag for the 63 catch→warn→continue persistence sites is additive and can ship earlier. | project (v0.4) |
23
+ | Complete the registry migration | C1 | Fold the remaining hand-written routes in `server.ts` into operations; collapse the 76-method `PmxCanvas` onto `executeOperation`; migrate the CLI's 60 raw `fetch` calls onto the operation invoker. **Slice 1 done post-0.3.0:** the 7 `/api/ext-app/*` routes moved to `ops/ext-app.ts` (~290 lines out of `server.ts`, the 5 clone handlers — old M5 — collapsed to one parametrized definition; wire envelopes byte-identical, no new MCP tools). **Slice 2 done:** the 15 ax wire routes (8 GET lists + `ax/context` + `ax/surface-snapshot` + `pinned-context` + `code-graph` + `ax/activity` + `ax/interaction` + legacy `PATCH /api/canvas/ax`) moved to `ops/ax-read.ts` (~370 more lines out of `server.ts`); `open-external` stays hand-written (coupled to the live server port + system browser opener). **Slice 3 done:** theme GET/POST, canvas update, viewport, annotation POST, and node `:id/refresh` moved to the registry (`ops/canvas-wire.ts`, plus additions to `ops/viewport.ts`, `ops/annotation.ts`, `ops/nodes.ts`); the two dispatch gates unified into one `/api/` gate; `server.ts` is at ~2,980 lines (from 3,873 at the audit). **Remaining routes (~20)** are the deliberate poor fits (SSE, binary, HTML/static, file bodies) plus four server-coupled handlers: `prompt` + workbench `state`/`open`/`intent` (module-local `primaryWorkbench*` session state), `render` (Excalidraw URL-normalizer chain), `open-external` (live port + system browser). Those need state injection like the webview runner — a deliberate follow-up, not mechanical. **Status (2026-08-03):** the CLI fetches are done (C1b) and the `PmxCanvas` collapse is closed by the async-SDK decision (sync facade stays). The 4 server-coupled routes (`prompt`, workbench `state`/`open`/`intent`, `render`, `open-external`) remain the only open C1 work — internal state-injection refactor, zero wire impact, deliberately post-0.4. | tail open (post-0.4) |
24
+ | One HTTP envelope | M4 residual | **Done (2026-08-03, v0.4.0):** every hand-written error response is now a JSON `{ ok:false, error }` envelope with unchanged status codes (matching the registry dispatcher); `/health` gained a `persistence` block that reports degraded durability after a failed save (ok again on the next success). The ext-app `{ok:true,result}` wrapper is KEPT deliberately — it is the app-bridge proxy contract our own panel parses; changing it bought no consumer value. | done |
24
25
  | `dist/` commit policy | C3 residual | **Decided + done post-0.3.0:** `dist/` stays committed; `test.yml` now has a `dist/types` staleness gate (rebuild + fail on drift). The `dist/canvas`/`dist/json-render` bundles are deliberately not byte-checked — bundler output varies across bun versions; their staleness story folds into M1. | done |
25
- | Skill-mirror validator | C2 residual | `validate:agent-skills` exits 0 when the (local-only, gitignored) trees are absent. Docs are honest about this now; decide whether to repoint, gate, or delete the check. | decision |
26
+ | Skill-mirror validator | C2 residual | **Decided + done (2026-08-03):** the script prints a labeled SKIPPED notice when trees are absent (local behavior unchanged), and the permanently-no-op CI step was removed with a comment. | done |
26
27
  | Generate CLAUDE.md/AGENTS.md from one source | M9 residual | **Done (tier-3 batch):** CI fails on any CLAUDE.md/AGENTS.md byte drift (`test.yml` identity gate) — the cheap alternative to generation the decision analysis chose. Generation/symlinking stays unnecessary while the gate holds. | done |
27
28
 
28
29
  ## Phase 2 — make the safety net real
@@ -30,19 +31,19 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
30
31
  | Item | Source | What's open | Shape |
31
32
  |---|---|---|---|
32
33
  | Lint/format tooling | H6 | **Done post-0.3.0:** Biome 2.5 adopted — `bun run lint` / `lint:fix`, wired into `release:check` (making RELEASE.md's "lints" claim true) and CI. First-run format sweep landed as its own mechanical commit; 7 lint errors hand-fixed (incl. a real conditional-hooks bug in `SelectionBar.tsx`). Deliberately disabled rules to revisit: a11y group, `useExhaustiveDependencies` (misfires on Preact signals), `noNonNullAssertion` (143 deliberate uses), `noArrayIndexKey` (13 sites). ~77 warnings (unused imports/vars, optional-chain) left as a burn-down list — warnings don't fail the gate. | done |
33
- | Client + server unit coverage | H7 | **Done post-0.3.3 (first tranche):** happy-dom + @testing-library/preact harness under `tests/client/` (own preload so DOM globals never leak into server suites); 20 client render tests incl. the SelectionBar conditional-hooks regression; `semantic-attention` (25 tests) and `canvas-schema` (21 tests incl. an M12 stale-tool-drift invariant) covered; tests are TYPECHECKED (`tsconfig.tests.json`, 270 latent errors fixed, wired into `typecheck` + CI); coverage now GATES via `scripts/check-coverage.ts` total floors (bun's bunfig threshold is per-file and its table form is silently ignored — documented). **Remaining (opportunistic):** dedicated tests for `ax-state-manager.ts` and `mcp-app-host.ts`, and growing the client suite alongside H5/M2 work. | first tranche done |
34
+ | Client + server unit coverage | H7 | **Done post-0.3.3 (first tranche):** happy-dom + @testing-library/preact harness under `tests/client/` (own preload so DOM globals never leak into server suites); 20 client render tests incl. the SelectionBar conditional-hooks regression; `semantic-attention` (25 tests) and `canvas-schema` (21 tests incl. an M12 stale-tool-drift invariant) covered; tests are TYPECHECKED (`tsconfig.tests.json`, 270 latent errors fixed, wired into `typecheck` + CI); coverage now GATES via `scripts/check-coverage.ts` total floors (bun's bunfig threshold is per-file and its table form is silently ignored — documented). **Done (2026-08-03):** dedicated suites for `ax-state-manager.ts` (39 tests, injected-deps harness + in-memory SQLite) and `mcp-app-host.ts` (15 tests; stateful paths in hermetic subprocesses). Unit-test scripts now pin `PMX_MCP_APP_HOST_STATE_FILE` to a scratch path so suites never touch the real home-dir state. Client suite grows alongside H5. | done |
34
35
  | Daemon port/lock lifecycle | H4 | **Fixed post-0.3.0** (`src/cli/daemon.ts` + `tests/unit/cli-daemon.test.ts`). Note the original finding was partly wrong: the daemon child was already strict-port (`PmxCanvas.start` pins `allowPortFallback: false`), so fallback-blind polling could not occur. The real defects — fixed — were: the pre-check accepted any responsive `/health` as "already running" (even a foreign workspace's daemon), a TOCTOU double-spawn window, the pid recorded only after health passed (slow start → unkillable orphan), and no PID-recycling guard. The flaky `pmx-canvas-sdk.test.ts` port collision is NOT this — it's M8's copy-pasted `getAvailablePort()` racing. | done |
35
- | Test-suite structure | M8 | Integration monoliths under `tests/unit/` (`server-api` 5,213 lines, `cli-node` 2,903); `getAvailablePort()` copy-pasted in 5 files; `waitForPersistence(650ms)` fixed sleeps in 9 files + ~19 magic `Bun.sleep`s; Playwright lacks `retries`/`forbidOnly`; `test:e2e-cli` (517-line script) runs in no CI workflow. *Post-0.3.0:* `operation-parity` now binds port 0 and derives the port from the base URL (removes one reserve-then-rebind race); the shared-helper dedup and the rest remain. Also noted: `ax-parity.test.ts` is a string-matching source-analysis test whose needles broke when Biome re-wrapped `cmd(` calls — its CLI needles are now layout-independent, but any future needle should avoid depending on line layout. **Also noted (2026-07-07):** the `server-api` Excalidraw diagram tests do real network round-trips to the hosted `mcp.excalidraw.com` MCP server and intermittently time out (30s) under full parallel runs. **M8 subset done (2026-07-08):** those tests now run against the local stdio fixture via `PMX_CANVAS_EXCALIDRAW_MCP_COMMAND`; `getAvailablePort` is one shared helper; Playwright has `retries`/`forbidOnly` on CI; `e2e-cli` is a CI job. **Remaining (parked):** the `tests/unit/` monolith split and the `waitForPersistence` fixed-sleep sweep. | subset done |
36
+ | Test-suite structure | M8 | Integration monoliths under `tests/unit/` (`server-api` 5,213 lines, `cli-node` 2,903); `getAvailablePort()` copy-pasted in 5 files; `waitForPersistence(650ms)` fixed sleeps in 9 files + ~19 magic `Bun.sleep`s; Playwright lacks `retries`/`forbidOnly`; `test:e2e-cli` (517-line script) runs in no CI workflow. *Post-0.3.0:* `operation-parity` now binds port 0 and derives the port from the base URL (removes one reserve-then-rebind race); the shared-helper dedup and the rest remain. Also noted: `ax-parity.test.ts` is a string-matching source-analysis test whose needles broke when Biome re-wrapped `cmd(` calls — its CLI needles are now layout-independent, but any future needle should avoid depending on line layout. **Also noted (2026-07-07):** the `server-api` Excalidraw diagram tests do real network round-trips to the hosted `mcp.excalidraw.com` MCP server and intermittently time out (30s) under full parallel runs. **M8 subset done (2026-07-08):** those tests now run against the local stdio fixture via `PMX_CANVAS_EXCALIDRAW_MCP_COMMAND`; `getAvailablePort` is one shared helper; Playwright has `retries`/`forbidOnly` on CI; `e2e-cli` is a CI job. **Sleep sweep done (2026-08-03):** fixed persistence sleeps replaced by condition polling via a shared helper. **Remaining (post-0.4, test organization only):** the `tests/unit/` monolith split. | sleep sweep done |
36
37
  | `noUncheckedIndexedAccess` | L7 residual | Enable in tsconfig; will surface many index-access errors — treat as its own sweep. | small (noisy) |
37
38
 
38
39
  ## Phase 3 — client consolidation
39
40
 
40
41
  | Item | Source | What's open | Shape |
41
42
  |---|---|---|---|
42
- | One node factory | H5 | `sse-bridge.ts` (1,066 lines) keeps its own `DEFAULT_POSITIONS`/`makeNode` divergent from `canvas-store`; `responseToThreadMap` is not cleaned on SSE reconnect; server-side `syncEventToCanvasState` (~330 lines) mirrors it as an if/else chain with geometry defaults duplicated against `ensureDefaultDockedNodes`. Share one factory; treat `canvas-layout-update` as the single source of truth; table-drive the event sync. | project |
43
+ | One node factory | H5 | **Client half done (2026-08-03):** the `responseToThreadMap` reconnect leak is fixed (cleared in the shared per-connection reset path; mutation-verified test) and all four sse-bridge construction sites route through a shared `node-factory.ts`. **Premise correction:** `canvas-store` holds no node-creation defaults — the real divergences are client-vs-SERVER (server `defaultNodeSize` vs the client table; `ensureDefaultDockedNodes` seeding collapsed/docked vs the bridge's expanded/undocked; a same-class `serverResponseToThreadMap` leak at server.ts:2469) — all catalogued in the factory for the deferred server half (table-drive `syncEventToCanvasState`, single source of truth). | client half done |
43
44
  | Shared AX iframe bridge | M2 | **Done (2026-08-03):** `useAxSurfaceBridge` (`src/client/nodes/use-ax-surface-bridge.ts`) single-sites the sandboxed-surface trust boundary previously copy-pasted in `HtmlNode.tsx`, `McpAppNode.tsx`, `ExtAppFrame.tsx`; `src/shared/ax-surface-protocol.ts` owns the wire tags (`pmx-canvas-ax`, `-ack`, `pmx-canvas-html-node`, the ext-app boot beacon) — client parsers import them, generated surface scripts (html-surface.ts, ext-app builders, json-render viewer) interpolate them byte-identically. Client render tests pin the validation gates + submit→ack round-trip (`tests/client/use-ax-surface-bridge.test.tsx`). | done |
44
45
  | `ExtAppFrame.tsx` timing hacks | M3 | 915 lines, 12 refs, 7 `setTimeout`s, 5 rAFs, WebKit remount slot counter, 1200 ms fallback bootstrap, two near-duplicate `buildHostContext` blocks. Extract `useExtAppBridge`; single `buildHostContext`. | project |
45
- | json-render stack decision | M1 | Preact + React 19 + recharts all runtime deps; 7 exact-pinned `@json-render/*` incl. 3 devtools packages shipped; `dist/json-render/index.js` 1.37 MB and `dist/canvas/index.js` 599 KB both unminified; build script shells to python3 with a stale-bundle fallback. Minimum: minify, move devtools to devDependencies, lazy-load the viewer, replace the python mtime gate. Bigger call: separate optional package if the React stack stays. | decision + project |
46
+ | json-render stack decision | M1 | **Minimum-four done (2026-08-03):** both bundles verified minified (the audit's "unminified" was stale — React+recharts dominate the 1.31 MiB minified size); the 2 `@json-render/devtools*` packages moved to devDependencies; the viewer is already a separate page bundle (nothing to lazy-load — verified, no churn manufactured); the python mtime gate is now Bun with parity-tested exit codes. **Still open (decision):** the React-stack question — a separate optional package remains the only real size lever. | decision |
46
47
  | Client dead code / duplication | L1, L2, L3 | `ContextPinHud.tsx` unreferenced; three pure re-export shims; two parallel markdown-format stacks; `global.css` 3,746 lines with 11 `!important` vs 227 inline styles and unscaled z-indexes. | small (batchable) |
47
48
 
48
49
  ## Smaller items (no phase, pick up opportunistically)
@@ -51,13 +52,13 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
51
52
  |---|---|---|
52
53
  | Per-action composite schemas | M6 | **Done (2026-08-03, middle path per the decision analysis):** composite dispatch re-enforces each member op's required MCP fields at runtime and names the missing field (`requiredMcpFields` in `operations/mcp.ts`) — no `oneOf` schema break (many MCP hosts handle `oneOf` poorly). Advertised schemas stay all-optional by design. |
53
54
  | Packaging | M10 | `files` ships both `src/` and `dist/` (~double tarball, two import paths); `bin` points at raw `.ts` (undocumented Bun-only constraint); `@types/turndown` in runtime deps. |
54
- | Env var hygiene | M11 residual | Docs side **done (tier-3 batch)**: `docs/environment.md` covers `PMX_DATA` + the `PMX_CANVAS_JSON_RENDER_*`/`PMX_MCP_APP_HOST_*` families. Still open: `PMX_CANVAS_PORT` vs `PMX_WEB_CANVAS_PORT` applied inconsistently across entry points (partly mitigated by the global `--port`/`--server-url` CLI flags). |
55
+ | Env var hygiene | M11 residual | **Done (2026-08-03):** server startup now falls back `--port` → `PMX_WEB_CANVAS_PORT` → `PMX_CANVAS_PORT` → 4313, so setting only the client var no longer splits client and server ports; documented in `docs/environment.md`. |
55
56
  | `publish.yml` e2e gap | M11 residual | **Done (tier-3 batch):** publish.yml verifies the tagged SHA has a successful `test.yml` run before publishing. |
56
57
  | MCP notification fan-out | M7 residual | Five `canvas://` resource URIs notify on every mutation regardless of type (`mcp/server.ts:95-99`). |
57
- | Legacy migration code on every boot | L4 | **Parked deliberately (2026-07-08):** the "unconditional" run is a couple of `existsSync` stats per boot — a one-shot marker adds state machinery to save nothing measurable and risks breaking upgrades from old layouts. Retire the migration code wholesale in a future major instead. |
58
- | Suppression/mutation plumbing | L5 | Two parallel suppression depth-counters; `onMutation` is a single-slot setter wired twice (last-write-wins). |
58
+ | Legacy migration code on every boot | L4 | **Done (2026-08-03, v0.4.0 breaking window):** the pre-0.2 import path is deleted (~150 lines); fresh-boot bootstrapping untouched; `PMX_CANVAS_STATE_FILE` survives only as a `.db`-path alias with a boot warning otherwise. Verified with a legacy-artifacts smoke (files untouched, no `.bak`). |
59
+ | Suppression/mutation plumbing | L5 | **Done (2026-08-03):** the "two counters" are semantically distinct by design (emit-suppression for batch vs recording-suppression for undo/redo — merging breaks one or the other; documented at both declarations). The redundant second `onMutation` wire in `PmxCanvas.start` was deleted; the slot contract is documented. |
59
60
  | Module-global lifecycle leaks | L6 | ~20 module-level `let`s in `server.ts`; code-graph debounce timer survives `stopCanvasServer`; remote SSE watcher loop has no cancellation. |
60
- | June doc citations | L7 residual | `tech-debt-assessment-2026-06.md` cites gitignored `.learnings/` entries future maintainers can't read. |
61
+ | June doc citations | L7 residual | **Done (2026-08-03):** each cited entry's substance is inlined so the doc stands alone; one miscitation corrected. |
61
62
 
62
63
  ## Added post-0.3.3 (2026-08-03 sweep)
63
64
 
Binary file
@@ -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.0",
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
 
@@ -228,6 +229,15 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
228
229
  (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
229
230
  fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
230
231
  what the recovery queue actually did in that panel.
232
+ - A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
233
+ upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
234
+ This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
235
+ background override ships but cannot reach the app's inner root). Keep ext-app tiles near the
236
+ app's aspect (the 960x738 default, or the same ratio), or expand the node — the fullscreen
237
+ overlay renders full-bleed. The durable fix is upstream in the excalidraw-mcp app.
238
+ - Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
239
+ back from SSE to a polling transport within ~3s, so the board still boots and stays live.
240
+ Force a mode with `/workbench?transport=poll` (or `transport=sse`) when diagnosing.
231
241
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
232
242
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
233
243
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look