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
@@ -178,7 +178,8 @@ const SCHEMA_SQL = `
178
178
  node_ids TEXT NOT NULL DEFAULT '[]',
179
179
  data TEXT,
180
180
  created_at TEXT NOT NULL,
181
- source TEXT
181
+ source TEXT,
182
+ agent_id TEXT
182
183
  );
183
184
  CREATE INDEX IF NOT EXISTS idx_ax_events_seq ON ax_events (seq);
184
185
 
@@ -202,7 +203,9 @@ const SCHEMA_SQL = `
202
203
  message TEXT NOT NULL,
203
204
  delivered INTEGER NOT NULL DEFAULT 0,
204
205
  created_at TEXT NOT NULL,
205
- source TEXT
206
+ source TEXT,
207
+ agent_id TEXT,
208
+ target TEXT
206
209
  );
207
210
  CREATE INDEX IF NOT EXISTS idx_ax_steering_seq ON ax_steering (seq);
208
211
 
@@ -213,6 +216,22 @@ const SCHEMA_SQL = `
213
216
  );
214
217
  `;
215
218
 
219
+ /** Idempotently add a column to an existing table (no prior-migrations system in this repo). */
220
+ function ensureColumn(db: Database, table: string, column: string, ddl: string): void {
221
+ interface ColumnInfoRow {
222
+ name: string;
223
+ }
224
+ const columns = db.query<ColumnInfoRow, []>(`PRAGMA table_info(${table})`).all();
225
+ if (columns.some((c) => c.name === column)) return;
226
+ try {
227
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
228
+ } catch (error) {
229
+ // Two processes can race the check-then-ALTER on the first post-upgrade
230
+ // open; the loser's duplicate-column error means the column exists — done.
231
+ if (!/duplicate column/i.test(error instanceof Error ? error.message : String(error))) throw error;
232
+ }
233
+ }
234
+
216
235
  function normalizePositiveInteger(value: number | undefined): number | undefined {
217
236
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
218
237
  return Math.floor(value);
@@ -258,6 +277,11 @@ export function openCanvasDb(dbPath: string): Database {
258
277
  db.exec('PRAGMA busy_timeout=5000');
259
278
  db.exec(SCHEMA_SQL);
260
279
 
280
+ // Additive columns for pre-existing DBs (fresh installs already get them via SCHEMA_SQL above).
281
+ ensureColumn(db, 'ax_events', 'agent_id', 'agent_id TEXT');
282
+ ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
283
+ ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
284
+
261
285
  // Set schema version if not present
262
286
  const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('schema_version');
263
287
  if (!row) {
@@ -858,7 +882,7 @@ function readLastSeq(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_stee
858
882
 
859
883
  export function appendAxEventToDB(db: Database, ev: Omit<PmxAxEvent, 'seq'>): PmxAxEvent {
860
884
  db.run(
861
- 'INSERT INTO ax_events (id, kind, summary, detail, node_ids, data, created_at, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
885
+ 'INSERT INTO ax_events (id, kind, summary, detail, node_ids, data, created_at, source, agent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
862
886
  [
863
887
  ev.id,
864
888
  ev.kind,
@@ -868,6 +892,7 @@ export function appendAxEventToDB(db: Database, ev: Omit<PmxAxEvent, 'seq'>): Pm
868
892
  ev.data ? JSON.stringify(ev.data) : null,
869
893
  ev.createdAt,
870
894
  ev.source,
895
+ ev.agentId,
871
896
  ],
872
897
  );
873
898
  const seq = readLastSeq(db, 'ax_events');
@@ -896,20 +921,20 @@ export function appendAxEvidenceToDB(db: Database, ev: Omit<PmxAxEvidence, 'seq'
896
921
  }
897
922
 
898
923
  export function appendAxSteeringToDB(db: Database, s: Omit<PmxAxSteeringMessage, 'seq'>): PmxAxSteeringMessage {
899
- db.run('INSERT INTO ax_steering (id, message, delivered, created_at, source) VALUES (?, ?, ?, ?, ?)', [
900
- s.id,
901
- s.message,
902
- s.delivered ? 1 : 0,
903
- s.createdAt,
904
- s.source,
905
- ]);
924
+ db.run(
925
+ 'INSERT INTO ax_steering (id, message, delivered, created_at, source, agent_id, target) VALUES (?, ?, ?, ?, ?, ?, ?)',
926
+ [s.id, s.message, s.delivered ? 1 : 0, s.createdAt, s.source, s.agentId, s.target],
927
+ );
906
928
  const seq = readLastSeq(db, 'ax_steering');
907
929
  trimAxTable(db, 'ax_steering');
908
930
  return { ...s, seq };
909
931
  }
910
932
 
911
933
  export function markAxSteeringDeliveredInDB(db: Database, id: string): boolean {
912
- const r = db.run('UPDATE ax_steering SET delivered = 1 WHERE id = ?', [id]);
934
+ // Compare-and-set: true ONLY on the undelivered→delivered transition. Two
935
+ // adapters racing the same steer see exactly one true — the loser (and any
936
+ // re-mark) gets false and must not treat the message as its own delivery.
937
+ const r = db.run('UPDATE ax_steering SET delivered = 1 WHERE id = ? AND delivered = 0', [id]);
913
938
  return r.changes > 0;
914
939
  }
915
940
 
@@ -924,6 +949,7 @@ export function loadAxEventsFromDB(db: Database, q: AxTimelineQuery = {}): PmxAx
924
949
  data: string | null;
925
950
  created_at: string;
926
951
  source: string | null;
952
+ agent_id: string | null;
927
953
  }
928
954
  const rows = db
929
955
  .query<Row, [number]>('SELECT * FROM ax_events ORDER BY seq DESC LIMIT ?')
@@ -935,6 +961,7 @@ export function loadAxEventsFromDB(db: Database, q: AxTimelineQuery = {}): PmxAx
935
961
  createdAt: r.created_at,
936
962
  nodeIds: safeParseJson(r.node_ids),
937
963
  data: safeParseJson(r.data),
964
+ agentId: r.agent_id,
938
965
  }),
939
966
  )
940
967
  .filter((e): e is PmxAxEvent => e !== null);
@@ -968,58 +995,62 @@ export function loadAxEvidenceFromDB(db: Database, q: AxTimelineQuery = {}): Pmx
968
995
  .filter((e): e is PmxAxEvidence => e !== null);
969
996
  }
