pmx-canvas 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/Readme.md +21 -4
  3. package/dist/canvas/global.css +68 -0
  4. package/dist/canvas/index.js +91 -70
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/smoke.d.ts +1 -0
  9. package/dist/types/client/mermaid-entry.d.ts +11 -0
  10. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  11. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  12. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  13. package/dist/types/client/types.d.ts +2 -2
  14. package/dist/types/server/canvas-provenance.d.ts +1 -1
  15. package/dist/types/server/canvas-state.d.ts +10 -1
  16. package/dist/types/server/canvas-validation.d.ts +34 -1
  17. package/dist/types/server/html-surface.d.ts +8 -0
  18. package/dist/types/server/index.d.ts +5 -0
  19. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  20. package/dist/types/server/workboard.d.ts +46 -0
  21. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  22. package/docs/cli.md +12 -0
  23. package/docs/http-api.md +12 -0
  24. package/docs/mcp.md +1 -1
  25. package/docs/node-types.md +2 -0
  26. package/docs/screenshot.png +0 -0
  27. package/package.json +4 -3
  28. package/skills/pmx-canvas/SKILL.md +56 -19
  29. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  30. package/src/cli/agent.ts +2 -0
  31. package/src/cli/commands/ax.ts +28 -3
  32. package/src/cli/commands/smoke.ts +212 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/shared.ts +1 -0
  35. package/src/client/canvas/CanvasViewport.tsx +6 -0
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
  37. package/src/client/canvas/Minimap.tsx +2 -0
  38. package/src/client/canvas/auto-fit.ts +2 -0
  39. package/src/client/icons.tsx +4 -0
  40. package/src/client/mermaid-entry.ts +76 -0
  41. package/src/client/nodes/DiffNode.tsx +114 -0
  42. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  43. package/src/client/nodes/MermaidNode.tsx +81 -0
  44. package/src/client/state/node-factory.ts +2 -0
  45. package/src/client/state/sse-bridge.ts +28 -0
  46. package/src/client/theme/global.css +68 -0
  47. package/src/client/types.ts +6 -0
  48. package/src/mcp/server.ts +19 -1
  49. package/src/server/ax-interaction.ts +6 -0
  50. package/src/server/canvas-operations.ts +39 -9
  51. package/src/server/canvas-provenance.ts +2 -0
  52. package/src/server/canvas-schema.ts +33 -0
  53. package/src/server/canvas-state.ts +26 -0
  54. package/src/server/canvas-validation.ts +79 -0
  55. package/src/server/html-surface.ts +11 -0
  56. package/src/server/index.ts +25 -6
  57. package/src/server/operations/composites.ts +3 -2
  58. package/src/server/operations/ops/batch.ts +8 -0
  59. package/src/server/operations/ops/json-render.ts +44 -0
  60. package/src/server/operations/ops/nodes.ts +9 -1
  61. package/src/server/operations/ops/viewport.ts +8 -1
  62. package/src/server/server.ts +48 -1
  63. package/src/server/workboard.ts +159 -0
  64. package/src/shared/ax-surface-protocol.ts +8 -0
  65. package/src/shared/surface.ts +2 -0
@@ -1895,6 +1895,74 @@ html.is-node-resizing .ext-app-preview-catcher {
1895
1895
  }
1896
1896
  }
1897
1897
 
