pmx-canvas 0.4.5 → 0.4.6

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 (65) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/Readme.md +21 -4
  3. package/dist/canvas/global.css +68 -0
  4. package/dist/canvas/index.js +91 -70
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/smoke.d.ts +1 -0
  9. package/dist/types/client/mermaid-entry.d.ts +11 -0
  10. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  11. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  12. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  13. package/dist/types/client/types.d.ts +2 -2
  14. package/dist/types/server/canvas-provenance.d.ts +1 -1
  15. package/dist/types/server/canvas-state.d.ts +10 -1
  16. package/dist/types/server/canvas-validation.d.ts +34 -1
  17. package/dist/types/server/html-surface.d.ts +8 -0
  18. package/dist/types/server/index.d.ts +5 -0
  19. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  20. package/dist/types/server/workboard.d.ts +46 -0
  21. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  22. package/docs/cli.md +12 -0
  23. package/docs/http-api.md +12 -0
  24. package/docs/mcp.md +1 -1
  25. package/docs/node-types.md +2 -0
  26. package/docs/screenshot.png +0 -0
  27. package/package.json +4 -3
  28. package/skills/pmx-canvas/SKILL.md +56 -19
  29. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  30. package/src/cli/agent.ts +2 -0
  31. package/src/cli/commands/ax.ts +28 -3
  32. package/src/cli/commands/smoke.ts +212 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/shared.ts +1 -0
  35. package/src/client/canvas/CanvasViewport.tsx +6 -0
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
  37. package/src/client/canvas/Minimap.tsx +2 -0
  38. package/src/client/canvas/auto-fit.ts +2 -0
  39. package/src/client/icons.tsx +4 -0
  40. package/src/client/mermaid-entry.ts +76 -0
  41. package/src/client/nodes/DiffNode.tsx +114 -0
  42. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  43. package/src/client/nodes/MermaidNode.tsx +81 -0
  44. package/src/client/state/node-factory.ts +2 -0
  45. package/src/client/state/sse-bridge.ts +28 -0
  46. package/src/client/theme/global.css +68 -0
  47. package/src/client/types.ts +6 -0
  48. package/src/mcp/server.ts +19 -1
  49. package/src/server/ax-interaction.ts +6 -0
  50. package/src/server/canvas-operations.ts +39 -9
  51. package/src/server/canvas-provenance.ts +2 -0
  52. package/src/server/canvas-schema.ts +33 -0
  53. package/src/server/canvas-state.ts +26 -0
  54. package/src/server/canvas-validation.ts +79 -0
  55. package/src/server/html-surface.ts +11 -0
  56. package/src/server/index.ts +25 -6
  57. package/src/server/operations/composites.ts +3 -2
  58. package/src/server/operations/ops/batch.ts +8 -0
  59. package/src/server/operations/ops/json-render.ts +44 -0
  60. package/src/server/operations/ops/nodes.ts +9 -1
  61. package/src/server/operations/ops/viewport.ts +8 -1
  62. package/src/server/server.ts +48 -1
  63. package/src/server/workboard.ts +159 -0
  64. package/src/shared/ax-surface-protocol.ts +8 -0
  65. package/src/shared/surface.ts +2 -0
@@ -161,9 +161,12 @@ export declare class PmxCanvas extends EventEmitter {
161
161
  data?: Record<string, unknown> | null;
162
162
  }, options?: {
163
163
  source?: PmxAxSource;
164
+ agentId?: string | null;
164
165
  }): PmxAxEvent;
165
166
  sendSteering(message: string, options?: {
166
167
  source?: PmxAxSource;
168
+ agentId?: string | null;
169
+ target?: string | null;
167
170
  }): PmxAxSteeringMessage;
168
171
  markSteeringDelivered(id: string): boolean;
169
172
  /**
@@ -198,6 +201,7 @@ export declare class PmxCanvas extends EventEmitter {
198
201
  status?: PmxAxWorkItemStatus;
199
202
  detail?: string | null;
200
203
  nodeIds?: string[];
204
+ agentId?: string | null;
201
205
  }, options?: {
202
206
  source?: PmxAxSource;
203
207
  }): PmxAxWorkItem;
@@ -206,6 +210,7 @@ export declare class PmxCanvas extends EventEmitter {
206
210
  status?: PmxAxWorkItemStatus;
207
211
  detail?: string | null;
208
212
  nodeIds?: string[];
213
+ agentId?: string | null;
209
214
  }, options?: {
210
215
  source?: PmxAxSource;
211
216
  }): PmxAxWorkItem | null;
@@ -1,6 +1,6 @@
1
1
  import { type CanvasLayout, type CanvasNodeState } from '../../canvas-state.js';
2
2
  import { type Operation } from '../types.js';
3
- export declare const NODE_TYPES: readonly ["markdown", "status", "context", "ledger", "trace", "file", "image", "mcp-app", "webpage", "html", "group"];
3
+ export declare const NODE_TYPES: readonly ["markdown", "status", "context", "ledger", "trace", "file", "diff", "mermaid", "image", "mcp-app", "webpage", "html", "group"];
4
4
  /** Per-type default node frame size (formerly copy-pasted ladders). */