970
997
 
998
+ interface AxSteeringRow {
999
+ seq: number;
1000
+ id: string;
1001
+ message: string;
1002
+ delivered: number;
1003
+ created_at: string;
1004
+ source: string | null;
1005
+ agent_id: string | null;
1006
+ target: string | null;
1007
+ }
1008
+
1009
+ function mapAxSteeringRow(r: AxSteeringRow): PmxAxSteeringMessage | null {
1010
+ return normalizeAxSteeringMessage({
1011
+ ...r,
1012
+ createdAt: r.created_at,
1013
+ delivered: r.delivered === 1,
1014
+ agentId: r.agent_id,
1015
+ });
1016
+ }
1017
+
971
1018
  export function loadAxSteeringFromDB(
972
1019
  db: Database,
973
1020
  q: AxTimelineQuery & { onlyPending?: boolean } = {},
974
1021
  ): PmxAxSteeringMessage[] {
975
- interface Row {
976
- seq: number;
977
- id: string;
978
- message: string;
979
- delivered: number;
980
- created_at: string;
981
- source: string | null;
982
- }
983
1022
  const sql = q.onlyPending
984
1023
  ? 'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?'
985
1024
  : 'SELECT * FROM ax_steering ORDER BY seq DESC LIMIT ?';
986
- const rows = db.query<Row, [number]>(sql).all(clampTimelineLimit(q.limit));
987
- return rows
988
- .map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
989
- .filter((s): s is PmxAxSteeringMessage => s !== null);
1025
+ const rows = db.query<AxSteeringRow, [number]>(sql).all(clampTimelineLimit(q.limit));
1026
+ return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
990
1027
  }
991
1028
 