1898
+ /* Diff nodes — unified diff viewer with per-file sections */
1899
+ .diff-node {
1900
+ display: flex;
1901
+ flex-direction: column;
1902
+ gap: 10px;
1903
+ height: 100%;
1904
+ overflow: auto;
1905
+ font-family: var(--mono);
1906
+ font-size: 12px;
1907
+ color: var(--c-text);
1908
+ }
1909
+
1910
+ .diff-node.diff-node-empty {
1911
+ align-items: center;
1912
+ justify-content: center;
1913
+ color: var(--c-muted);
1914
+ }
1915
+
1916
+ .diff-node .diff-file {
1917
+ border: 1px solid var(--c-line);
1918
+ border-radius: 6px;
1919
+ overflow: hidden;
1920
+ flex-shrink: 0;
1921
+ }
1922
+
1923
+ .diff-node .diff-file-header {
1924
+ padding: 4px 8px;
1925
+ background: var(--c-panel-overlay);
1926
+ border-bottom: 1px solid var(--c-line);
1927
+ color: var(--c-text-soft);
1928
+ font-weight: 600;
1929
+ white-space: nowrap;
1930
+ overflow: hidden;
1931
+ text-overflow: ellipsis;
1932
+ }
1933
+
1934
+ .diff-node .diff-lines {
1935
+ margin: 0;
1936
+ padding: 4px 0;
1937
+ overflow-x: auto;
1938
+ font-family: inherit;
1939
+ font-size: inherit;
1940
+ line-height: 1.5;
1941
+ }
1942
+
1943
+ .diff-node .diff-lines > div {
1944
+ padding: 0 8px;
1945
+ white-space: pre;
1946
+ }
1947
+
1948
+ .diff-node .diff-hunk {
1949
+ color: var(--c-muted);
1950
+ }
1951
+
1952
+ .diff-node .diff-line-add {
1953
+ color: var(--c-ok);
1954
+ background: var(--c-ok-10);
1955
+ }
1956
+
1957
+ .diff-node .diff-line-remove {
1958
+ color: var(--c-danger);
1959
+ background: var(--c-danger-12);
1960
+ }
1961
+
1962
+ .diff-node .diff-line-context {
1963
+ color: var(--c-text-soft);
1964
+ }
1965
+
1898
1966
  /* Docked node — compact toolbar-flanking panel */
1899
1967
  .docked-node {
1900
1968
  min-width: 200px;
@@ -21,6 +21,8 @@ export interface CanvasNodeState {
21
21
  | 'ledger'
22
22
  | 'trace'
23
23
  | 'file'
24
+ | 'diff'
25
+ | 'mermaid'
24
26
  | 'image'
25
27
  | 'html'
26
28
  | 'group';
@@ -77,6 +79,8 @@ export const TYPE_LABELS: Record<CanvasNodeState['type'], string> = {
77
79
  ledger: 'LOG',
78
80
  trace: 'TRACE',
79
81
  file: 'FILE',
82
+ diff: 'DIFF',
83
+ mermaid: 'MERMAID',
80
84
  image: 'IMG',
81
85
  html: 'HTML',
82
86
  group: 'GROUP',
@@ -92,6 +96,8 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
92
96
  'context',
93
97
  'ledger',
94
98
  'file',
99
+ 'diff',
100
+ 'mermaid',
95
101
  'image',
96
102
  'html',
97
103
  ]);
package/src/mcp/server.ts CHANGED
@@ -27,7 +27,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
27
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
28
  import { z } from 'zod';
29
29
  import { AX_SOURCES } from '../server/operations/ops/ax-shared.js';
30
- import { canvasState, describeCanvasSchema } from '../server/index.js';
30
+ import { canvasState, describeCanvasSchema, stopCanvasServer } from '../server/index.js';
31
31
  import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
32
32
  import { buildPendingAxActivity } from '../server/ax-state.js';
33
33
  import { registerOperationTools, registerCompositeTools } from '../server/operations/index.js';
@@ -884,6 +884,24 @@ export async function startMcpServer(): Promise<void> {
884
884
  // Connect via stdio
885
885
  const transport = new StdioServerTransport();
886
886
  await server.connect(transport);
887
+
888
+ // Exit when the stdio channel closes (0.4.5 report Finding Y): without this,
889
+ // a client that completes the official close sequence leaves this process
890
+ // orphaned — the auto-started canvas server and its timers keep the event
891
+ // loop alive forever. stopCanvasServer() checkpoints the SQLite DB before
892
+ // exit and is a no-op when this process only attached to a remote daemon.
893
+ const shutdown = (): void => {
894
+ try {
895
+ stopCanvasServer();
896
+ } catch {
897
+ // Exit regardless — a failed cleanup must not resurrect the orphan.
898
+ }
899
+ process.exit(0);
900
+ };
901
+ server.server.onclose = shutdown;
902
+ process.stdin.on('end', shutdown);
903
+ process.on('SIGINT', shutdown);
904
+ process.on('SIGTERM', shutdown);
887
905
  }
888
906
 
889
907
  // Allow direct execution: bun run src/mcp/server.ts
@@ -102,6 +102,12 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
102
102
  'notify-agent',
103
103
  ),