5
5
  export declare function defaultNodeSize(type: string): {
6
6
  width: number;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Live AX work-item board, materialized as a json-render node.
3
+ *
4
+ * `buildWorkboardSpec` is the pure spec builder (status columns of work-item
5
+ * cards); `renderWorkboard` is the operation core (create-or-rebuild the
6
+ * single `data.workboard === true` json-render node); `refreshWorkboardNodes`
7
+ * is the live-refresh hook this module registers on `canvasState` so every
8
+ * addWorkItem/updateWorkItem rebuilds the board from the fresh work-item list
9
+ * through the SAME spec/update path the operation uses.
10
+ *
11
+ * The refresh runs inside `withSuppressedRecording` so live board rebuilds do
12
+ * not spam undo/redo history (persistence and SSE emits are unaffected).
13
+ */
14
+ import type { JsonRenderSpec } from '../json-render/server.js';
15
+ import type { PmxAxWorkItem } from './ax-state.js';
16
+ export declare const WORKBOARD_NODE_TITLE = "Work Board";
17
+ export declare const WORKBOARD_NODE_SIZE: {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ /**
22
+ * Build the workboard json-render spec from a work-item list: one column per
23
+ * status (todo → in-progress → blocked → done → cancelled; empty statuses are
24
+ * omitted), each item a Card with an agentId Badge and detail Text when
25
+ * present. An empty list renders a single muted "No work items" text block.
26
+ */
27
+ export declare function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec;
28
+ /**
29
+ * Operation core for `render.workboard`: rebuild the existing workboard node
30
+ * in place, or create one (tagged `data.workboard: true`) when none exists.
31
+ */
32
+ export declare function renderWorkboard(input?: {
33
+ x?: number;
34
+ y?: number;
35
+ }): {
36
+ ok: true;
37
+ id: string;
38
+ created: boolean;
39
+ itemCount: number;
40
+ };
41
+ /**
42
+ * Live refresh: rebuild every workboard node's spec from the current work-item
43
+ * list. Registered below as the canvasState work-item change listener. Node
44
+ * updates never touch work items, so this cannot recurse.
45
+ */
46
+ export declare function refreshWorkboardNodes(): void;
@@ -16,3 +16,10 @@ export declare const AX_SURFACE_ACK_SOURCE = "pmx-canvas-ax-ack";
16
16
  export declare const HTML_SURFACE_PUSH_SOURCE = "pmx-canvas-html-node";
17
17
  /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
18
18
  export declare const EXT_APP_BOOT_BEACON_SOURCE = "pmx-canvas-ext-app-alive";
19
+ /**
20
+ * Parent→ext-app paint probe (Finding N): the parent asks the app document to
21
+ * answer with a double-rAF `paint-tick` beacon. A frame whose rendering
22
+ * pipeline is live answers within a frame or two; silence past the timeout is
23
+ * the paint-fail signal that drives the recovery ladder.
24
+ */
25
+ export declare const EXT_APP_PAINT_PROBE_SOURCE = "pmx-canvas-ext-app-paint-probe";
package/docs/cli.md CHANGED
@@ -201,6 +201,18 @@ pmx-canvas ax policy get
201
201
  pmx-canvas ax policy set --excluded-tools shell,write --mode concise
202
202
  ```
203
203
 
204
+ ## Environment smoke check
205
+
206
+ ```bash
207
+ pmx-canvas smoke # one-command check of a running canvas
208
+ pmx-canvas smoke --port 4750 # target a specific daemon
209
+ ```
210
+
211
+ Verifies server health + workspace, CLI/server version skew, the MCP
212
+ initialize handshake, a temporary node create/search/remove round-trip (the
213
+ temp node is always removed, including on failure), and board validation.
214
+ Prints a JSON report and exits 1 if any check fails.
215
+
204
216
  ## Copilot adapter
205
217
 
206
218
  Install the bundled GitHub Copilot extension adapter into a repo. The adapter
package/docs/http-api.md CHANGED
@@ -306,6 +306,18 @@ curl -X DELETE "http://localhost:4313/api/canvas/ax/intent/<id>?settledNodeId=no
306
306
  curl -X DELETE "http://localhost:4313/api/canvas/ax/intent/<id>?vetoed=true"
307
307
  ```
308
308
 
309
+ ## Live work board
310
+
311
+ ```bash
312
+ # Materialize (or refresh) the one board node showing all AX work items by status.
313
+ curl -X POST http://localhost:4313/api/canvas/workboard \
314
+ -H 'Content-Type: application/json' -d '{}'
315
+ ```
316
+
317
+ The board is a `json-render` node tagged `data.workboard: true`. Repeat calls
318
+ are idempotent (`created: false`) and the board refreshes itself whenever a
319
+ work item changes — no polling needed.
320
+
309
321
  ## Diagrams (Excalidraw preset)
310
322
 
311
323
  ```bash
package/docs/mcp.md CHANGED
@@ -38,7 +38,7 @@ its `action` to the same operation the legacy tool used, so results are identica
38
38
  | Composite | `action` values | Replaced (removed in v0.3.0) |
39
39
  |-----------|-----------------|----------|
40
40
  | `canvas_node` | `add` · `get` · `update` · `remove` | `canvas_add_node`, `canvas_get_node`, `canvas_update_node`, `canvas_remove_node`, `canvas_add_html_node` (`add` + `type:"html"`), `canvas_add_html_primitive` (`add` + `type:"html"`, `primitive:"<kind>"`), `canvas_refresh_webpage_node` (`update` + `refresh:true`) |
41
- | `canvas_render` | `describe-schema` · `validate` · `add-json-render` · `stream-json-render` · `add-graph` | `canvas_describe_schema`, `canvas_validate_spec`, `canvas_add_json_render_node`, `canvas_stream_json_render_node`, `canvas_add_graph_node` |
41
+ | `canvas_render` | `describe-schema` · `validate` · `add-json-render` · `stream-json-render` · `add-graph` · `workboard` | `canvas_describe_schema`, `canvas_validate_spec`, `canvas_add_json_render_node`, `canvas_stream_json_render_node`, `canvas_add_graph_node` |
42
42
  | `canvas_edge` | `add` · `remove` | `canvas_add_edge`, `canvas_remove_edge` |
43
43
  | `canvas_group` | `create` · `add` · `ungroup` | `canvas_create_group`, `canvas_group_nodes`, `canvas_ungroup` |
44
44
  | `canvas_history` | `undo` · `redo` | `canvas_undo`, `canvas_redo` |
@@ -15,6 +15,8 @@ see [MCP tools](mcp.md), [HTTP API](http-api.md), and [SDK](sdk.md).
15
15
  | `ledger` | Execution ledger summary |
16
16
  | `trace` | Agent trace pills (tool calls, subagent activity) |
17
17
  | `file` | Live file viewer with auto-update on disk changes |
18
+ | `diff` | Unified diff viewer: per-file headers, hunk rows, +/− colored lines |
19
+ | `mermaid` | Mermaid diagram rendered client-side from text (flowchart, sequence, state, …) |
18
20
  | `image` | Image viewer (file paths, data URIs, URLs) |
19
21
  | `webpage` | Persisted webpage snapshot with stored URL, extracted text, refresh |
20
22
  | `mcp-app` | Tool-backed hosted MCP App iframes (Excalidraw, etc.) |
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.5",
4
- "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
3
+ "version": "0.4.6",
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",
7
7
  "module": "./src/server/index.ts",
@@ -32,7 +32,7 @@
32
32
  "dev:demo": "bun run src/cli/index.ts --demo",
33
33
  "dev:portless": "portless run --name pmx --app-port 4313 bun run src/cli/index.ts --no-open --port=4313",
34
34
  "dev:portless:demo": "portless run --name pmx --app-port 4313 bun run src/cli/index.ts --no-open --port=4313 --demo",
35
- "build:client": "bun build src/client/index.tsx --outdir dist/canvas --minify && cp src/client/theme/global.css dist/canvas/global.css && cp src/client/theme/surface-theme.css dist/canvas/surface-theme.css",
35
+ "build:client": "bun build src/client/index.tsx --outdir dist/canvas --minify && bun build src/client/mermaid-entry.ts --outdir dist/canvas --minify && cp src/client/theme/global.css dist/canvas/global.css && cp src/client/theme/surface-theme.css dist/canvas/surface-theme.css",
36
36
  "build:json-render": "bash scripts/build-json-render.sh",
37
37
  "build:types": "tsc -p tsconfig.types.json",
38
38
  "build": "bun run build:client && bun run build:json-render && bun run build:types",
@@ -68,6 +68,7 @@
68
68
  "@modelcontextprotocol/sdk": "^1.0.0",
69
69
  "@preact/signals": "^2.0.0",
70
70
  "marked": "^15.0.0",
71
+ "mermaid": "^11.16.1",
71
72
  "preact": "^10.25.0",
72
73
  "react": "^19.2.3",
73
74
  "react-dom": "^19.2.3",
@@ -52,6 +52,10 @@ Humans curate agent context by pinning nodes; agents read that curation through
52
52
  6. **Mutate through current composites.** Prefer the 16 composite MCP tools below.
53
53
  7. **Arrange and validate.** After batch changes, use `canvas_view { action: "arrange" }` when
54
54
  appropriate and always finish with `canvas_query { action: "validate" }`.
55
+ 7b. **Show the human.** After creating user-facing output, bring the camera to it: a single node
56
+ gets `canvas_view { action: "focus", id }` (pans by default), a small cluster gets
57
+ `canvas_view { action: "fit", nodeIds: [...] }` with exactly the new ids. See
58
+ **In-View Placement & Sizing** below — auto-placement is board-relative, not camera-relative.
55
59
  8. **Verify context pins.** Pin with `canvas_pin_nodes` or the browser's **Pin as context**, then
56
60
  read `canvas://pinned-context`.
57
61
  9. **Clean up temporary nodes.** Remove retry/test fixtures and restore the baseline snapshot when
@@ -67,6 +71,10 @@ pmx-canvas serve status
67
71
  ```
68
72
 
69
73
  Both surfaces report `workspace`. It must match the intended workspace root.
74
+ For a full environment check in one command (health + workspace, CLI/server
75
+ version skew, MCP initialize handshake, temp-node create/search/remove
76
+ round-trip, board validation), run `pmx-canvas smoke` (0.4.6+) — JSON report,
77
+ exit 1 on failure.
70
78
 
71
79
  - If `responsive: true` but `pidRunning: false`, treat the listener as potentially stale.
72
80
  - On mismatch, do not mutate. Start the intended workspace on an explicit free port:
@@ -97,14 +105,17 @@ Both surfaces report `workspace`. It must match the intended workspace root.
97
105
  | Persistent context cards | `context` via `canvas_node` |
98
106
  | Event/check stream | `ledger` or `trace` via `canvas_node` |
99
107
  | Local source with live updates | `file` via `canvas_node` |
108
+ | Code review / unified diff (0.4.6+) | `diff` via `canvas_node` (content = diff text; link to its file node with a `references` edge) |
109
+ | Flowchart / sequence / state diagram (0.4.6+) | `mermaid` via `canvas_node` (content = mermaid source; renders client-side, no hosted app) |
100
110
  | Image | `image` via `canvas_node` |
101
111
  | Cached URL content | `webpage` via `canvas_node` |
102
112
  | Structured UI | `json-render` via `canvas_render` |
103
113
  | Chart | `graph` via `canvas_render` |
114
+ | Live work-item board (0.4.6+) | `canvas_render { action: "workboard" }` — one board node, auto-refreshes on work-item changes |
104
115
  | Generated communication surface | HTML primitive via `canvas_node` |
105
116
  | Self-contained HTML/JS | `html` via `canvas_node` |
106
117
  | Hosted interactive MCP app | `canvas_app { action: "open-mcp-app" }` |
107
- | Excalidraw diagram | `canvas_app { action: "diagram" }` |
118
+ | Excalidraw diagram (interactive/human drawing) | `canvas_app { action: "diagram" }` — prefer `mermaid` for agent-authored diagrams |
108
119
  | Bundled React artifact | `canvas_app { action: "build-artifact" }` |
109
120
 
110
121
  Use the lightest tier that communicates the result. Do not build a web artifact when markdown,
@@ -164,6 +175,35 @@ v0.4.0 after their deprecated 0.3.x window.
164
175
  - Edge types: `flow`, `depends-on`, `relation`, `references`.
165
176
  - After manual or batch layout changes, run `canvas_query { action: "validate" }`.
166
177
 
178
+ ## In-View Placement & Sizing (required for user-facing nodes)
179
+
180
+ Auto-placement (omitting `x`/`y`) is **board-relative, not camera-relative**: it places right of
181
+ the last node or scans rows from the origin, ignoring where the human is looking. On a board with
182
+ distant nodes, an auto-placed node lands off-camera.
183
+
184
+ 1. Omit `x`/`y` only on an empty or locally dense board. Otherwise place near the human's
185
+ attention: the pinned/focused neighborhood, or explicit coordinates beside the last
186
+ user-facing output (gap ≥ 24–48 px).
187
+ 2. After creating nodes the human should see, pan the camera (operating-sequence step 7b):
188
+ `focus` for one node, `fit` with exactly the new `nodeIds` for a cluster. Use `noPan`
189
+ (`focus --no-pan`) only when you must not steal the camera.
190
+ 3. Never fit the whole board to "show" new work — on a board with outliers that miniaturizes
191
+ everything. Always pass explicit `nodeIds` to `fit`.
192
+ 4. Never leave user-facing output at far coordinates without a focus/fit.
193
+
194
+ **Size for content.** Omitting `width`/`height` gives readable per-type defaults — prefer them:
195
+ 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
197
+ readability floors (e.g. markdown 360×180, graph/json-render/html 420×280, mcp-app 480×320) —
198
+ a tiny probe size silently becomes the floor. `strictSize: true` is the only opt-out (a fixed
199
+ scrolling frame you genuinely want small). `canvas_query { action: "validate" }` additionally
200
+ reports any node below its floor as an advisory `sizeWarnings` entry — treat a non-empty list
201
+ as layout work left to do.
202
+
203
+ **Token hygiene.** For routine state checks use `canvas_ax_state { action: "get" }` WITHOUT
204
+ `includeContext` — the full AX context payload is ~10× larger; request it only when you are
205
+ actually consuming context.
206
+
167
207
  ## Context Pins
168
208
 
169
209
  Context pins are the primary human-to-agent bridge:
@@ -245,24 +285,21 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
245
285
  renders the same content and theme, but `window.PMX_AX` is not injected without the canvas
246
286
  iframe's per-mount nonce, so AX buttons only work inside the in-canvas node (0.4.4 Codex note).
247
287
  Do not tell a user a standalone tab's controls will steer the agent.
248
- - A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
249
- loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
250
- compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
251
- `sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
252
- live after the panel hydrates). Under WebKit the canvas ATTEMPTS auto-recovery: after boot,
253
- each present-at-load ext-app is remounted through a serialized, boot-aware queue (one app at a
254
- time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
255
- hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed — the 0.3.2
256
- Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
257
- success is not observable from page JS, so retries are bounded). Since 0.4.1 the canvas also
258
- fires a post-boot **repaint nudge** under WebKit (a 1px layout bounce that forces the
259
- compositor to re-rasterize the frame — the cheap half of the proven enlarge-close unstick;
260
- shows as `repaint-nudge` in the recovery trail). Keep the cautionary behavior anyway:
261
- recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
262
- the fullscreen overlay, which always paints), or open the workbench in a normal browser
263
- (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
264
- fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
265
- what the recovery queue actually did in that panel.
288
+ - A hosted ext-app (Excalidraw) node in a **WebKit** host panel (e.g. the GitHub Copilot app's
289
+ embedded WKWebView) historically could render as a black tile — a host compositor paint race
290
+ on the nested iframe, **not** a broken node (the session is healthy, `sessionStatus` is
291
+ `ready`, and it renders fine in Chrome/Codex). Since 0.4.6 the canvas runs a **paint oracle +
292
+ recovery ladder** under WebKit: present-at-load ext-apps mount strictly one at a time (the
293
+ cold burst was the trigger), each frame answers a double-rAF **paint probe** after settle, and
294
+ on silence the ladder escalates — soft-expand cycle (the automatic analogue of the proven
295
+ enlarge+close) → serialized remount → an explicit "App surface failed to paint / Retry"
296
+ affordance. The connecting overlay stays up until paint is confirmed, so a black layer is
297
+ never presented as ready. The recovery trail
298
+ (`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
301
+ tile still shows the Retry affordance, click Retry (fresh recovery budget) or expand-then-close;
302
+ attach the recovery trail when reporting.
266
303
  - Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
267
304
  while a panel stays open leaves ext-app tiles on dead frame URLs (`Frame document not found`,
268
305
  0.4.0 report Finding S) until a full workbench reload. Since 0.4.1 the browser revalidates its
@@ -95,6 +95,9 @@ Prefer extending the existing suites before inventing a one-off script.
95
95
  - When checking embedded frame fit manually, start from a clean seeded state, rebuild stale bundles,
96
96
  and inspect the actual iframe document in a browser. Server dimensions can look correct while the
97
97
  embedded content is still clipped.
98
+ - User-facing creation flows must end with the new nodes visible: after the create (plus the
99
+ skill-mandated focus/fit), a screenshot must show them without any manual pan. A `panned: true`
100
+ API result alone is not proof — verify the frame is on-screen and unobscured.
98
101
 
99
102
  ## Failure Handling
100
103
 
package/src/cli/agent.ts CHANGED
@@ -32,6 +32,7 @@ import './commands/webview.js';
32
32
  import './commands/apps.js';
33
33
  import './commands/copilot.js';
34
34
  import './commands/skills.js';
35
+ import './commands/smoke.js';
35
36
 
36
37
  // Re-exported for existing importers (tests import it from this module).
37
38
  export { extractGlobalTargetFlags };
@@ -75,6 +76,7 @@ Canvas commands:
75
76
  pmx-canvas arrange [--layout MODE] Auto-arrange (grid|column|flow)
76
77
  pmx-canvas batch [--file FILE] Run many canvas operations at once
77
78
  pmx-canvas validate Check collisions and containment issues
79
+ pmx-canvas smoke One-command environment check of a running canvas
78
80
  pmx-canvas validate spec Validate json-render/graph payloads without creating nodes
79
81
  pmx-canvas watch [options] Watch semantic canvas changes over SSE
80
82
  pmx-canvas focus <id> Pan viewport to node
@@ -53,6 +53,7 @@ cmd(
53
53
  [
54
54
  'pmx-canvas ax event add --kind tool-start --summary "ran tests"',
55
55
  'pmx-canvas ax event add --kind failure --summary "build broke" --detail "..." node1 node2',
56
+ 'pmx-canvas ax event add --kind tool-start --summary "ran tests" --agent-id builder-1',
56
57
  ],
57
58
  async (args) => {
58
59
  const { positional, flags } = parseFlags(args);
@@ -61,12 +62,14 @@ cmd(
61
62
  const kind = requireFlag(flags, 'kind', 'pmx-canvas ax event add --kind <kind> --summary <text>');
62
63
  const summary = requireFlag(flags, 'summary', 'pmx-canvas ax event add --kind <kind> --summary <text>');
63
64
  const detail = getStringFlag(flags, 'detail');
65
+ const agentId = getStringFlag(flags, 'agent-id');
64
66
 
65
67
  output(
66
68
  await invokeOperation('ax.event.record', {
67
69
  kind,
68
70
  summary,
69
71
  ...(detail ? { detail } : {}),
72
+ ...(agentId ? { agentId } : {}),
70
73
  ...(positional.length > 0 ? { nodeIds: positional } : {}),
71
74
  source: resolveAxSource(flags),
72
75
  }),
@@ -77,7 +80,11 @@ cmd(
77
80
  cmd(
78
81
  'ax steer',
79
82
  'Send a steering message to the active agent session',
80
- ['pmx-canvas ax steer "focus on the failing test first"', 'pmx-canvas ax steer --message "stop and re-plan"'],
83
+ [
84
+ 'pmx-canvas ax steer "focus on the failing test first"',
85
+ 'pmx-canvas ax steer --message "stop and re-plan"',
86
+ 'pmx-canvas ax steer "wave 2 is unblocked" --agent-id orchestrator --target builder-1',
87
+ ],
81
88
  async (args) => {
82
89
  const { positional, flags } = parseFlags(args);
83
90
  if (flags.help || flags.h) return showCommandHelp('ax steer');
@@ -86,8 +93,17 @@ cmd(
86
93
  if (!message) {
87
94
  die('Missing steering message', 'pmx-canvas ax steer <message>');
88
95
  }
96
+ const agentId = getStringFlag(flags, 'agent-id');
97
+ const target = getStringFlag(flags, 'target');
89
98
 
90
- output(await invokeOperation('ax.steer', { message, source: resolveAxSource(flags) }));
99
+ output(
100
+ await invokeOperation('ax.steer', {
101
+ message,
102
+ ...(agentId ? { agentId } : {}),
103
+ ...(target ? { target } : {}),
104
+ source: resolveAxSource(flags),
105
+ }),
106
+ );
91
107
  },
92
108
  );
93
109
 
@@ -374,6 +390,7 @@ cmd(
374
390
  [
375
391
  'pmx-canvas ax work add --title "Wire up auth" --status in-progress',
376
392
  'pmx-canvas ax work add --title "Review API" node1 node2',
393
+ 'pmx-canvas ax work add --title "Wire up auth" --agent-id builder-1',
377
394
  ],
378
395
  async (args) => {
379
396
  const { positional, flags } = parseFlags(args);
@@ -382,12 +399,14 @@ cmd(
382
399
  const title = requireFlag(flags, 'title', 'pmx-canvas ax work add --title <text>');
383
400
  const status = getStringFlag(flags, 'status');
384
401
  const detail = getStringFlag(flags, 'detail');
402
+ const agentId = getStringFlag(flags, 'agent-id');
385
403
 
386
404
  output(
387
405
  await invokeOperation('ax.work.create', {
388
406
  title,
389
407
  ...(status ? { status } : {}),
390
408
  ...(detail ? { detail } : {}),
409
+ ...(agentId ? { agentId } : {}),
391
410
  ...(positional.length > 0 ? { nodeIds: positional } : {}),
392
411
  source: resolveAxSource(flags),
393
412
  }),
@@ -398,7 +417,11 @@ cmd(
398
417
  cmd(
399
418
  'ax work update',
400
419
  'Update a canvas-bound AX work item by ID',
401
- ['pmx-canvas ax work update <id> --status done', 'pmx-canvas ax work update <id> --title "New title" --detail "..."'],
420
+ [
421
+ 'pmx-canvas ax work update <id> --status done',
422
+ 'pmx-canvas ax work update <id> --title "New title" --detail "..."',
423
+ 'pmx-canvas ax work update <id> --agent-id builder-2',
424
+ ],
402
425
  async (args) => {
403
426
  const { positional, flags } = parseFlags(args);
404
427
  if (flags.help || flags.h) return showCommandHelp('ax work update');
@@ -408,6 +431,7 @@ cmd(
408
431
  const title = getStringFlag(flags, 'title');
409
432
  const status = getStringFlag(flags, 'status');
410
433
  const detail = getStringFlag(flags, 'detail');
434
+ const agentId = getStringFlag(flags, 'agent-id');
411
435
 
412
436
  output(
413
437
  await invokeOperation('ax.work.update', {
@@ -415,6 +439,7 @@ cmd(
415
439
  ...(title ? { title } : {}),
416
440
  ...(status ? { status } : {}),
417
441
  ...(detail ? { detail } : {}),
442
+ ...(agentId ? { agentId } : {}),
418
443
  ...(positional.length > 1 ? { nodeIds: positional.slice(1) } : {}),
419
444
  source: resolveAxSource(flags),
420
445
  }),
@@ -0,0 +1,212 @@
1
+ // One-command environment check: does a running canvas actually work here?
2
+ //
3
+ // Runs the checks every host test report hand-rolls — health + workspace,
4
+ // MCP stdio initialize, a temp-node create/search/remove round-trip, and
5
+ // structural board validation — and reports them as a single JSON result
6
+ // with a pass/fail exit code. Designed for fresh orbs/hosts: `pmx-canvas
7
+ // smoke` answers "does the canvas work in this environment?" in one call.
8
+ //
9
+ // The command tests a RUNNING server (like every other agent command); it
10
+ // never starts one. Board validation gates only on structural issues
11
+ // (missing edge endpoints) — node overlaps are a layout choice on real
12
+ // boards, so collisions are reported but never fail the smoke.
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { HttpOperationInvoker } from '../../server/operations/index.js';
18
+ import { cmd, getBaseUrl, isRecord, output, parseFlags, showCommandHelp } from '../shared.js';
19
+
20
+ interface SmokeCheck {
21
+ name: string;
22
+ ok: boolean;
23
+ detail: string;
24
+ }
25
+
26
+ const MCP_INITIALIZE_TIMEOUT_MS = 10_000;
27
+
28
+ function cliPackageVersion(): string {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8')) as {
31
+ version?: string;
32
+ };
33
+ return pkg.version ?? 'unknown';
34
+ } catch {
35
+ return 'unknown';
36
+ }
37
+ }
38
+
39
+ async function checkHealth(baseUrl: string, cliVersion: string): Promise<SmokeCheck & { serverVersion?: string }> {
40
+ const name = 'health';
41
+ let body: unknown;
42
+ try {
43
+ const response = await fetch(`${baseUrl}/health`);
44
+ if (!response.ok) return { name, ok: false, detail: `GET /health returned HTTP ${response.status}` };
45
+ body = await response.json();
46
+ } catch (error) {
47
+ return {
48
+ name,
49
+ ok: false,
50
+ detail: `Cannot connect to pmx-canvas at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}. Start the server first: pmx-canvas --no-open`,
51
+ };
52
+ }
53
+ if (!isRecord(body) || body.ok !== true) {
54
+ return { name, ok: false, detail: `GET /health returned an unhealthy body: ${JSON.stringify(body)}` };
55
+ }
56
+ const serverVersion = typeof body.version === 'string' ? body.version : 'unknown';
57
+ const persistence = isRecord(body.persistence) && body.persistence.ok === false ? 'DEGRADED' : 'ok';
58
+ const skew =
59
+ serverVersion !== 'unknown' && serverVersion !== cliVersion
60
+ ? ` — differs from CLI v${cliVersion} (version skew)`
61
+ : '';
62
+ return {
63
+ name,
64
+ ok: true,
65
+ serverVersion,
66
+ detail: `workspace ${String(body.workspace)}, server v${serverVersion}${skew}, pid ${String(body.pid)}, persistence ${persistence}`,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Spawn the bundled MCP server over stdio (same entry `pmx-canvas --mcp`
72
+ * runs) and verify a JSON-RPC initialize handshake completes. The transport
73
+ * is newline-delimited JSON; the child is killed as soon as the response
74
+ * with our request id arrives.
75
+ */
76
+ function checkMcpInitialize(cliVersion: string): Promise<SmokeCheck> {
77
+ const name = 'mcp-initialize';
78
+ const entry = fileURLToPath(new URL('../../mcp/server.ts', import.meta.url));
79
+ return new Promise((resolveCheck) => {
80
+ const child = spawn(process.execPath, ['run', entry], {
81
+ stdio: ['pipe', 'pipe', 'ignore'],
82
+ env: process.env,
83
+ });
84
+ let settled = false;
85
+ let stdoutBuffer = '';
86
+ const finish = (ok: boolean, detail: string) => {
87
+ if (settled) return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ child.kill();
91
+ resolveCheck({ name, ok, detail });
92
+ };
93
+ const timer = setTimeout(
94
+ () => finish(false, `no initialize response within ${MCP_INITIALIZE_TIMEOUT_MS}ms`),
95
+ MCP_INITIALIZE_TIMEOUT_MS,
96
+ );
97
+ child.on('error', (error) => finish(false, `failed to spawn MCP server: ${error.message}`));
98
+ child.on('exit', (code) => finish(false, `MCP server exited before responding (code ${code ?? 'unknown'})`));
99
+ child.stdout.on('data', (chunk: Buffer) => {
100
+ stdoutBuffer += chunk.toString('utf-8');
101
+ const lines = stdoutBuffer.split('\n');
102
+ stdoutBuffer = lines.pop() ?? '';
103
+ for (const line of lines) {
104
+ if (!line.trim()) continue;
105
+ let message: unknown;
106
+ try {
107
+ message = JSON.parse(line);
108
+ } catch {
109
+ continue;
110
+ }
111
+ if (!isRecord(message) || message.id !== 1) continue;
112
+ const result = isRecord(message.result) ? message.result : null;
113
+ const serverInfo = result && isRecord(result.serverInfo) ? result.serverInfo : null;
114
+ if (serverInfo) {
115
+ finish(true, `initialize ok: ${String(serverInfo.name)} v${String(serverInfo.version)}`);
116
+ } else {
117
+ finish(false, `initialize returned an error: ${JSON.stringify(message.error ?? message)}`);
118
+ }
119
+ return;
120
+ }
121
+ });
122
+ child.stdin.write(
123
+ `${JSON.stringify({
124
+ jsonrpc: '2.0',
125
+ id: 1,
126
+ method: 'initialize',
127
+ params: {
128
+ protocolVersion: '2025-06-18',
129
+ capabilities: {},
130
+ clientInfo: { name: 'pmx-canvas-smoke', version: cliVersion },
131
+ },
132
+ })}\n`,
133
+ );
134
+ });
135
+ }
136
+
137
+ async function checkNodeLifecycle(invoker: HttpOperationInvoker): Promise<SmokeCheck> {
138
+ const name = 'node-lifecycle';
139
+ const marker = `pmx-smoke-${Math.random().toString(36).slice(2, 10)}`;
140
+ const searchHits = async (): Promise<Set<string>> => {
141
+ const result = await invoker.invoke('search', { q: marker });
142
+ const results = isRecord(result) && Array.isArray(result.results) ? result.results : [];
143
+ return new Set(results.filter(isRecord).map((entry) => String(entry.id)));
144
+ };
145
+ let nodeId: string | null = null;
146
+ try {
147
+ const added = await invoker.invoke('node.add', {
148
+ type: 'markdown',
149
+ title: marker,
150
+ content: `Temporary smoke-check node (${marker}). Safe to remove.`,
151
+ x: 100_000,
152
+ y: 100_000,
153
+ });
154
+ const node = isRecord(added) && isRecord(added.node) ? added.node : null;
155
+ nodeId = node && typeof node.id === 'string' ? node.id : null;
156
+ if (!nodeId) return { name, ok: false, detail: `node.add returned no node id: ${JSON.stringify(added)}` };
157
+ const found = (await searchHits()).has(nodeId);
158
+ // Remove BEFORE reporting either way: an early return here (search miss —
159
+ // exactly the version-skew case this command exists to detect) would skip
160
+ // the catch-block cleanup and park the temp node on the user's real board.
161
+ await invoker.invoke('node.remove', { id: nodeId });
162
+ nodeId = null;
163
+ if (!found) return { name, ok: false, detail: 'search did not find the created node' };
164
+ return { name, ok: true, detail: 'create → search → remove round-trip ok' };
165
+ } catch (error) {
166
+ // Best-effort cleanup so a failed smoke never leaves its temp node behind.
167
+ if (nodeId) await invoker.invoke('node.remove', { id: nodeId }).catch(() => {});
168
+ return { name, ok: false, detail: error instanceof Error ? error.message : String(error) };
169
+ }
170
+ }
171
+
172
+ async function checkValidation(invoker: HttpOperationInvoker): Promise<SmokeCheck> {
173
+ const name = 'validate';
174
+ try {
175
+ const result = await invoker.invoke('validate.get', {});
176
+ const summary = isRecord(result) && isRecord(result.summary) ? result.summary : {};
177
+ const missingEdgeEndpoints = Number(summary.missingEdgeEndpoints ?? 0);
178
+ const detail = `${String(summary.nodes ?? '?')} nodes, ${String(summary.edges ?? '?')} edges, ${String(
179
+ summary.collisions ?? '?',
180
+ )} collisions (informational), ${missingEdgeEndpoints} broken edges`;
181
+ return { name, ok: missingEdgeEndpoints === 0, detail };
182
+ } catch (error) {
183
+ return { name, ok: false, detail: error instanceof Error ? error.message : String(error) };
184
+ }
185
+ }
186
+
187
+ cmd(
188
+ 'smoke',
189
+ 'One-command check that a running canvas works in this environment',
190
+ ['pmx-canvas smoke', 'pmx-canvas smoke --skip-mcp', 'pmx-canvas smoke --port 4750'],
191
+ async (args) => {
192
+ const { flags } = parseFlags(args);
193
+ if (flags.help || flags.h) return showCommandHelp('smoke');
194
+
195
+ const baseUrl = getBaseUrl();
196
+ const cliVersion = cliPackageVersion();
197
+ const invoker = new HttpOperationInvoker(baseUrl);
198
+ const checks: SmokeCheck[] = [];
199
+
200
+ const health = await checkHealth(baseUrl, cliVersion);
201
+ checks.push({ name: health.name, ok: health.ok, detail: health.detail });
202
+ if (health.ok) {
203
+ if (!flags['skip-mcp']) checks.push(await checkMcpInitialize(cliVersion));
204
+ checks.push(await checkNodeLifecycle(invoker));
205
+ checks.push(await checkValidation(invoker));
206
+ }
207
+
208
+ const ok = checks.every((check) => check.ok);
209
+ output({ ok, target: baseUrl, cliVersion, serverVersion: health.serverVersion ?? null, checks });
210
+ if (!ok) process.exit(1);
211
+ },
212
+ );