992
1029
  export function loadPendingAxSteeringFromDB(
993
1030
  db: Database,
994
1031
  options: { consumer?: string; limit?: number } = {},
995
1032
  ): PmxAxSteeringMessage[] {
996
- interface Row {
997
- seq: number;
998
- id: string;
999
- message: string;
1000
- delivered: number;
1001
- created_at: string;
1002
- source: string | null;
1003
- }
1004
- // FIFO (oldest undelivered first); exclude the consumer's own steering in SQL
1005
- // so the LIMIT is applied AFTER loop-prevention, not before.
1033
+ // FIFO (oldest undelivered first); exclude the consumer's own steering (loop
1034
+ // prevention) and any steering addressed to a DIFFERENT consumer (target scoping)
1035
+ // in SQL so the LIMIT is applied AFTER both filters, not before.
1006
1036
  const limit = clampTimelineLimit(options.limit);
1007
1037
  const rows = options.consumer
1008
1038
  ? db
1009
- .query<Row, [string, number]>(
1010
- 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq ASC LIMIT ?',
1039
+ .query<AxSteeringRow, [string, string, string, number]>(
1040
+ 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?) ORDER BY seq ASC LIMIT ?',
1011
1041
  )
1012
- .all(options.consumer, limit)
1013
- : db.query<Row, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq ASC LIMIT ?').all(limit);
1014
- return rows
1015
- .map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
1016
- .filter((s): s is PmxAxSteeringMessage => s !== null);
1042
+ .all(options.consumer, options.consumer, options.consumer, limit)
1043
+ : db
1044
+ .query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq ASC LIMIT ?')
1045
+ .all(limit);
1046
+ return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1017
1047
  }
1018
1048
 
1019
1049
  /**
1020
1050
  * NEWEST undelivered steering first (report #57) for the compact AX context lead
1021
1051
  * block — so a fresh steer is visible even behind a long backlog. Loop-safe: excludes
1022
- * the consumer's own steering in SQL so the LIMIT applies after loop-prevention.
1052
+ * the consumer's own steering and steering targeted at a different consumer in SQL
1053
+ * so the LIMIT applies after both filters.
1023
1054
  * Distinct from loadPendingAxSteeringFromDB (FIFO oldest-first) which the claim/ack
1024
1055
  * delivery queue uses for ordered processing.
1025
1056
  */
@@ -1027,35 +1058,27 @@ export function loadNewestPendingAxSteeringFromDB(
1027
1058
  db: Database,
1028
1059
  options: { consumer?: string; limit?: number } = {},
1029
1060
  ): PmxAxSteeringMessage[] {
1030
- interface Row {
1031
- seq: number;
1032
- id: string;
1033
- message: string;
1034
- delivered: number;
1035
- created_at: string;
1036
- source: string | null;
1037
- }
1038
1061
  const limit = clampTimelineLimit(options.limit);
1039
1062
  const rows = options.consumer
1040
1063
  ? db
1041
- .query<Row, [string, number]>(
1042
- 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq DESC LIMIT ?',
1064
+ .query<AxSteeringRow, [string, string, string, number]>(
1065
+ 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?) ORDER BY seq DESC LIMIT ?',
1043
1066
  )
1044
- .all(options.consumer, limit)
1045
- : db.query<Row, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?').all(limit);
1046
- return rows
1047
- .map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
1048
- .filter((s): s is PmxAxSteeringMessage => s !== null);
1067
+ .all(options.consumer, options.consumer, options.consumer, limit)
1068
+ : db
1069
+ .query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?')
1070
+ .all(limit);
1071
+ return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1049
1072
  }
1050
1073
 
