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
@@ -105,6 +105,19 @@ function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxInte
105
105
  return INTENT_KINDS_BY_OPERATION[name];
106
106
  }
107
107
 
108
+ /**
109
+ * Ops exempt from auto-ghost SYNTHESIS only (explicit intent linking above is
110
+ * unaffected). Stream appends arrive in rapid succession — a fresh ghost per
111
+ * chunk is exactly the high-frequency churn class the batch exemption exists
112
+ * for; the stream's CREATING call (no nodeId yet) still ghosts once.
113
+ */
114
+ function autoGhostExempt(name: string, rawInput: unknown): boolean {
115
+ if (name !== 'jsonrender.stream') return false;
116
+ const input =
117
+ rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
118
+ return typeof input.nodeId === 'string' && input.nodeId.length > 0;
119
+ }
120
+
108
121
  function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined {
109
122
  if (intent.kind === 'connect' || intent.kind === 'remove') return undefined;
110
123
  if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
@@ -118,7 +131,75 @@ function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined
118
131
  return typeof record.id === 'string' ? record.id : undefined;
119
132
  }
120
133
 
121
- export async function executeOperation(name: string, rawInput: unknown): Promise<unknown> {
134
+ /** Verb for the synthesized auto-ghost chip, per intent kind. */
135
+ const AUTO_GHOST_VERBS: Record<string, string> = {
136
+ create: 'Adding',
137
+ move: 'Moving',
138
+ edit: 'Updating',
139
+ connect: 'Connecting',
140
+ remove: 'Removing',
141
+ };
142
+
143
+ const AUTO_GHOST_EDGE_TYPES = new Set(['flow', 'depends-on', 'relation', 'references']);
144
+
145
+ /**
146
+ * Build a valid signal input for a synthesized ghost, or null when the raw
147
+ * input can't satisfy the kind's required fields (e.g. a create with server
148
+ * auto-placement has no position to ghost at) — in that case the mutation
149
+ * simply runs unghosted.
150
+ */
151
+ function autoGhostInput(name: string, rawInput: unknown, kind: string): Record<string, unknown> | null {
152
+ const body =
153
+ rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
154
+ const subject =
155
+ typeof body.title === 'string' && body.title.trim()
156
+ ? body.title.trim().slice(0, 80)
157
+ : typeof body.type === 'string'
158
+ ? `${body.type} node`
159
+ : typeof body.id === 'string'
160
+ ? body.id
161
+ : name.split('.')[0];
162
+ const base = {
163
+ kind,
164
+ label: `${AUTO_GHOST_VERBS[kind] ?? 'Changing'} ${subject}`,
165
+ ...(typeof body.type === 'string' ? { nodeType: body.type } : {}),
166
+ source: 'auto',
167
+ auto: true,
168
+ ttlMs: 6000,
169
+ };
170
+ const position = typeof body.x === 'number' && typeof body.y === 'number' ? { x: body.x, y: body.y } : undefined;
171
+ switch (kind) {
172
+ case 'create':
173
+ return position ? { ...base, position } : null;
174
+ case 'move':
175
+ return typeof body.id === 'string' && position ? { ...base, nodeId: body.id, position } : null;
176
+ case 'edit':
177
+ case 'remove':
178
+ return typeof body.id === 'string' ? { ...base, nodeId: body.id } : null;
179
+ case 'connect': {
180
+ if (typeof body.from !== 'string' || typeof body.to !== 'string') return null;
181
+ const edgeType = typeof body.type === 'string' && AUTO_GHOST_EDGE_TYPES.has(body.type) ? body.type : 'flow';
182
+ return { ...base, nodeType: undefined, edge: { from: body.from, to: body.to, type: edgeType } };
183
+ }
184
+ default:
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export interface ExecuteOperationMeta {
190
+ /**
191
+ * Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
192
+ * (a human dragging a node is not agent activity) and by canvas.batch for
193
+ * its inner dispatches (batch churn is exempt, matching the skill contract).
194
+ */
195
+ suppressAutoGhost?: boolean;
196
+ }
197
+
198
+ export async function executeOperation(
199
+ name: string,
200
+ rawInput: unknown,
201
+ meta: ExecuteOperationMeta = {},
202
+ ): Promise<unknown> {
122
203
  const op = getOperation(name);
123
204
  const intentId = linkedIntentId(rawInput);
124
205
  const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
@@ -140,6 +221,46 @@ export async function executeOperation(name: string, rawInput: unknown): Promise
140
221
  );
141
222
  }
142
223
 
224
+ // Auto-ghost: an agent-originated visible mutation with NO explicit intent
225
+ // still shows its move on the canvas — a server-synthesized ghost that
226
+ // settles the moment the mutation lands (the client's minimum-dwell keeps
227
+ // the flash perceptible). Explicit canvas_intent signalling stays the
228
+ // richer path (labels, reasons, a real veto window before the mutation).
229
+ const autoKinds = allowedIntentKinds(name, rawInput);
230
+ if (
231
+ autoKinds &&
232
+ !meta.suppressAutoGhost &&
233
+ !autoGhostExempt(name, rawInput) &&
234
+ process.env.PMX_CANVAS_AUTO_INTENT !== '0'
235
+ ) {
236
+ // Ghost synthesis must NEVER break the mutation: skip silently when the
237
+ // input can't satisfy a kind's required fields or signalling throws.
238
+ let ghost: PmxAxIntent | null = null;
239
+ try {
240
+ for (const kind of autoKinds) {
241
+ const input = autoGhostInput(name, rawInput, kind);
242
+ if (input) {
243
+ ghost = intentRegistry.signal(input);
244
+ break;
245
+ }
246
+ }
247
+ } catch {
248
+ ghost = null;
249
+ }
250
+ if (ghost) {
251
+ const ghostId = ghost.id;
252
+ try {
253
+ const result = await op.execute(rawInput, operationContext);
254
+ if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
255
+ intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
256
+ return result;
257
+ } catch (error) {
258
+ intentRegistry.clear(ghostId);
259
+ throw error;
260
+ }
261
+ }
262
+ }
263
+
143
264
  const result = await op.execute(rawInput, operationContext);
144
265
  if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
145
266
  return result;
@@ -47,6 +47,7 @@ import {
47
47
  buildAxStateBridge,
48
48
  buildContentHeightReporter,
49
49
  buildHtmlSurfaceDocument,
50
+ buildMermaidSurfaceHtml,
50
51
  HTML_SURFACE_SANDBOX,
51
52
  normalizeSurfaceTheme,
52
53
  } from './html-surface.js';
@@ -455,7 +456,7 @@ export async function startCanvasAutomationWebView(
455
456
  return runCanvasAutomationWebViewTask(async () => {
456
457
  const WebView = getCanvasWebViewConstructor();
457
458
  if (!WebView) {
458
- const message = 'Bun.WebView is not available in this Bun runtime. Bun >=1.3.12 is required.';
459
+ const message = `Bun.WebView is not available in this Bun runtime (Bun ${Bun.version}). Bun >=1.3.12 is required — the canvas itself keeps working; only WebView automation/screenshots are unavailable.`;
459
460
  canvasAutomationWebViewStatus = {
460
461
  ...canvasAutomationWebViewStatus,
461
462
  lastError: message,
@@ -1004,6 +1005,7 @@ function canvasSpaHtml(): string {
1004
1005
  // (see src/client/state/iframe-mode.ts).
1005
1006
  process.env.AMP_ORB ? '<script>window.__PMX_AMP_ORB = true;</script>' : ''
1006
1007
  }
1008
+ <script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
1007
1009
  <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1008
1010
  </body>
1009
1011
  </html>`;
@@ -1271,6 +1273,28 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1271
1273
 
1272
1274
  const theme = normalizeSurfaceTheme(url.searchParams.get('theme'));
1273
1275
 
1276
+ if (node.type === 'mermaid') {
1277
+ const source = typeof node.data.content === 'string' ? node.data.content : '';
1278
+ if (!source.trim()) return responseText('Mermaid node has no content', 404);
1279
+ const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id;
1280
+ // Display-only surface: same theme/height plumbing as html nodes, but no AX
1281
+ // bridge and no presentation mode — the document is our own wrapper around
1282
+ // escaped diagram source, rendered by /canvas/mermaid-entry.js.
1283
+ const doc = buildHtmlSurfaceDocument(buildMermaidSurfaceHtml(source), {
1284
+ theme,
1285
+ title: surfaceTitle,
1286
+ themeToken: url.searchParams.get('themeToken') ?? undefined,
1287
+ // Content-height reporter nonce (lets the node grow to fit the diagram).
1288
+ ...(url.searchParams.get('frameToken')
1289
+ ? { contentHeightToken: url.searchParams.get('frameToken') as string }
1290
+ : {}),
1291
+ // Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
1292
+ // are styled without depending on the <link> subresource.
1293
+ ...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
1294
+ });
1295
+ return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1296
+ }
1297
+
1274
1298
  if (node.type === 'html') {
1275
1299
  const html =
1276
1300
  typeof node.data.html === 'string'
@@ -1449,6 +1473,23 @@ function responseJson(data: unknown, status = 200): Response {
1449
1473
  });
1450
1474
  }
1451
1475
 
1476
+ // Resolved from the sibling package.json so it stays accurate through bunx,
1477
+ // global npm installs, and repo-local runs — same mechanism as --version in
1478
+ // src/cli/index.ts and the MCP server. Memoized: /health is polled.
1479
+ let cachedPackageVersion: string | null = null;
1480
+ function serverPackageVersion(): string {
1481
+ if (cachedPackageVersion !== null) return cachedPackageVersion;
1482
+ try {
1483
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8')) as {
1484
+ version?: string;
1485
+ };
1486
+ cachedPackageVersion = pkg.version ?? 'unknown';
1487
+ } catch {
1488
+ cachedPackageVersion = 'unknown';
1489
+ }
1490
+ return cachedPackageVersion;
1491
+ }
1492
+
1452
1493
  // v0.4.0 (M4): every hand-written error response is a JSON envelope
1453
1494
  // ({ ok: false, error }, same status codes) matching the registry dispatcher —
1454
1495
  // consumers no longer need to branch on text/plain vs JSON error bodies.
@@ -1799,6 +1840,9 @@ function buildWorkbenchConnectSnapshot(
1799
1840
  continuity,
1800
1841
  path: primaryWorkbenchPath,
1801
1842
  theme: canvasState.theme,
1843
+ // Lets a long-lived client detect that the server was upgraded under it
1844
+ // (stale in-memory SPA — 0.4.5 report Finding W) and reload once.
1845
+ version: serverPackageVersion(),
1802
1846
  timestamp: new Date().toISOString(),
1803
1847
  },
1804
1848
  });
@@ -2999,7 +3043,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2999
3043
  // M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
3000
3044
  // setting only the client var no longer splits the client and server ports.
3001
3045
  const preferredPort =
3002
- options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? DEFAULT_PORT);
3046
+ options.port ??
3047
+ Number(
3048
+ process.env.PMX_WEB_CANVAS_PORT ??
3049
+ process.env.PMX_CANVAS_PORT ??
3050
+ // Amp orbs assign the portal port via $PORT (gated on AMP_ORB so a
3051
+ // stray PORT in normal shells never shifts the default).
3052
+ (process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
3053
+ DEFAULT_PORT,
3054
+ );
3003
3055
  const portCandidates =
3004
3056
  options.port === 0
3005
3057
  ? [0]
@@ -3030,11 +3082,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3030
3082
  // when the pid file is stale (e.g. an adapter respawned the server
3031
3083
  // outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
3032
3084
  // surfaces silent save failures (M4): the canvas keeps serving from
3033
- // memory, but ok:false means durability is degraded.
3085
+ // memory, but ok:false means durability is degraded. `version` lets
3086
+ // `pmx-canvas smoke` verify the RUNNING server, not just the CLI —
3087
+ // version skew between the two is a known failure mode.
3034
3088
  return responseJson({
3035
3089
  ok: true,
3036
3090
  workspace: activeWorkspaceRoot,
3037
3091
  pid: process.pid,
3092
+ version: serverPackageVersion(),
3038
3093
  persistence: canvasState.persistenceHealth,
3039
3094
  });
3040
3095
  }
@@ -0,0 +1,159 @@
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, PmxAxWorkItemStatus } from './ax-state.js';
16
+ import { buildJsonRenderNodeUpdate, createCanvasJsonRenderNode, emitCanvasLayoutUpdate } from './canvas-operations.js';
17
+ import { canvasState, type CanvasNodeState } from './canvas-state.js';
18
+
19
+ export const WORKBOARD_NODE_TITLE = 'Work Board';
20
+ export const WORKBOARD_NODE_SIZE = { width: 960, height: 560 };
21
+
22
+ const WORKBOARD_STATUS_ORDER: PmxAxWorkItemStatus[] = ['todo', 'in-progress', 'blocked', 'done', 'cancelled'];
23
+
24
+ const WORKBOARD_STATUS_LABELS: Record<PmxAxWorkItemStatus, string> = {
25
+ todo: 'To Do',
26
+ 'in-progress': 'In Progress',
27
+ blocked: 'Blocked',
28
+ done: 'Done',
29
+ cancelled: 'Cancelled',
30
+ };
31
+
32
+ /**
33
+ * Build the workboard json-render spec from a work-item list: one column per
34
+ * status (todo → in-progress → blocked → done → cancelled; empty statuses are
35
+ * omitted), each item a Card with an agentId Badge and detail Text when
36
+ * present. An empty list renders a single muted "No work items" text block.
37
+ */
38
+ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
39
+ if (workItems.length === 0) {
40
+ return {
41
+ root: 'empty',
42
+ elements: {
43
+ empty: { type: 'Text', props: { text: 'No work items', variant: 'muted' }, children: [] },
44
+ },
45
+ };
46
+ }
47
+
48
+ const elements: Record<string, unknown> = {};
49
+ const columnIds: string[] = [];
50
+ for (const status of WORKBOARD_STATUS_ORDER) {
51
+ const items = workItems.filter((item) => item.status === status);
52
+ if (items.length === 0) continue;
53
+ const columnId = `col-${status}`;
54
+ const headerId = `${columnId}-header`;
55
+ elements[headerId] = {
56
+ type: 'Heading',
57
+ props: { level: 'h4', text: `${WORKBOARD_STATUS_LABELS[status]} (${items.length})` },
58
+ children: [],
59
+ };
60
+ const columnChildren = [headerId];
61
+ for (const item of items) {
62
+ const cardId = `item-${item.id}`;
63
+ const cardChildren: string[] = [];
64
+ if (item.agentId) {
65
+ elements[`${cardId}-agent`] = {
66
+ type: 'Badge',
67
+ props: { text: item.agentId, variant: 'secondary' },
68
+ children: [],
69
+ };
70
+ cardChildren.push(`${cardId}-agent`);
71
+ }
72
+ if (item.detail) {
73
+ elements[`${cardId}-detail`] = {
74
+ type: 'Text',
75
+ props: { text: item.detail, variant: 'muted' },
76
+ children: [],
77
+ };
78
+ cardChildren.push(`${cardId}-detail`);
79
+ }
80
+ elements[cardId] = { type: 'Card', props: { title: item.title }, children: cardChildren };
81
+ columnChildren.push(cardId);
82
+ }
83
+ elements[columnId] = {
84
+ type: 'Stack',
85
+ props: { direction: 'vertical', gap: 'sm' },
86
+ children: columnChildren,
87
+ };
88
+ columnIds.push(columnId);
89
+ }
90
+ elements.board = {
91
+ type: 'Stack',
92
+ props: { direction: 'horizontal', gap: 'md', align: 'start' },
93
+ children: columnIds,
94
+ };
95
+ return { root: 'board', elements };
96
+ }
97
+
98
+ function findWorkboardNodes(): CanvasNodeState[] {
99
+ return canvasState.getLayout().nodes.filter((node) => node.type === 'json-render' && node.data.workboard === true);
100
+ }
101
+
102
+ /** Rebuild one workboard node's spec in place (shared by the op and the live refresh). */
103
+ function applyWorkboardSpec(node: CanvasNodeState, spec: JsonRenderSpec): void {
104
+ const title = typeof node.data.title === 'string' && node.data.title ? node.data.title : WORKBOARD_NODE_TITLE;
105
+ const update = buildJsonRenderNodeUpdate(node, { title, spec });
106
+ canvasState.updateNode(node.id, { data: update.data });
107
+ }
108
+
109
+ /**
110
+ * Operation core for `render.workboard`: rebuild the existing workboard node
111
+ * in place, or create one (tagged `data.workboard: true`) when none exists.
112
+ */
113
+ export function renderWorkboard(input: { x?: number; y?: number } = {}): {
114
+ ok: true;
115
+ id: string;
116
+ created: boolean;
117
+ itemCount: number;
118
+ } {
119
+ const workItems = canvasState.getWorkItems();
120
+ const spec = buildWorkboardSpec(workItems);
121
+ const existing = findWorkboardNodes()[0];
122
+ if (existing) {
123
+ applyWorkboardSpec(existing, spec);
124
+ return { ok: true, id: existing.id, created: false, itemCount: workItems.length };
125
+ }
126
+ const created = createCanvasJsonRenderNode({
127
+ title: WORKBOARD_NODE_TITLE,
128
+ spec,
129
+ width: WORKBOARD_NODE_SIZE.width,
130
+ height: WORKBOARD_NODE_SIZE.height,
131
+ ...(input.x !== undefined ? { x: input.x } : {}),
132
+ ...(input.y !== undefined ? { y: input.y } : {}),
133
+ });
134
+ // Tag the node as THE workboard. Suppressed: the tag is part of the single
135
+ // logical "create workboard" mutation, not a second undo step.
136
+ canvasState.withSuppressedRecording(() => {
137
+ canvasState.updateNode(created.id, { data: { ...created.node.data, workboard: true } });
138
+ });
139
+ return { ok: true, id: created.id, created: true, itemCount: workItems.length };
140
+ }
141
+
142
+ /**
143
+ * Live refresh: rebuild every workboard node's spec from the current work-item
144
+ * list. Registered below as the canvasState work-item change listener. Node
145
+ * updates never touch work items, so this cannot recurse.
146
+ */
147
+ export function refreshWorkboardNodes(): void {
148
+ const nodes = findWorkboardNodes();
149
+ if (nodes.length === 0) return;
150
+ const spec = buildWorkboardSpec(canvasState.getWorkItems());
151
+ canvasState.withSuppressedRecording(() => {
152
+ for (const node of nodes) {
153
+ applyWorkboardSpec(node, spec);
154
+ }
155
+ });
156
+ emitCanvasLayoutUpdate();
157
+ }
158
+
159
+ canvasState.setWorkItemsChangedListener(refreshWorkboardNodes);
@@ -44,6 +44,12 @@ export interface PmxAxIntent {
44
44
  seq?: number;
45
45
  /** Source label of the surface that signalled the intent (mcp/api/sdk/...). */
46
46
  source?: string;
47
+ /**
48
+ * Server-synthesized ghost for an agent mutation that arrived WITHOUT an
49
+ * explicit canvas_intent signal (auto-ghost). Rendered visually lighter than
50
+ * a deliberate signal, and not vetoable — it settles immediately.
51
+ */
52
+ auto?: boolean;
47
53
  /** Epoch ms when the intent was first signalled. */
48
54
  createdAt: number;
49
55
  /** Epoch ms when the intent auto-dissolves if not settled/cleared first. */
@@ -20,3 +20,11 @@ export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
20
20
 
21
21
  /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
22
22
  export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
23
+
24
+ /**
25
+ * Parent→ext-app paint probe (Finding N): the parent asks the app document to
26
+ * answer with a double-rAF `paint-tick` beacon. A frame whose rendering
27
+ * pipeline is live answers within a frame or two; silence past the timeout is
28
+ * the paint-fail signal that drives the recovery ladder.
29
+ */
30
+ export const EXT_APP_PAINT_PROBE_SOURCE = 'pmx-canvas-ext-app-paint-probe';
@@ -17,6 +17,8 @@ export function canOpenNodeAsSurface(type: string, data: Record<string, unknown>
17
17
  (typeof data.html === 'string' && data.html.length > 0) ||
18
18
  (typeof data.content === 'string' && data.content.length > 0)
19
19
  );
20
+ case 'mermaid':
21
+ return typeof data.content === 'string' && data.content.trim().length > 0;
20
22
  case 'json-render':
21
23
  case 'graph':
22
24
  return true;
@@ -51,7 +51,7 @@ export const CANVAS_THEME_META: Record<CanvasThemeName, CanvasThemeMeta> = {
51
51
  arctic: { label: 'Arctic', scheme: 'dark', swatchBg: '#20242E', swatchAccent: '#7FC8DE' },
52
52
  ember: { label: 'Ember', scheme: 'dark', swatchBg: '#0B0B0C', swatchAccent: '#F34E1C' },
53
53
  forest: { label: 'Forest', scheme: 'dark', swatchBg: '#0C1712', swatchAccent: '#5CCF8F' },
54
- volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#F34E3F' },
54
+ volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#DFDFC1' },
55
55
  };
56
56
 
57
57
  /** Collapse a theme to the dark/light scheme embedded dark|light-only viewers understand. */