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.
Files changed (98) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/Readme.md +52 -4
  3. package/dist/canvas/global.css +115 -11
  4. package/dist/canvas/index.js +93 -72
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/canvas/surface-theme.css +2 -2
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/skills.d.ts +1 -1
  10. package/dist/types/cli/commands/smoke.d.ts +1 -0
  11. package/dist/types/client/mermaid-entry.d.ts +11 -0
  12. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  13. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  14. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  15. package/dist/types/client/state/intent-store.d.ts +6 -0
  16. package/dist/types/client/types.d.ts +2 -2
  17. package/dist/types/server/ax-state-manager.d.ts +5 -0
  18. package/dist/types/server/ax-state.d.ts +9 -3
  19. package/dist/types/server/canvas-db.d.ts +3 -2
  20. package/dist/types/server/canvas-provenance.d.ts +1 -1
  21. package/dist/types/server/canvas-state.d.ts +22 -1
  22. package/dist/types/server/canvas-validation.d.ts +34 -1
  23. package/dist/types/server/html-surface.d.ts +8 -0
  24. package/dist/types/server/index.d.ts +5 -0
  25. package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
  26. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  27. package/dist/types/server/operations/registry.d.ts +9 -1
  28. package/dist/types/server/workboard.d.ts +46 -0
  29. package/dist/types/shared/ax-intent.d.ts +6 -0
  30. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  31. package/docs/cli.md +12 -0
  32. package/docs/environment.md +1 -0
  33. package/docs/http-api.md +12 -0
  34. package/docs/mcp.md +1 -1
  35. package/docs/node-types.md +2 -0
  36. package/docs/screenshot.png +0 -0
  37. package/docs/screenshots/amp-orb-portal.png +0 -0
  38. package/docs/screenshots/claude-code-desktop.png +0 -0
  39. package/docs/screenshots/codex-app.png +0 -0
  40. package/docs/screenshots/github-copilot-app.png +0 -0
  41. package/package.json +4 -3
  42. package/skills/pmx-canvas/SKILL.md +71 -24
  43. package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
  44. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  45. package/src/cli/agent.ts +2 -0
  46. package/src/cli/commands/ax.ts +28 -3
  47. package/src/cli/commands/skills.ts +1 -1
  48. package/src/cli/commands/smoke.ts +212 -0
  49. package/src/cli/index.ts +15 -2
  50. package/src/cli/shared.ts +1 -0
  51. package/src/client/canvas/CanvasNode.tsx +12 -1
  52. package/src/client/canvas/CanvasViewport.tsx +6 -0
  53. package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
  54. package/src/client/canvas/IntentLayer.tsx +8 -6
  55. package/src/client/canvas/Minimap.tsx +2 -0
  56. package/src/client/canvas/auto-fit.ts +2 -0
  57. package/src/client/icons.tsx +4 -0
  58. package/src/client/mermaid-entry.ts +76 -0
  59. package/src/client/nodes/DiffNode.tsx +114 -0
  60. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  61. package/src/client/nodes/MermaidNode.tsx +81 -0
  62. package/src/client/state/intent-bridge.ts +12 -3
  63. package/src/client/state/intent-store.ts +47 -2
  64. package/src/client/state/node-factory.ts +2 -0
  65. package/src/client/state/sse-bridge.ts +28 -0
  66. package/src/client/theme/global.css +115 -11
  67. package/src/client/theme/surface-theme.css +2 -2
  68. package/src/client/types.ts +6 -0
  69. package/src/mcp/server.ts +22 -6
  70. package/src/server/ax-interaction.ts +6 -0
  71. package/src/server/ax-state-manager.ts +27 -6
  72. package/src/server/ax-state.ts +36 -3
  73. package/src/server/canvas-db.ts +83 -60
  74. package/src/server/canvas-operations.ts +39 -9
  75. package/src/server/canvas-provenance.ts +2 -0
  76. package/src/server/canvas-schema.ts +33 -0
  77. package/src/server/canvas-state.ts +78 -6
  78. package/src/server/canvas-validation.ts +79 -0
  79. package/src/server/html-surface.ts +11 -0
  80. package/src/server/index.ts +25 -6
  81. package/src/server/intent-registry.ts +2 -0
  82. package/src/server/operations/composites.ts +3 -2
  83. package/src/server/operations/http.ts +5 -1
  84. package/src/server/operations/ops/ax-shared.ts +20 -1
  85. package/src/server/operations/ops/ax-state.ts +4 -7
  86. package/src/server/operations/ops/ax-timeline.ts +26 -3
  87. package/src/server/operations/ops/ax-work.ts +27 -1
  88. package/src/server/operations/ops/batch.ts +11 -1
  89. package/src/server/operations/ops/json-render.ts +44 -0
  90. package/src/server/operations/ops/nodes.ts +24 -2
  91. package/src/server/operations/ops/viewport.ts +8 -1
  92. package/src/server/operations/registry.ts +122 -1
  93. package/src/server/server.ts +58 -3
  94. package/src/server/workboard.ts +159 -0
  95. package/src/shared/ax-intent.ts +6 -0
  96. package/src/shared/ax-surface-protocol.ts +8 -0
  97. package/src/shared/surface.ts +2 -0
  98. 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
@@ -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
  }),
@@ -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') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
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') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
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
@@ -164,6 +164,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
164
164
  'files',
165
165
  'ui-prompts',
166
166
  'check',
167
+ 'skip-mcp',
167
168
  ]);
168
169
  for (let i = 0; i < args.length; i++) {
169
170
  const arg = args[i];
@@ -140,7 +140,15 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
140
140
  requestAnimationFrame(() => renameRef.current?.focus());
141
141
  }, []);
142
142
 
143
- const title = (node.data.title as string) || TYPE_LABELS[node.type];
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
- if (typeof intent.confidence !== 'number') return 0.82;
63
- return 0.4 + Math.max(0, Math.min(1, intent.confidence)) * 0.55;
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`,
@@ -36,6 +36,8 @@ function getNodeColors(): Record<CanvasNodeState['type'], string> {
36
36
  ledger: t.dim,
37
37
  trace: t.purple,
38
38
  file: t.accent,
39
+ diff: t.ok,
40
+ mermaid: t.purple,
39
41
  image: t.ok,
40
42
  html: t.warn,
41
43
  group: t.dim,
@@ -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;
@@ -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':