1051
- /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own). */
1074
+ /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own; target-scoped). */
1052
1075
  export function countPendingAxSteeringFromDB(db: Database, consumer?: string): number {
1053
1076
  const n = consumer
1054
1077
  ? db
1055
- .query<{ n: number }, [string]>(
1056
- 'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?)',
1078
+ .query<{ n: number }, [string, string, string]>(
1079
+ 'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?)',
1057
1080
  )
1058
- .get(consumer)?.n
1081
+ .get(consumer, consumer, consumer)?.n
1059
1082
  : db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0').get()?.n;
1060
1083
  return Number(n ?? 0);
1061
1084
  }
@@ -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
@@ -1716,18 +1740,63 @@ class CanvasStateManager {
1716
1740
  }
1717
1741
 
1718
1742
  addWorkItem(
1719
- input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
1743
+ input: {
1744
+ title: string;
1745
+ status?: PmxAxWorkItemStatus;
1746
+ detail?: string | null;
1747
+ nodeIds?: string[];
1748
+ agentId?: string | null;
1749
+ },
1720
1750
  options: { source?: PmxAxSource } = {},
1721
1751
  ): PmxAxWorkItem {
1722
- return this.ax.addWorkItem(input, options);
1752
+ const item = this.ax.addWorkItem(input, options);
1753
+ this.mirrorAxWorkStatusToNodes(item.nodeIds, [], item.status);
1754
+ this.notifyWorkItemsChanged();
1755
+ return item;
1723
1756
  }
1724
1757
 
1725
1758
  updateWorkItem(
1726
1759
  id: string,
1727
- patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
1760
+ patch: {
1761
+ title?: string;
1762
+ status?: PmxAxWorkItemStatus;
1763
+ detail?: string | null;
1764
+ nodeIds?: string[];
1765
+ agentId?: string | null;
1766
+ },
1728
1767
  options: { source?: PmxAxSource } = {},
1729
1768
  ): PmxAxWorkItem | null {
1730
- return this.ax.updateWorkItem(id, patch, options);
1769
+ const previousNodeIds = this.ax.getWorkItems().find((w) => w.id === id)?.nodeIds ?? [];
1770
+ const item = this.ax.updateWorkItem(id, patch, options);
1771
+ if (!item) return null;
1772
+ this.mirrorAxWorkStatusToNodes(item.nodeIds, previousNodeIds, item.status);
1773
+ this.notifyWorkItemsChanged();
1774
+ return item;
1775
+ }
1776
+
1777
+ /**
1778
+ * Mirror a work item's status onto every linked canvas node's `data.axWorkStatus`,
1779
+ * through the standard node-update path (persistence + undo/redo, same as any other
1780
+ * node data change). Nodes unlinked by an updateWorkItem nodeIds change have the
1781
+ * mirrored status cleared.
1782
+ */
1783
+ private mirrorAxWorkStatusToNodes(nodeIds: string[], previousNodeIds: string[], status: PmxAxWorkItemStatus): void {
1784
+ for (const nodeId of nodeIds) {
1785
+ const node = this.nodes.get(nodeId);
1786
+ if (!node) continue;
1787
+ // No-op guard: a title-/agentId-only work-item patch must not push
1788
+ // history entries and SSE churn for chips that aren't changing.
1789
+ if (node.data.axWorkStatus === status) continue;
1790
+ this.updateNode(nodeId, { data: { ...node.data, axWorkStatus: status } });
1791
+ }
1792
+ for (const nodeId of previousNodeIds) {
1793
+ if (nodeIds.includes(nodeId)) continue;
1794
+ const node = this.nodes.get(nodeId);
1795
+ if (!node || node.data.axWorkStatus === undefined) continue;
1796
+ const data = { ...node.data };
1797
+ delete data.axWorkStatus;
1798
+ this.updateNode(nodeId, { data });
1799
+ }
1731
1800
  }
1732
1801
 
1733
1802
  // ── Approval gates (canvas-bound) ─────────────────────────────────
@@ -1873,7 +1942,7 @@ class CanvasStateManager {
1873
1942
  nodeIds?: string[];
1874
1943
  data?: Record<string, unknown> | null;
1875
1944
  },
1876
- options: { source?: PmxAxSource } = {},
1945
+ options: { source?: PmxAxSource; agentId?: string | null } = {},
1877
1946
  ): PmxAxEvent {
1878
1947
  return this.ax.recordAxEvent(input, options);
1879
1948
  }
@@ -1892,7 +1961,10 @@ class CanvasStateManager {
1892
1961
  return this.ax.addEvidence(input, options);
1893
1962
  }
1894
1963
 
1895
- recordSteeringMessage(message: string, options: { source?: PmxAxSource } = {}): PmxAxSteeringMessage {
1964
+ recordSteeringMessage(
1965
+ message: string,
1966
+ options: { source?: PmxAxSource; agentId?: string | null; target?: string | null } = {},
1967
+ ): PmxAxSteeringMessage {
1896
1968
  return this.ax.recordSteeringMessage(message, options);
1897
1969
  }
1898
1970
 
@@ -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
  /**