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.
- package/CHANGELOG.md +56 -0
- package/Readme.md +21 -4
- package/dist/canvas/global.css +68 -0
- package/dist/canvas/index.js +91 -70
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +10 -1
- package/dist/types/server/canvas-validation.d.ts +34 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/docs/cli.md +12 -0
- package/docs/http-api.md +12 -0
- package/docs/mcp.md +1 -1
- package/docs/node-types.md +2 -0
- package/docs/screenshot.png +0 -0
- package/package.json +4 -3
- package/skills/pmx-canvas/SKILL.md +56 -19
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/shared.ts +1 -0
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +2 -0
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +28 -0
- package/src/client/theme/global.css +68 -0
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-interaction.ts +6 -0
- package/src/server/canvas-operations.ts +39 -9
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +26 -0
- package/src/server/canvas-validation.ts +79 -0
- package/src/server/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +9 -1
- package/src/server/operations/ops/viewport.ts +8 -1
- package/src/server/server.ts +48 -1
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- 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` |
|
package/docs/node-types.md
CHANGED
|
@@ -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.) |
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Spatial canvas workbench for coding agents
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
package/src/cli/commands/ax.ts
CHANGED
|
@@ -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
|
-
[
|
|
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(
|
|
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
|
-
[
|
|
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
|
+
);
|