104
104
  file: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
105
+ diff: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
106
+ mermaid: caps(
107
+ true,
108
+ ['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
109
+ 'notify-agent',
110
+ ),
105
111
  'json-render': caps(true, [
106
112
  'ax.work.create',
107
113
  'ax.work.update',
@@ -17,6 +17,7 @@ import {
17
17
  type ExternalMcpTransportConfig,
18
18
  } from './mcp-app-runtime.js';
19
19
  import { mutationHistory } from './mutation-history.js';
20
+ import { clampCreateNodeSize } from './canvas-validation.js';
20
21
  import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
21
22
  import { searchNodes } from './spatial-analysis.js';
22
23
  import { getCanvasNodeTitle } from './canvas-serialization.js';
@@ -114,6 +115,10 @@ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
114
115
  export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
115
116
  export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
116
117
 
118
+ // Per-type minimum creation sizes + clamp live in canvas-validation.ts
119
+ // (NODE_MIN_CREATE_SIZES / clampCreateNodeSize) — shared with the validate
120
+ // surface's sizeWarnings.
121
+
117
122
  interface CanvasCreateGroupInput {
118
123
  title?: string;
119
124
  childIds?: string[];
@@ -360,11 +365,20 @@ export function buildJsonRenderNodeUpdate(
360
365
  if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
361
366
  const spec = normalizeAndValidateJsonRenderSpec(input.spec);
362
367
  const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
368
+ // Bump specVersion so the viewer URL changes: the iframe src is keyed on
369
+ // nodeId + ?v=specVersion, and the viewer only reads the spec at document
370
+ // load. Without the bump the src is byte-identical after a content update,
371
+ // the iframe never reloads, and the node renders stale forever (the live
372
+ // workboard's refresh depends on this).
373
+ const prevVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : 0;
363
374
  return {
364
375
  spec,
365
376
  data: {
366
377
  ...node.data,
367
- ...createJsonRenderNodeData(node.id, title, spec, { viewerType: 'json-render' }),
378
+ ...createJsonRenderNodeData(node.id, title, spec, {
379
+ viewerType: 'json-render',
380
+ specVersion: prevVersion + 1,
381
+ }),
368
382
  },
369
383
  };
370
384
  }
@@ -906,8 +920,12 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
906
920
  throw new Error(`Use the dedicated ${input.type} node APIs for structured viewer nodes.`);
907
921
  }
908
922
 
909
- const width = input.width ?? input.defaultWidth ?? 720;
910
- const height = input.height ?? input.defaultHeight ?? 600;
923
+ const { width, height } = clampCreateNodeSize(
924
+ input.type,
925
+ input.width ?? input.defaultWidth ?? 720,
926
+ input.height ?? input.defaultHeight ?? 600,
927
+ input.strictSize,
928
+ );
911
929
  const position =
912
930
  input.x !== undefined && input.y !== undefined
913
931
  ? { x: input.x, y: input.y }
@@ -1540,8 +1558,12 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
1540
1558
  node: CanvasNodeState;
1541
1559
  } {
1542
1560
  const spec = normalizeAndValidateJsonRenderSpec(input.spec);
1543
- const width = input.width ?? JSON_RENDER_NODE_SIZE.width;
1544
- const height = input.height ?? JSON_RENDER_NODE_SIZE.height;
1561
+ const { width, height } = clampCreateNodeSize(
1562
+ 'json-render',
1563
+ input.width ?? JSON_RENDER_NODE_SIZE.width,
1564
+ input.height ?? JSON_RENDER_NODE_SIZE.height,
1565
+ input.strictSize,
1566
+ );
1545
1567
  const position =
1546
1568
  input.x !== undefined && input.y !== undefined
1547
1569
  ? { x: input.x, y: input.y }
@@ -1580,8 +1602,12 @@ export function createCanvasStreamingJsonRenderNode(input: {
1580
1602
  strictSize?: boolean;
1581
1603
  }): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
1582
1604
  const spec = emptyStreamingSpec();
1583
- const width = input.width ?? JSON_RENDER_NODE_SIZE.width;
1584
- const height = input.height ?? JSON_RENDER_NODE_SIZE.height;
1605
+ const { width, height } = clampCreateNodeSize(
1606
+ 'json-render',
1607
+ input.width ?? JSON_RENDER_NODE_SIZE.width,
1608
+ input.height ?? JSON_RENDER_NODE_SIZE.height,
1609
+ input.strictSize,
1610
+ );
1585
1611
  const position =
1586
1612
  input.x !== undefined && input.y !== undefined
1587
1613
  ? { x: input.x, y: input.y }
@@ -1652,8 +1678,12 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1652
1678
  } {
1653
1679
  const title = input.title?.trim() || 'Graph';
1654
1680
  const spec = buildGraphSpec(input);
1655
- const width = input.width ?? GRAPH_NODE_SIZE.width;
1656
- const height = input.heightPx ?? GRAPH_NODE_SIZE.height;
1681
+ const { width, height } = clampCreateNodeSize(
1682
+ 'graph',
1683
+ input.width ?? GRAPH_NODE_SIZE.width,
1684
+ input.heightPx ?? GRAPH_NODE_SIZE.height,
1685
+ input.strictSize,
1686
+ );
1657
1687
  const position =
1658
1688
  input.x !== undefined && input.y !== undefined
1659
1689
  ? { x: input.x, y: input.y }
@@ -14,6 +14,8 @@ export type CanvasNodeType =
14
14
  | 'ledger'
15
15
  | 'trace'
16
16
  | 'file'
17
+ | 'diff'
18
+ | 'mermaid'
17
19
  | 'image'
18
20
  | 'html'
19
21
  | 'group';
@@ -209,6 +209,39 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
209
209
  'Path-backed file nodes automatically persist `data.provenance` with a file URI and file-watch refresh strategy.',
210
210
  ],
211
211
  },
212
+ {
213
+ type: 'diff',
214
+ kind: 'node',
215
+ description: 'Unified diff viewer with per-file sections and colored add/remove lines.',
216
+ endpoint: '/api/canvas/node',
217
+ mcpTool: 'canvas_node (action:"add")',
218
+ fields: [
219
+ { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
220
+ { name: 'content', type: 'string', required: false, description: 'Unified diff text (git or raw hunks).' },
221
+ ],
222
+ example: {
223
+ type: 'diff',
224
+ title: 'Fix null check',
225
+ content: '--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1,3 +1,3 @@\n-const a = b.c;\n+const a = b?.c;\n context',
226
+ },
227
+ },
228
+ {
229
+ type: 'mermaid',
230
+ kind: 'node',
231
+ description:
232
+ 'Text-to-diagram node rendered client-side with Mermaid (flowcharts, sequence, state, class diagrams, …).',
233
+ endpoint: '/api/canvas/node',
234
+ mcpTool: 'canvas_node (action:"add")',
235
+ fields: [
236
+ { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
237
+ { name: 'content', type: 'string', required: false, description: 'Mermaid diagram source text.' },
238
+ ],
239
+ example: {
240
+ type: 'mermaid',
241
+ title: 'Request flow',
242
+ content: 'graph TD; A[Client] --> B[Server]; B --> C[(DB)];',
243
+ },
244
+ },
212
245
  {
213
246
  type: 'image',
214
247
  kind: 'node',
@@ -175,6 +175,8 @@ export interface CanvasNodeState {
175
175
  | 'ledger'
176
176
  | 'trace'
177
177
  | 'file'
178
+ | 'diff'
179
+ | 'mermaid'
178
180
  | 'image'
179
181
  | 'html'
180
182
  | 'group';
@@ -368,6 +370,28 @@ class CanvasStateManager {
368
370
  };
369
371
  }
370
372
 
373
+ // ── Work-item change listener (single slot, for live workboard refresh) ──
374
+ private _workItemsChangedListener: (() => void) | null = null;
375
+
376
+ /**
377
+ * Register THE work-item change listener (single slot, last-write-wins).
378
+ * Fired after addWorkItem/updateWorkItem completes (including the node
379
+ * status mirror), so live views like the workboard can rebuild from the
380
+ * fresh work-item list.
381
+ */
382
+ setWorkItemsChangedListener(listener: (() => void) | null): void {
383
+ this._workItemsChangedListener = listener;
384
+ }
385
+
386
+ private notifyWorkItemsChanged(): void {
387
+ if (!this._workItemsChangedListener) return;
388
+ try {
389
+ this._workItemsChangedListener();
390
+ } catch (error) {
391
+ logCanvasStateWarning('work-items-changed listener failed', error);
392
+ }
393
+ }
394
+
371
395
  private notifyChange(type: CanvasChangeType): void {
372
396
  // Iterate a snapshot: a listener (e.g. a blocking-wait via onChange) may dispose
373
397
  // itself synchronously here, and splicing the live array mid-iteration would skip
@@ -1727,6 +1751,7 @@ class CanvasStateManager {
1727
1751
  ): PmxAxWorkItem {
1728
1752
  const item = this.ax.addWorkItem(input, options);
1729
1753
  this.mirrorAxWorkStatusToNodes(item.nodeIds, [], item.status);
1754
+ this.notifyWorkItemsChanged();
1730
1755
  return item;
1731
1756
  }
1732
1757
 
@@ -1745,6 +1770,7 @@ class CanvasStateManager {
1745
1770
  const item = this.ax.updateWorkItem(id, patch, options);
1746
1771
  if (!item) return null;
1747
1772
  this.mirrorAxWorkStatusToNodes(item.nodeIds, previousNodeIds, item.status);
1773
+ this.notifyWorkItemsChanged();
1748
1774
  return item;
1749
1775
  }
1750
1776
 
@@ -1,6 +1,47 @@
1
1
  import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import { getCanvasNodeTitle } from './canvas-serialization.js';
3
3
 
4
+ /**
5
+ * Per-type minimum node size (0.4.5 report follow-up): agents frequently
6
+ * create nodes with explicit frames far too small for their content — clipped
7
+ * markdown, charts squeezed behind inner scrollbars. Explicit sizes below the
8
+ * floor are clamped UP at creation (canvas-operations.ts creators);
9
+ * `strictSize: true` is the escape hatch for a genuinely small fixed frame.
10
+ * Floors sit below every type default and above the point of unreadability.
11
+ * Creation-only: later updates are untouched (the browser's drag-resize
12
+ * enforces its own 200×100 floor client-side), but `validate` reports any
13
+ * node below its floor as an advisory sizeWarning. Absent types (trace,
14
+ * group, prompt/response) are intentionally unclamped — trace is small by
15
+ * design, groups size to their children.
16
+ */
17
+ export const NODE_MIN_CREATE_SIZES: Partial<Record<CanvasNodeState['type'], { width: number; height: number }>> = {
18
+ markdown: { width: 360, height: 180 },
19
+ context: { width: 360, height: 180 },
20
+ file: { width: 360, height: 200 },
21
+ diff: { width: 420, height: 240 },
22
+ mermaid: { width: 360, height: 240 },
23
+ status: { width: 280, height: 120 },
24
+ ledger: { width: 320, height: 200 },
25
+ image: { width: 240, height: 180 },
26
+ html: { width: 420, height: 280 },
27
+ webpage: { width: 420, height: 280 },
28
+ 'json-render': { width: 420, height: 280 },
29
+ graph: { width: 420, height: 280 },
30
+ 'mcp-app': { width: 480, height: 320 },
31
+ };
32
+
33
+ export function clampCreateNodeSize(
34
+ type: CanvasNodeState['type'],
35
+ width: number,
36
+ height: number,
37
+ strictSize?: boolean,
38
+ ): { width: number; height: number } {
39
+ if (strictSize === true) return { width, height };
40
+ const min = NODE_MIN_CREATE_SIZES[type];
41
+ if (!min) return { width, height };
42
+ return { width: Math.max(width, min.width), height: Math.max(height, min.height) };
43
+ }
44
+
4
45
  export interface CanvasValidationPair {
5
46
  aId: string;
6
47
  aTitle: string | null;
@@ -15,12 +56,24 @@ export interface CanvasContainmentIssue {
15
56
  childTitle: string | null;
16
57
  }
17
58
 
59
+ export interface CanvasSizeWarning {
60
+ id: string;
61
+ title: string | null;
62
+ type: CanvasNodeState['type'];
63
+ width: number;
64
+ height: number;
65
+ minWidth: number;
66
+ minHeight: number;
67
+ }
68
+
18
69
  export interface CanvasValidationResult {
19
70
  ok: boolean;
20
71
  collisions: CanvasValidationPair[];
21
72
  containments: CanvasContainmentIssue[];
22
73
  containmentViolations: CanvasContainmentIssue[];
23
74
  missingEdgeEndpoints: Array<{ edgeId: string; from: string; to: string }>;
75
+ /** Nodes below their type's readable minimum (advisory — does not fail `ok`). */
76
+ sizeWarnings: CanvasSizeWarning[];
24
77
  summary: {
25
78
  nodes: number;
26
79
  edges: number;
@@ -28,6 +81,7 @@ export interface CanvasValidationResult {
28
81
  containments: number;
29
82
  containmentViolations: number;
30
83
  missingEdgeEndpoints: number;
84
+ sizeWarnings: number;
31
85
  };
32
86
  }
33
87
 
@@ -109,12 +163,36 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
109
163
  .filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))
110
164
  .map((edge) => ({ edgeId: edge.id, from: edge.from, to: edge.to }));
111
165
 
166
+ // Advisory: nodes below their type's readable floor (clipped/unreadable
167
+ // content — 0.4.5 report follow-up). Creation clamps these, but resized or
168
+ // strictSize-created nodes can still be undersized; surface them so an
169
+ // agent's validate pass sees the problem. Collapsed/docked nodes render as
170
+ // bars, and strictSize is the deliberate opt-out — all skipped.
171
+ const sizeWarnings: CanvasSizeWarning[] = layout.nodes
172
+ .filter((node) => !node.collapsed && node.dockPosition == null && node.data?.strictSize !== true)
173
+ .flatMap((node) => {
174
+ const min = NODE_MIN_CREATE_SIZES[node.type];
175
+ if (!min || (node.size.width >= min.width && node.size.height >= min.height)) return [];
176
+ return [
177
+ {
178
+ id: node.id,
179
+ title: getCanvasNodeTitle(node),
180
+ type: node.type,
181
+ width: node.size.width,
182
+ height: node.size.height,
183
+ minWidth: min.width,
184
+ minHeight: min.height,
185
+ },
186
+ ];
187
+ });
188
+
112
189
  return {
113
190
  ok: collisions.length === 0 && containmentViolations.length === 0 && missingEdgeEndpoints.length === 0,
114
191
  collisions,
115
192
  containments,
116
193
  containmentViolations,
117
194
  missingEdgeEndpoints,
195
+ sizeWarnings,
118
196
  summary: {
119
197
  nodes: layout.nodes.length,
120
198
  edges: layout.edges.length,
@@ -122,6 +200,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
122
200
  containments: containments.length,
123
201
  containmentViolations: containmentViolations.length,
124
202
  missingEdgeEndpoints: missingEdgeEndpoints.length,
203
+ sizeWarnings: sizeWarnings.length,
125
204
  },
126
205
  };
127
206
  }
@@ -211,6 +211,17 @@ function escapeSurfaceHtml(value: string): string {
211
211
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
212
212
  }
213
213
 
214
+ /**
215
+ * Author-HTML fragment for a `mermaid` node surface: the HTML-escaped diagram
216
+ * source in a hidden `<pre>` the entry script reads back via textContent, plus
217
+ * the separately-built renderer bundle (dist/canvas/mermaid-entry.js — kept out
218
+ * of the main SPA bundle). Escaping is mandatory: the source is arbitrary node
219
+ * data and must not be parsed as markup.
220
+ */
221
+ export function buildMermaidSurfaceHtml(source: string): string {
222
+ return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre><script src="/canvas/mermaid-entry.js"></script>`;
223
+ }
224
+
214
225
  export interface HtmlSurfaceOptions {
215
226
  theme: SurfaceTheme;
216
227
  /**
@@ -542,15 +542,22 @@ export class PmxCanvas extends EventEmitter {
542
542
  nodeIds?: string[];
543
543
  data?: Record<string, unknown> | null;
544
544
  },
545
- options?: { source?: PmxAxSource },
545
+ options?: { source?: PmxAxSource; agentId?: string | null },
546
546
  ): PmxAxEvent {
547
- const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk' });
547
+ const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk', agentId: options?.agentId });
548
548
  emitPrimaryWorkbenchEvent('ax-event-created', { event });
549
549
  return event;
550
550
  }
551
551
 
552
- sendSteering(message: string, options?: { source?: PmxAxSource }): PmxAxSteeringMessage {
553
- const steering = canvasState.recordSteeringMessage(message, { source: options?.source ?? 'sdk' });
552
+ sendSteering(
553
+ message: string,
554
+ options?: { source?: PmxAxSource; agentId?: string | null; target?: string | null },
555
+ ): PmxAxSteeringMessage {
556
+ const steering = canvasState.recordSteeringMessage(message, {
557
+ source: options?.source ?? 'sdk',
558
+ agentId: options?.agentId,
559
+ target: options?.target,
560
+ });
554
561
  emitPrimaryWorkbenchEvent('ax-event-created', { steering });
555
562
  return steering;
556
563
  }
@@ -604,7 +611,13 @@ export class PmxCanvas extends EventEmitter {
604
611
  }
605
612
 
606
613
  addWorkItem(
607
- input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
614
+ input: {
615
+ title: string;
616
+ status?: PmxAxWorkItemStatus;
617
+ detail?: string | null;
618
+ nodeIds?: string[];
619
+ agentId?: string | null;
620
+ },
608
621
  options?: { source?: PmxAxSource },
609
622
  ): PmxAxWorkItem {
610
623
  const workItem = canvasState.addWorkItem(input, { source: options?.source ?? 'sdk' });
@@ -614,7 +627,13 @@ export class PmxCanvas extends EventEmitter {
614
627
 
615
628
  updateWorkItem(
616
629
  id: string,
617
- patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
630
+ patch: {
631
+ title?: string;
632
+ status?: PmxAxWorkItemStatus;
633
+ detail?: string | null;
634
+ nodeIds?: string[];
635
+ agentId?: string | null;
636
+ },
618
637
  options?: { source?: PmxAxSource },
619
638
  ): PmxAxWorkItem | null {
620
639
  const workItem = canvasState.updateWorkItem(id, patch, { source: options?.source ?? 'sdk' });
@@ -156,14 +156,15 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
156
156
  {
157
157
  toolName: 'canvas_render',
158
158
  description:
159
- 'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph).',
160
- actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph',
159
+ 'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph); "workboard" materializes a live AX work-item board (a json-render node with a column per status) that auto-refreshes whenever work items change — idempotent, rebuilding the existing board in place.',
160
+ actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph | workboard',
161
161
  actions: {
162
162
  'describe-schema': 'schema.describe',
163
163
  validate: 'spec.validate',
164
164
  'add-json-render': 'jsonrender.add',
165
165
  'stream-json-render': 'jsonrender.stream',
166
166
  'add-graph': 'graph.add',
167
+ workboard: 'render.workboard',
167
168
  },
168
169
  },
169
170
  {
@@ -375,6 +375,14 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
375
375
  // ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
376
376
  const envelope = await runWithSuppressedEmits(() => runBatch(operations));
377
377
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
378
+ // The suppression window also swallows the pins op's context-pins-changed
379
+ // frame, leaving the client's pin state (menus, indicators) stale until
380
+ // reload (0.4.5 report Finding X). If the batch touched pins, follow the
381
+ // layout frame with ONE final pins frame carrying the end state.
382
+ if (operations.some((entry) => entry.op.startsWith('pin.'))) {
383
+ const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
384
+ ctx.emit('context-pins-changed', { count: nodeIds.length, nodeIds });
385
+ }
378
386
  return envelope;
379
387
  },
380
388
  });
@@ -39,6 +39,7 @@ import {
39
39
  } from '../../canvas-operations.js';
40
40
  import { describeCanvasSchema, validateStructuredCanvasPayload } from '../../canvas-schema.js';
41
41
  import { isHtmlPrimitiveKind } from '../../html-primitives.js';
42
+ import { renderWorkboard } from '../../workboard.js';
42
43
  import { defineOperation, OperationError, type Operation } from '../types.js';
43
44
  import {
44
45
  buildNodeResponse,
@@ -311,6 +312,48 @@ const jsonRenderStreamOperation = defineOperation<z.infer<typeof jsonRenderStrea
311
312
  // Wire shape is `{ id, url, ok, applied, ... }` — handler output verbatim.
312
313
  });
313
314
 
315
+ // ── render.workboard ──────────────────────────────────────────
316
+
317
+ const workboardRenderShape = {
318
+ intentId: z
319
+ .string()
320
+ .optional()
321
+ .catch(undefined)
322
+ .describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
323
+ x: z.number().optional().catch(undefined).describe('Optional X position (only used when creating the board)'),
324
+ y: z.number().optional().catch(undefined).describe('Optional Y position (only used when creating the board)'),
325
+ };
326
+
327
+ const workboardRenderSchema = z.looseObject(workboardRenderShape);
328
+
329
+ const workboardRenderOperation = defineOperation<
330
+ z.infer<typeof workboardRenderSchema>,
331
+ ReturnType<typeof renderWorkboard>
332
+ >({
333
+ name: 'render.workboard',
334
+ mutates: true,
335
+ input: workboardRenderSchema,
336
+ inputShape: workboardRenderShape,
337
+ http: {
338
+ method: 'POST',
339
+ path: '/api/canvas/workboard',
340
+ },
341
+ mcp: {
342
+ toolName: 'canvas_render_workboard',
343
+ description:
344
+ 'Materialize a live work-item board: a json-render node with one column per status (todo → in-progress → blocked → done → cancelled) showing every AX work item as a card (title, agentId chip, detail). Idempotent: rebuilds the existing board node in place when one exists, otherwise creates it. The board auto-refreshes whenever work items change.',
345
+ },
346
+ handler: (input) => {
347
+ const body: Record<string, unknown> = input;
348
+ const x = pickFiniteNumber(body, 'x');
349
+ const y = pickFiniteNumber(body, 'y');
350
+ return renderWorkboard({
351
+ ...(x !== undefined ? { x } : {}),
352
+ ...(y !== undefined ? { y } : {}),
353
+ });
354
+ },
355
+ });
356
+
314
357
  // ── graph.add ─────────────────────────────────────────────────
315
358
 
316
359
  const graphAddShape = {
@@ -829,6 +872,7 @@ const specValidateOperation = defineOperation<z.infer<typeof specValidateSchema>
829
872
  export const jsonRenderOperations: Operation[] = [
830
873
  jsonRenderAddOperation,
831
874
  jsonRenderStreamOperation,
875
+ workboardRenderOperation,
832
876
  graphAddOperation,
833
877
  schemaDescribeOperation,
834
878
  specValidateOperation,