pmx-canvas 0.4.4 → 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 +108 -0
- package/Readme.md +52 -4
- package/dist/canvas/global.css +115 -11
- package/dist/canvas/index.js +93 -72
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/canvas/surface-theme.css +2 -2
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/skills.d.ts +1 -1
- 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/state/intent-store.d.ts +6 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-state-manager.d.ts +5 -0
- package/dist/types/server/ax-state.d.ts +9 -3
- package/dist/types/server/canvas-db.d.ts +3 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +22 -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/ax-shared.d.ts +4 -1
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-intent.d.ts +6 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/docs/cli.md +12 -0
- package/docs/environment.md +1 -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/docs/screenshots/amp-orb-portal.png +0 -0
- package/docs/screenshots/claude-code-desktop.png +0 -0
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/package.json +4 -3
- package/skills/pmx-canvas/SKILL.md +71 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
- 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/skills.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/index.ts +15 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/canvas/CanvasNode.tsx +12 -1
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
- package/src/client/canvas/IntentLayer.tsx +8 -6
- 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/intent-bridge.ts +12 -3
- package/src/client/state/intent-store.ts +47 -2
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +28 -0
- package/src/client/theme/global.css +115 -11
- package/src/client/theme/surface-theme.css +2 -2
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +22 -6
- package/src/server/ax-interaction.ts +6 -0
- package/src/server/ax-state-manager.ts +27 -6
- package/src/server/ax-state.ts +36 -3
- package/src/server/canvas-db.ts +83 -60
- 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 +78 -6
- 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/intent-registry.ts +2 -0
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/http.ts +5 -1
- package/src/server/operations/ops/ax-shared.ts +20 -1
- package/src/server/operations/ops/ax-state.ts +4 -7
- package/src/server/operations/ops/ax-timeline.ts +26 -3
- package/src/server/operations/ops/ax-work.ts +27 -1
- package/src/server/operations/ops/batch.ts +11 -1
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +8 -1
- package/src/server/operations/registry.ts +122 -1
- package/src/server/server.ts +58 -3
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-intent.ts +6 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/surface.ts +2 -0
- package/src/shared/themes.ts +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pmx-canvas-orchestration
|
|
3
|
+
description: Choreography for running multi-agent orchestration ON a PMX Canvas board — the agent host (Claude Code, Copilot, Codex) executes; the canvas is the shared graph, state, human-steering surface, and audit trail. Load this in the ORCHESTRATOR session and in EVERY subagent that touches the board.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PMX Canvas Orchestration
|
|
7
|
+
|
|
8
|
+
The division of labor is fixed: **the agent host runs the loop** (spawning, scheduling,
|
|
9
|
+
fan-out/join, retries); **the canvas is the graph** (persistent shared state, the live visual,
|
|
10
|
+
human gates and steering, the timeline). The canvas never executes anything. This skill is the
|
|
11
|
+
choreography that makes that split work — both the orchestrator and every subagent must follow
|
|
12
|
+
it, or the board silently diverges from reality.
|
|
13
|
+
|
|
14
|
+
## Identity
|
|
15
|
+
|
|
16
|
+
- Every participant picks a stable `agentId` (kebab-case role name: `orchestrator`,
|
|
17
|
+
`researcher`, `impl-auth`, …) and passes it on EVERY AX write that supports it (work items,
|
|
18
|
+
timeline events, steering). `source` stays the host label (`mcp`, `cli`, `amp`, …);
|
|
19
|
+
`agentId` is who within the host.
|
|
20
|
+
- One canvas node + one work item per subagent, created BY THE ORCHESTRATOR at spawn time
|
|
21
|
+
(title = the agentId). The subagent updates its own work item; it never creates its own
|
|
22
|
+
identity row.
|
|
23
|
+
- Tag every run: put the same `data.runId` on every NODE (work items carry no free-form data —
|
|
24
|
+
their run scoping comes from the run-prefixed `agentId`, e.g. `run7:impl-auth`), so
|
|
25
|
+
consecutive orchestrations don't interleave in the bounded timeline.
|
|
26
|
+
|
|
27
|
+
## Graph semantics
|
|
28
|
+
|
|
29
|
+
- `flow` edges = planned sequence. `depends-on` edges = blocking dependency (the visual DAG).
|
|
30
|
+
`relation`/`references` = context only — never scheduling.
|
|
31
|
+
- The orchestrator owns the topology: it creates all nodes and edges up front with one
|
|
32
|
+
`canvas_batch`, then only mutates statuses. Subagents mutate their OWN node/work item only.
|
|
33
|
+
- The host's real execution order is authoritative. When the host reorders or skips, the
|
|
34
|
+
orchestrator updates the edges — a board that contradicts execution is worse than no board.
|
|
35
|
+
|
|
36
|
+
## Status discipline
|
|
37
|
+
|
|
38
|
+
- Work-item status is the single source of step state: `todo → in-progress → done`
|
|
39
|
+
(or `blocked` / `cancelled`). Update the work item FIRST; linked nodes mirror the status automatically
|
|
40
|
+
(0.4.5+) — do not hand-edit a status node to fake progress.
|
|
41
|
+
- Signal `canvas_intent` before each phase of visible board mutation (the auto-ghost floor
|
|
42
|
+
covers forgotten signals, but only an explicit signal gives the human a veto window and your
|
|
43
|
+
reasoning).
|
|
44
|
+
|
|
45
|
+
## Gates and human input
|
|
46
|
+
|
|
47
|
+
- ALL approval gates, elicitations, and mode requests are created and awaited by the
|
|
48
|
+
ORCHESTRATOR, never by subagents. `canvas_ax_gate { action: "await" }` blocks at most ~120s
|
|
49
|
+
per call — the orchestrator re-awaits in a loop; a subagent burning turns on a gate wait is
|
|
50
|
+
a bug.
|
|
51
|
+
- A pending gate is NOT approval. Execution-class actions stay blocked until the gate is
|
|
52
|
+
explicitly `approved`.
|
|
53
|
+
|
|
54
|
+
## Steering
|
|
55
|
+
|
|
56
|
+
- Humans steer through the board; agents MUST poll for it — MCP notifications cannot wake a
|
|
57
|
+
subagent, so everything is pull. Cadence: check `canvas://ax-pending-steering` (or
|
|
58
|
+
`canvas_ax_delivery { action: "claim", consumer: "<agentId or host>" }`) between every major
|
|
59
|
+
step.
|
|
60
|
+
- Address a specific agent with the steering `target` field (0.4.5+). Untargeted steers are
|
|
61
|
+
broadcast: only the ORCHESTRATOR claims them and re-routes; subagents claim only steers
|
|
62
|
+
targeted at their own `agentId`.
|
|
63
|
+
- Mark delivery ONLY after acting on the message. The mark is compare-and-set: a
|
|
64
|
+
`delivered:false` response means another consumer owned it — drop it, do not act twice.
|
|
65
|
+
|
|
66
|
+
## Run lifecycle
|
|
67
|
+
|
|
68
|
+
1. Orchestrator: snapshot the board (`canvas_snapshot save`) before the run.
|
|
69
|
+
2. Orchestrator: one `canvas_batch` creating the run's nodes + edges (+ `data.runId`), one
|
|
70
|
+
work item per step/subagent, intent-signaled.
|
|
71
|
+
3. Spawn subagents; each loads this skill, learns its `agentId`, node id, and work-item id
|
|
72
|
+
from the spawn prompt.
|
|
73
|
+
4. Subagents: flip work item to `in-progress`, do the work, attach evidence
|
|
74
|
+
(`canvas_ax_timeline { action: "add-evidence" }`), flip to `done`, poll steering between
|
|
75
|
+
steps.
|
|
76
|
+
5. Orchestrator: join on host-side completion (the host knows the real DAG), resolve gates,
|
|
77
|
+
record a final timeline event, and validate the board (`canvas_query { action: "validate" }`).
|
|
78
|
+
6. On abort/failure: leave work items in their true state (`blocked` + a timeline failure
|
|
79
|
+
event), never green-wash.
|
|
80
|
+
|
|
81
|
+
The acceptance bar: a human watching the board and a human reading the host transcript must
|
|
82
|
+
see the same story.
|
|
@@ -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
|
}),
|
|
@@ -13,7 +13,7 @@ import { join, resolve } from 'node:path';
|
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { cmd, output, parseFlags, showCommandHelp } from '../shared.js';
|
|
15
15
|
|
|
16
|
-
export const BUNDLED_SKILLS = ['pmx-canvas', 'pmx-canvas-testing'] as const;
|
|
16
|
+
export const BUNDLED_SKILLS = ['pmx-canvas', 'pmx-canvas-testing', 'pmx-canvas-orchestration'] as const;
|
|
17
17
|
|
|
18
18
|
function listFilesRecursive(root: string, prefix = ''): string[] {
|
|
19
19
|
const out: string[] = [];
|
|
@@ -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
|
+
);
|
package/src/cli/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ const AGENT_COMMANDS = new Set([
|
|
|
67
67
|
'serve',
|
|
68
68
|
'copilot',
|
|
69
69
|
'skills',
|
|
70
|
+
'smoke',
|
|
70
71
|
]);
|
|
71
72
|
|
|
72
73
|
const firstArg = args[0] ?? '';
|
|
@@ -166,7 +167,13 @@ const serveSubcommand = firstArg === 'serve' ? (args[1] ?? '') : '';
|
|
|
166
167
|
|
|
167
168
|
if (firstArg === 'serve' && (serveSubcommand === 'status' || serveSubcommand === 'stop')) {
|
|
168
169
|
const port = parseInt(
|
|
169
|
-
readOption('port') ??
|
|
170
|
+
readOption('port') ??
|
|
171
|
+
process.env.PMX_WEB_CANVAS_PORT ??
|
|
172
|
+
process.env.PMX_CANVAS_PORT ??
|
|
173
|
+
// Amp orbs assign the portal port via $PORT; honored only under AMP_ORB
|
|
174
|
+
// so a stray PORT in normal shells never shifts the default.
|
|
175
|
+
(process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
|
|
176
|
+
'4313',
|
|
170
177
|
10,
|
|
171
178
|
);
|
|
172
179
|
const daemonLogFile = resolve(readOption('log-file') ?? `.pmx-canvas/daemon-${port}.log`);
|
|
@@ -214,7 +221,13 @@ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
|
|
|
214
221
|
} else {
|
|
215
222
|
// "serve" is also accessible via flags (backward compat)
|
|
216
223
|
const port = parseInt(
|
|
217
|
-
readOption('port') ??
|
|
224
|
+
readOption('port') ??
|
|
225
|
+
process.env.PMX_WEB_CANVAS_PORT ??
|
|
226
|
+
process.env.PMX_CANVAS_PORT ??
|
|
227
|
+
// Amp orbs assign the portal port via $PORT; honored only under AMP_ORB
|
|
228
|
+
// so a stray PORT in normal shells never shifts the default.
|
|
229
|
+
(process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
|
|
230
|
+
'4313',
|
|
218
231
|
10,
|
|
219
232
|
);
|
|
220
233
|
const demo = hasFlag('demo');
|
package/src/cli/shared.ts
CHANGED
|
@@ -140,7 +140,15 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
140
140
|
requestAnimationFrame(() => renameRef.current?.focus());
|
|
141
141
|
}, []);
|
|
142
142
|
|
|
143
|
-
const title =
|
|
143
|
+
const title =
|
|
144
|
+
(node.data.title as string) ||
|
|
145
|
+
// Untitled file nodes (created before titles were synthesized server-side)
|
|
146
|
+
// still show their filename instead of a bare type label.
|
|
147
|
+
(node.type === 'file'
|
|
148
|
+
? // Separator-agnostic: node paths are OS-native (backslashes on Windows).
|
|
149
|
+
(((node.data.path as string) || (node.data.content as string) || '').split(/[\\/]/).pop() ?? '')
|
|
150
|
+
: '') ||
|
|
151
|
+
TYPE_LABELS[node.type];
|
|
144
152
|
|
|
145
153
|
const commitRename = useCallback(
|
|
146
154
|
(value: string) => {
|
|
@@ -254,6 +262,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
254
262
|
})()}
|
|
255
263
|
</span>
|
|
256
264
|
<span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
|
|
265
|
+
{typeof node.data.axWorkStatus === 'string' && (
|
|
266
|
+
<span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
|
|
267
|
+
)}
|
|
257
268
|
{renaming ? (
|
|
258
269
|
<input
|
|
259
270
|
ref={renameRef}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { ContextNode } from '../nodes/ContextNode';
|
|
3
|
+
import { DiffNode } from '../nodes/DiffNode';
|
|
3
4
|
import { FileNode } from '../nodes/FileNode';
|
|
4
5
|
import { LedgerNode } from '../nodes/LedgerNode';
|
|
5
6
|
import { MarkdownNode } from '../nodes/MarkdownNode';
|
|
7
|
+
import { MermaidNode } from '../nodes/MermaidNode';
|
|
6
8
|
import { McpAppNode } from '../nodes/McpAppNode';
|
|
7
9
|
import { StatusNode } from '../nodes/StatusNode';
|
|
8
10
|
import { ImageNode } from '../nodes/ImageNode';
|
|
@@ -64,6 +66,10 @@ function renderNodeContent(node: CanvasNodeState) {
|
|
|
64
66
|
return <TraceNode node={node} />;
|
|
65
67
|
case 'file':
|
|
66
68
|
return <FileNode node={node} />;
|
|
69
|
+
case 'diff':
|
|
70
|
+
return <DiffNode node={node} />;
|
|
71
|
+
case 'mermaid':
|
|
72
|
+
return <MermaidNode node={node} />;
|
|
67
73
|
case 'image':
|
|
68
74
|
return <ImageNode node={node} />;
|
|
69
75
|
case 'html':
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { ContextNode } from '../nodes/ContextNode';
|
|
4
|
+
import { DiffNode } from '../nodes/DiffNode';
|
|
4
5
|
import { FileNode } from '../nodes/FileNode';
|
|
5
6
|
import { LedgerNode } from '../nodes/LedgerNode';
|
|
6
7
|
import { MarkdownNode } from '../nodes/MarkdownNode';
|
|
8
|
+
import { MermaidNode } from '../nodes/MermaidNode';
|
|
7
9
|
import { McpAppNode } from '../nodes/McpAppNode';
|
|
8
10
|
import { StatusNode } from '../nodes/StatusNode';
|
|
9
11
|
import { ImageNode } from '../nodes/ImageNode';
|
|
@@ -50,6 +52,10 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
|
|
|
50
52
|
return <TraceNode node={node} />;
|
|
51
53
|
case 'file':
|
|
52
54
|
return <FileNode node={node} expanded={expanded} />;
|
|
55
|
+
case 'diff':
|
|
56
|
+
return <DiffNode node={node} />;
|
|
57
|
+
case 'mermaid':
|
|
58
|
+
return <MermaidNode node={node} expanded={expanded} />;
|
|
53
59
|
case 'image':
|
|
54
60
|
return <ImageNode node={node} expanded={expanded} />;
|
|
55
61
|
case 'html':
|
|
@@ -64,6 +70,10 @@ function getNodeTextContent(node: CanvasNodeState): string {
|
|
|
64
70
|
switch (node.type) {
|
|
65
71
|
case 'markdown':
|
|
66
72
|
return (node.data.content as string) || '';
|
|
73
|
+
case 'diff':
|
|
74
|
+
return (node.data.content as string) || '';
|
|
75
|
+
case 'mermaid':
|
|
76
|
+
return (node.data.content as string) || '';
|
|
67
77
|
case 'file':
|
|
68
78
|
return (node.data.fileContent as string) || '';
|
|
69
79
|
case 'webpage':
|
|
@@ -59,8 +59,10 @@ function center(rect: Rect): { x: number; y: number } {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function ghostOpacity(intent: ClientIntent): number {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const base = typeof intent.confidence !== 'number' ? 0.82 : 0.4 + Math.max(0, Math.min(1, intent.confidence)) * 0.55;
|
|
63
|
+
// Auto-ghosts (server-synthesized, no deliberate signal) read lighter than
|
|
64
|
+
// an explicit canvas_intent so the two are visually distinguishable.
|
|
65
|
+
return intent.auto ? base * 0.62 : base;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|
|
@@ -89,7 +91,7 @@ function GhostInfo({ intent }: { intent: ClientIntent }) {
|
|
|
89
91
|
)}
|
|
90
92
|
<span class="intent-chip-label">{label}</span>
|
|
91
93
|
{confidencePct && <span class="intent-confidence">{confidencePct}</span>}
|
|
92
|
-
{intent.phase === 'forming' && (
|
|
94
|
+
{intent.phase === 'forming' && !intent.auto && (
|
|
93
95
|
<button
|
|
94
96
|
type="button"
|
|
95
97
|
class="intent-veto"
|
|
@@ -114,7 +116,7 @@ function GhostBox({ intent, rect }: { intent: ClientIntent; rect: Rect }) {
|
|
|
114
116
|
const typeLabel = isKnownNodeType(intent.nodeType) ? TYPE_LABELS[intent.nodeType] : 'Node';
|
|
115
117
|
return (
|
|
116
118
|
<div
|
|
117
|
-
class={`intent-ghost intent-ghost-box is-${intent.phase}`}
|
|
119
|
+
class={`intent-ghost intent-ghost-box is-${intent.phase}${intent.auto ? ' is-auto' : ''}`}
|
|
118
120
|
data-intent-id={intent.id}
|
|
119
121
|
style={{
|
|
120
122
|
left: `${rect.left}px`,
|
|
@@ -138,7 +140,7 @@ function GhostBox({ intent, rect }: { intent: ClientIntent; rect: Rect }) {
|
|
|
138
140
|
function GhostOverlay({ intent, rect, variant }: { intent: ClientIntent; rect: Rect; variant: 'remove' | 'edit' }) {
|
|
139
141
|
return (
|
|
140
142
|
<div
|
|
141
|
-
class={`intent-ghost intent-ghost-${variant} is-${intent.phase}`}
|
|
143
|
+
class={`intent-ghost intent-ghost-${variant} is-${intent.phase}${intent.auto ? ' is-auto' : ''}`}
|
|
142
144
|
data-intent-id={intent.id}
|
|
143
145
|
style={{
|
|
144
146
|
left: `${rect.left}px`,
|
|
@@ -199,7 +201,7 @@ function renderGhost(intent: ClientIntent) {
|
|
|
199
201
|
return (
|
|
200
202
|
<div
|
|
201
203
|
key={intent.id}
|
|
202
|
-
class={`intent-ghost intent-ghost-connect is-${intent.phase}`}
|
|
204
|
+
class={`intent-ghost intent-ghost-connect is-${intent.phase}${intent.auto ? ' is-auto' : ''}`}
|
|
203
205
|
data-intent-id={intent.id}
|
|
204
206
|
style={{
|
|
205
207
|
left: `${rect.left}px`,
|
|
@@ -21,6 +21,7 @@ export const AUTO_FIT_BODY_PADDING = 24;
|
|
|
21
21
|
function isIframeNode(node: CanvasNodeState): boolean {
|
|
22
22
|
return (
|
|
23
23
|
node.type === 'html' ||
|
|
24
|
+
node.type === 'mermaid' ||
|
|
24
25
|
node.type === 'json-render' ||
|
|
25
26
|
node.type === 'graph' ||
|
|
26
27
|
node.type === 'mcp-app' ||
|
|
@@ -33,6 +34,7 @@ function isIframeNode(node: CanvasNodeState): boolean {
|
|
|
33
34
|
* viewers (unbounded/scrolling content that must not drive node height). */
|
|
34
35
|
function isContentFitSurface(node: CanvasNodeState): boolean {
|
|
35
36
|
if (node.type === 'html') return node.data.presentation !== true;
|
|
37
|
+
if (node.type === 'mermaid') return true;
|
|
36
38
|
if (node.type === 'json-render' || node.type === 'graph') return true;
|
|
37
39
|
if (node.type === 'mcp-app') return node.data.viewerType === 'web-artifact';
|
|
38
40
|
return false;
|
package/src/client/icons.tsx
CHANGED
|
@@ -473,6 +473,10 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
|
|
|
473
473
|
return IconNodeResponse;
|
|
474
474
|
case 'file':
|
|
475
475
|
return IconNodeFile;
|
|
476
|
+
case 'diff':
|
|
477
|
+
return IconNodeFile;
|
|
478
|
+
case 'mermaid':
|
|
479
|
+
return IconNodeGraph;
|
|
476
480
|
case 'image':
|
|
477
481
|
return IconNodeImage;
|
|
478
482
|
case 'webpage':
|