pmx-canvas 0.1.15 → 0.1.17

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 (48) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +25 -0
  4. package/dist/canvas/index.js +72 -72
  5. package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/dist/types/client/icons.d.ts +4 -0
  9. package/dist/types/client/nodes/ContextNode.d.ts +11 -2
  10. package/dist/types/client/nodes/StatusNode.d.ts +1 -0
  11. package/dist/types/client/state/canvas-store.d.ts +22 -3
  12. package/dist/types/client/state/intent-bridge.d.ts +2 -0
  13. package/dist/types/client/types.d.ts +20 -0
  14. package/dist/types/mcp/canvas-access.d.ts +1 -0
  15. package/dist/types/server/canvas-serialization.d.ts +23 -1
  16. package/dist/types/server/canvas-state.d.ts +27 -1
  17. package/dist/types/server/index.d.ts +7 -2
  18. package/dist/types/server/mutation-history.d.ts +1 -1
  19. package/dist/types/server/spatial-analysis.d.ts +11 -2
  20. package/package.json +1 -1
  21. package/skills/pmx-canvas/SKILL.md +17 -0
  22. package/src/cli/agent.ts +6 -0
  23. package/src/client/App.tsx +60 -3
  24. package/src/client/canvas/AnnotationLayer.tsx +28 -0
  25. package/src/client/canvas/CanvasViewport.tsx +169 -10
  26. package/src/client/canvas/ContextPinBar.tsx +3 -1
  27. package/src/client/canvas/DockedNode.tsx +4 -3
  28. package/src/client/canvas/use-pan-zoom.ts +10 -5
  29. package/src/client/icons.tsx +22 -0
  30. package/src/client/nodes/ContextNode.tsx +128 -6
  31. package/src/client/nodes/StatusNode.tsx +16 -1
  32. package/src/client/nodes/StatusSummary.tsx +2 -1
  33. package/src/client/state/canvas-store.ts +65 -7
  34. package/src/client/state/intent-bridge.ts +5 -1
  35. package/src/client/state/sse-bridge.ts +36 -2
  36. package/src/client/theme/global.css +25 -0
  37. package/src/client/types.ts +17 -0
  38. package/src/mcp/canvas-access.ts +10 -0
  39. package/src/mcp/server.ts +35 -4
  40. package/src/server/canvas-schema.ts +25 -0
  41. package/src/server/canvas-serialization.ts +69 -1
  42. package/src/server/canvas-state.ts +74 -2
  43. package/src/server/diagram-presets.ts +54 -19
  44. package/src/server/index.ts +20 -3
  45. package/src/server/mutation-history.ts +2 -0
  46. package/src/server/server.ts +101 -3
  47. package/src/server/spatial-analysis.ts +46 -1
  48. package/src/shared/semantic-attention.ts +4 -2
@@ -1,5 +1,5 @@
1
1
  import { batch, computed, signal } from '@preact/signals';
2
- import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
2
+ import { isExcalidrawNode, type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
3
3
  import { computeAutoArrange } from '../../shared/auto-arrange';
4
4
  import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
5
5
 
@@ -11,6 +11,7 @@ function logCanvasStoreError(action: string, error: unknown): void {
11
11
  export const viewport = signal<ViewportState>({ x: 0, y: 0, scale: 1 });
12
12
  export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
13
13
  export const edges = signal<Map<string, CanvasEdge>>(new Map());
14
+ export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
14
15
  export const activeNodeId = signal<string | null>(null);
15
16
  export const connectionStatus = signal<ConnectionStatus>('connecting');
16
17
  export const sessionId = signal<string>('');
@@ -258,6 +259,50 @@ export function removeEdgesForNode(nodeId: string): void {
258
259
  if (changed) edges.value = next;
259
260
  }
260
261
 
262
+ export function addAnnotation(annotation: CanvasAnnotation): void {
263
+ const next = new Map(annotations.value);
264
+ next.set(annotation.id, annotation);
265
+ annotations.value = next;
266
+ }
267
+
268
+ export function removeAnnotation(id: string): void {
269
+ const next = new Map(annotations.value);
270
+ if (!next.delete(id)) return;
271
+ annotations.value = next;
272
+ }
273
+
274
+ export async function createAnnotationFromClient(input: {
275
+ points: CanvasAnnotation['points'];
276
+ color: string;
277
+ width: number;
278
+ label?: string;
279
+ }): Promise<{ ok: boolean }> {
280
+ try {
281
+ const res = await fetch('/api/canvas/annotation', {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify(input),
285
+ });
286
+ return { ok: res.ok };
287
+ } catch (error) {
288
+ logCanvasStoreError('createAnnotationFromClient', error);
289
+ return { ok: false };
290
+ }
291
+ }
292
+
293
+ export async function removeAnnotationFromClient(id: string): Promise<{ ok: boolean }> {
294
+ try {
295
+ const res = await fetch(`/api/canvas/annotation/${encodeURIComponent(id)}`, {
296
+ method: 'DELETE',
297
+ });
298
+ if (res.ok) removeAnnotation(id);
299
+ return { ok: res.ok };
300
+ } catch (error) {
301
+ logCanvasStoreError('removeAnnotationFromClient', error);
302
+ return { ok: false };
303
+ }
304
+ }
305
+
261
306
  export function resizeNode(id: string, size: { width: number; height: number }): void {
262
307
  const existing = nodes.value.get(id);
263
308
  if (!existing) return;
@@ -330,13 +375,20 @@ export function replaceViewport(next: ViewportState): void {
330
375
  }
331
376
 
332
377
  export function commitViewport(next: ViewportState): void {
378
+ commitViewportWithOptions(next);
379
+ }
380
+
381
+ function commitViewportWithOptions(
382
+ next: ViewportState,
383
+ options: { recordHistory?: boolean } = {},
384
+ ): void {
333
385
  viewport.value = next;
334
- persistLayout();
335
- void updateViewportFromClient(next);
386
+ persistLayout(options);
387
+ void updateViewportFromClient(next, options);
336
388
  }
337
389
 
338
390
  export function applyServerCanvasLayout(
339
- layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
391
+ layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState; annotations?: CanvasAnnotation[] },
340
392
  options: { applyViewport?: boolean } = {},
341
393
  ): void {
342
394
  const nextNodes = new Map<string, CanvasNodeState>();
@@ -353,6 +405,10 @@ export function applyServerCanvasLayout(
353
405
  for (const edge of edgeSource) {
354
406
  nextEdges.set(edge.id, edge);
355
407
  }
408
+ const nextAnnotations = new Map<string, CanvasAnnotation>();
409
+ for (const annotation of layout.annotations ?? []) {
410
+ nextAnnotations.set(annotation.id, annotation);
411
+ }
356
412
 
357
413
  const nextActiveNodeId =
358
414
  activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
@@ -368,6 +424,7 @@ export function applyServerCanvasLayout(
368
424
  maxZ = nextMaxZ;
369
425
  nodes.value = nextNodes;
370
426
  edges.value = nextEdges;
427
+ annotations.value = nextAnnotations;
371
428
  activeNodeId.value = nextActiveNodeId;
372
429
  expandedNodeId.value = nextExpandedNodeId;
373
430
  if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
@@ -394,6 +451,7 @@ function easeOutCubic(t: number): number {
394
451
  export function animateViewport(
395
452
  target: ViewportState,
396
453
  duration = 300,
454
+ options: { recordHistory?: boolean } = {},
397
455
  ): void {
398
456
  if (animationId !== null) cancelAnimationFrame(animationId);
399
457
 
@@ -415,7 +473,7 @@ export function animateViewport(
415
473
  animationId = requestAnimationFrame(tick);
416
474
  } else {
417
475
  animationId = null;
418
- commitViewport(target);
476
+ commitViewportWithOptions(target, options);
419
477
  }
420
478
  }
421
479
 
@@ -540,7 +598,7 @@ export function fitAll(containerW: number, containerH: number): void {
540
598
  }
541
599
 
542
600
  // ── Focus node ────────────────────────────────────────────────
543
- export function focusNode(id: string): void {
601
+ export function focusNode(id: string, options: { recordHistory?: boolean } = {}): void {
544
602
  const node = nodes.value.get(id);
545
603
  if (!node) return;
546
604
  const v = viewport.value;
@@ -550,7 +608,7 @@ export function focusNode(id: string): void {
550
608
  x: window.innerWidth / 2 - cx * v.scale,
551
609
  y: window.innerHeight / 2 - cy * v.scale,
552
610
  scale: v.scale,
553
- });
611
+ }, 300, options);
554
612
  bringToFront(id);
555
613
  }
556
614
 
@@ -231,11 +231,15 @@ export async function removeNodeFromClient(id: string): Promise<{ ok: boolean; r
231
231
  /** Commit the current viewport to the authoritative server state. */
232
232
  export async function updateViewportFromClient(
233
233
  viewport: { x: number; y: number; scale: number },
234
+ options: { recordHistory?: boolean } = {},
234
235
  ): Promise<{ ok: boolean }> {
235
236
  return requestJson('updateViewportFromClient', '/api/canvas/viewport', { ok: false }, {
236
237
  method: 'POST',
237
238
  headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify(viewport),
239
+ body: JSON.stringify({
240
+ ...viewport,
241
+ ...(options.recordHistory === false ? { recordHistory: false } : {}),
242
+ }),
239
243
  });
240
244
  }
241
245
 
@@ -1,6 +1,6 @@
1
1
  import { findOpenCanvasPosition } from '../utils/placement.js';
2
2
  import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
3
- import type { CanvasEdge, CanvasNodeState } from '../types';
3
+ import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
4
4
  import {
5
5
  activeNodeId,
6
6
  addEdge,
@@ -229,7 +229,7 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
229
229
  });
230
230
  addNode(node);
231
231
  if (!node.dockPosition) {
232
- focusNode(id);
232
+ focusNode(id, { recordHistory: false });
233
233
  }
234
234
  }
235
235
 
@@ -310,6 +310,7 @@ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
310
310
  || value === 'trace'
311
311
  || value === 'file'
312
312
  || value === 'image'
313
+ || value === 'html'
313
314
  || value === 'group';
314
315
  }
315
316
 
@@ -334,6 +335,12 @@ function parseCanvasSize(value: unknown): { width: number; height: number } | nu
334
335
  return { width: size.width, height: size.height };
335
336
  }
336
337
 
338
+ function parseCanvasRect(value: unknown): { x: number; y: number; width: number; height: number } | null {
339
+ const position = parseCanvasPosition(value);
340
+ const size = parseCanvasSize(value);
341
+ return position && size ? { ...position, ...size } : null;
342
+ }
343
+
337
344
  function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
338
345
  if (typeof raw.id !== 'string' || !raw.id) return null;
339
346
  if (!isCanvasNodeType(raw.type)) return null;
@@ -379,6 +386,28 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
379
386
  };
380
387
  }
381
388
 
389
+ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
390
+ if (typeof raw.id !== 'string' || !raw.id) return null;
391
+ if (raw.type !== 'freehand') return null;
392
+ if (!Array.isArray(raw.points)) return null;
393
+ const points = raw.points
394
+ .map((point) => parseCanvasPosition(point))
395
+ .filter((point): point is { x: number; y: number } => point !== null);
396
+ const bounds = parseCanvasRect(raw.bounds);
397
+ if (points.length < 2 || !bounds) return null;
398
+
399
+ return {
400
+ id: raw.id,
401
+ type: 'freehand',
402
+ points,
403
+ bounds,
404
+ color: typeof raw.color === 'string' ? raw.color : '#f97316',
405
+ width: typeof raw.width === 'number' ? raw.width : 4,
406
+ ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
407
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
408
+ };
409
+ }
410
+
382
411
  // ── SSE event handlers ───────────────────────────────────────
383
412
  function handleConnected(data: Record<string, unknown>): void {
384
413
  sessionId.value = (data.sessionId as string) || '';
@@ -801,6 +830,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
801
830
  | {
802
831
  nodes?: Array<Record<string, unknown>>;
803
832
  edges?: Array<Record<string, unknown>>;
833
+ annotations?: Array<Record<string, unknown>>;
804
834
  viewport?: Record<string, unknown>;
805
835
  }
806
836
  | undefined;
@@ -814,6 +844,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
814
844
  const serverEdges = Array.isArray(layout.edges)
815
845
  ? layout.edges.map(parseCanvasEdge).filter((edge): edge is CanvasEdge => edge !== null)
816
846
  : Array.from(edges.value.values());
847
+ const serverAnnotations = Array.isArray(layout.annotations)
848
+ ? layout.annotations.map(parseCanvasAnnotation).filter((annotation): annotation is CanvasAnnotation => annotation !== null)
849
+ : undefined;
817
850
  const nextViewport = layout.viewport
818
851
  ? {
819
852
  x: typeof layout.viewport.x === 'number' ? layout.viewport.x : 0,
@@ -827,6 +860,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
827
860
  ...(nextViewport ? { viewport: nextViewport } : {}),
828
861
  nodes: serverNodes,
829
862
  edges: serverEdges,
863
+ ...(serverAnnotations ? { annotations: serverAnnotations } : {}),
830
864
  }, { applyViewport: shouldApplyViewport });
831
865
 
832
866
  syncAttentionFromSse({ event: 'canvas-layout-update', data });
@@ -50,6 +50,7 @@
50
50
  --c-accent-hover: #6ECAFF;
51
51
  --c-warn-hover: #f5d06b;
52
52
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
53
+ --c-annotation: #F4EFE6;
53
54
  /* ── Non-color tokens ────────────────────────────────────── */
54
55
  --font: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
55
56
  --mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
@@ -109,6 +110,7 @@
109
110
  --c-accent-hover: #1588CE;
110
111
  --c-warn-hover: #dab040;
111
112
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(8, 21, 36, 0.02));
113
+ --c-annotation: #081524;
112
114
  }
113
115
 
114
116
  :root[data-theme="high-contrast"] {
@@ -162,6 +164,7 @@
162
164
  --c-accent-hover: #33ffff;
163
165
  --c-warn-hover: #ffff33;
164
166
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
167
+ --c-annotation: #ffff00;
165
168
  }
166
169
 
167
170
  * {
@@ -1471,6 +1474,28 @@ body,
1471
1474
  z-index: 9998;
1472
1475
  }
1473
1476
 
1477
+ .annotation-layer {
1478
+ position: absolute;
1479
+ inset: 0;
1480
+ width: 1px;
1481
+ height: 1px;
1482
+ overflow: visible;
1483
+ pointer-events: none;
1484
+ z-index: 45;
1485
+ }
1486
+
1487
+ .annotation-capture-layer {
1488
+ position: absolute;
1489
+ inset: 0;
1490
+ z-index: 9996;
1491
+ pointer-events: none;
1492
+ background: color-mix(in srgb, var(--c-accent) 5%, transparent);
1493
+ }
1494
+
1495
+ .annotation-capture-layer.erasing {
1496
+ background: color-mix(in srgb, var(--c-danger) 6%, transparent);
1497
+ }
1498
+
1474
1499
  /* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
1475
1500
  .drop-zone-overlay {
1476
1501
  position: absolute;
@@ -41,6 +41,22 @@ export interface CanvasEdge {
41
41
  animated?: boolean;
42
42
  }
43
43
 
44
+ export interface CanvasAnnotationPoint {
45
+ x: number;
46
+ y: number;
47
+ }
48
+
49
+ export interface CanvasAnnotation {
50
+ id: string;
51
+ type: 'freehand';
52
+ points: CanvasAnnotationPoint[];
53
+ bounds: { x: number; y: number; width: number; height: number };
54
+ color: string;
55
+ width: number;
56
+ label?: string;
57
+ createdAt: string;
58
+ }
59
+
44
60
  export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
45
61
 
46
62
  // ── Shared constants for node type display ──────────────────
@@ -93,4 +109,5 @@ export interface CanvasLayout {
93
109
  viewport: ViewportState;
94
110
  nodes: CanvasNodeState[];
95
111
  edges: CanvasEdge[];
112
+ annotations?: CanvasAnnotation[];
96
113
  }
@@ -106,6 +106,7 @@ export interface CanvasAccess {
106
106
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
107
107
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
108
108
  removeNode(id: string): Promise<void>;
109
+ removeAnnotation(id: string): Promise<boolean>;
109
110
  addEdge(input: AddEdgeInput): Promise<string>;
110
111
  removeEdge(id: string): Promise<void>;
111
112
  createGroup(input: CreateGroupInput): Promise<string>;
@@ -203,6 +204,10 @@ class LocalCanvasAccess implements CanvasAccess {
203
204
  this.canvas.removeNode(id);
204
205
  }
205
206
 
207
+ async removeAnnotation(id: string): Promise<boolean> {
208
+ return this.canvas.removeAnnotation(id);
209
+ }
210
+
206
211
  async addEdge(input: AddEdgeInput): Promise<string> {
207
212
  return this.canvas.addEdge(input);
208
213
  }
@@ -455,6 +460,11 @@ class RemoteCanvasAccess implements CanvasAccess {
455
460
  await this.requestJson<unknown>('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
456
461
  }
457
462
 
463
+ async removeAnnotation(id: string): Promise<boolean> {
464
+ const response = await this.requestJson<{ ok?: boolean }>('DELETE', `/api/canvas/annotation/${encodeURIComponent(id)}`);
465
+ return response.ok === true;
466
+ }
467
+
458
468
  async addEdge(input: AddEdgeInput): Promise<string> {
459
469
  const response = await this.requestJson<{ id?: string }>('POST', '/api/canvas/edge', input);
460
470
  if (!response.id) throw new Error('Canvas edge response did not include an edge id.');
package/src/mcp/server.ts CHANGED
@@ -29,7 +29,7 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
32
- import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
32
+ import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode, summarizeCanvasAnnotationForContext } from '../server/canvas-serialization.js';
33
33
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
34
34
 
35
35
  let canvas: CanvasAccess | null = null;
@@ -191,6 +191,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
191
191
  return {
192
192
  summary: buildSummaryFromLayout(layout, pinnedIds),
193
193
  viewport: layout.viewport,
194
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
194
195
  nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
195
196
  edges: layout.edges.map((edge) => ({
196
197
  id: edge.id,
@@ -204,6 +205,13 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
204
205
  };
205
206
  }
206
207
 
208
+ function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
209
+ return {
210
+ ...serializeCanvasLayout(layout),
211
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
212
+ };
213
+ }
214
+
207
215
  function compactBatchValue(value: unknown): unknown {
208
216
  if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
209
217
  const record = value as Record<string, unknown>;
@@ -248,6 +256,8 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
248
256
  return {
249
257
  totalNodes: layout.nodes.length,
250
258
  totalEdges: layout.edges.length,
259
+ totalAnnotations: (layout.annotations ?? []).length,
260
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
251
261
  nodesByType,
252
262
  pinnedCount: pinned.size,
253
263
  pinnedTitles,
@@ -263,6 +273,7 @@ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['ge
263
273
  return {
264
274
  nodeCount: layout.nodes.length,
265
275
  edgeCount: layout.edges.length,
276
+ annotationCount: (layout.annotations ?? []).length,
266
277
  nodesByType,
267
278
  viewport: layout.viewport,
268
279
  };
@@ -287,7 +298,7 @@ export async function startMcpServer(): Promise<void> {
287
298
  const c = await ensureCanvas();
288
299
  const layout = await c.getLayout();
289
300
  const payload = wantsFullPayload(input)
290
- ? serializeCanvasLayout(layout)
301
+ ? agentSafeFullLayoutPayload(layout)
291
302
  : compactLayoutPayload(layout, await c.getPinnedNodeIds());
292
303
  return {
293
304
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
@@ -902,6 +913,26 @@ export async function startMcpServer(): Promise<void> {
902
913
  },
903
914
  );
904
915
 
916
+ // ── canvas_remove_annotation ─────────────────────────────────────
917
+ server.tool(
918
+ 'canvas_remove_annotation',
919
+ 'Remove a human-drawn canvas annotation by ID.',
920
+ { id: z.string().describe('Annotation ID to remove') },
921
+ async ({ id }) => {
922
+ const c = await ensureCanvas();
923
+ const removed = await c.removeAnnotation(id);
924
+ if (!removed) {
925
+ return {
926
+ content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
927
+ isError: true,
928
+ };
929
+ }
930
+ return {
931
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
932
+ };
933
+ },
934
+ );
935
+
905
936
  // ── canvas_add_edge ────────────────────────────────────────────
906
937
  server.tool(
907
938
  'canvas_add_edge',
@@ -1401,7 +1432,7 @@ export async function startMcpServer(): Promise<void> {
1401
1432
  },
1402
1433
  async () => {
1403
1434
  const c = await ensureCanvas();
1404
- const layout = serializeCanvasLayout(await c.getLayout());
1435
+ const layout = agentSafeFullLayoutPayload(await c.getLayout());
1405
1436
  return {
1406
1437
  contents: [
1407
1438
  {
@@ -1452,7 +1483,7 @@ export async function startMcpServer(): Promise<void> {
1452
1483
  async () => {
1453
1484
  const c = await ensureCanvas();
1454
1485
  const layout = await c.getLayout();
1455
- const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
1486
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()), layout.annotations ?? []);
1456
1487
  return {
1457
1488
  contents: [
1458
1489
  {
@@ -223,6 +223,31 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
223
223
  'Webpage nodes persist `data.provenance` with the source URL and refresh strategy so reopened snapshots can be re-fetched.',
224
224
  ],
225
225
  },
226
+ {
227
+ type: 'html',
228
+ kind: 'node',
229
+ description: 'Sandboxed iframe node rendered from inline HTML.',
230
+ endpoint: '/api/canvas/node',
231
+ mcpTool: 'canvas_add_html_node',
232
+ fields: [
233
+ { name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
234
+ { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
235
+ { name: 'x', type: 'number', required: false, description: 'Optional X position.' },
236
+ { name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
237
+ { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
238
+ { name: 'height', type: 'number', required: false, description: 'Optional node height.' },
239
+ { name: 'strictSize', type: 'boolean', required: false, description: 'Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting.', aliases: ['strict-size', 'scroll-overflow'] },
240
+ ],
241
+ example: {
242
+ type: 'html',
243
+ title: 'HTML Widget',
244
+ html: '<main><h1>Hello from PMX Canvas</h1></main>',
245
+ },
246
+ notes: [
247
+ 'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
248
+ 'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
249
+ ],
250
+ },
226
251
  {
227
252
  type: 'mcp-app',
228
253
  kind: 'node',
@@ -1,5 +1,5 @@
1
1
  import { canvasState } from './canvas-state.js';
2
- import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
2
+ import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
3
3
  import {
4
4
  normalizeCanvasNodeData,
5
5
  type CanvasNodeProvenance,
@@ -19,6 +19,26 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
19
19
  nodes: SerializedCanvasNode[];
20
20
  }
21
21
 
22
+ export interface CanvasAnnotationSummary {
23
+ id: string;
24
+ type: CanvasAnnotation['type'];
25
+ bounds: CanvasAnnotation['bounds'];
26
+ color: string;
27
+ width: number;
28
+ pointCount: number;
29
+ label: string | null;
30
+ createdAt: string;
31
+ }
32
+
33
+ export interface CanvasAnnotationContextSummary {
34
+ id: string;
35
+ label: string | null;
36
+ bounds: CanvasAnnotation['bounds'];
37
+ targetNodeIds: string[];
38
+ targetNodeTitles: string[];
39
+ target: string;
40
+ }
41
+
22
42
  interface BlobSummary {
23
43
  stored: 'sidecar';
24
44
  path: string;
@@ -104,9 +124,55 @@ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): Se
104
124
  };
105
125
  }
106
126
 
127
+ export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary {
128
+ return {
129
+ id: annotation.id,
130
+ type: annotation.type,
131
+ bounds: annotation.bounds,
132
+ color: annotation.color,
133
+ width: annotation.width,
134
+ pointCount: annotation.points.length,
135
+ label: annotation.label ?? null,
136
+ createdAt: annotation.createdAt,
137
+ };
138
+ }
139
+
140
+ function rectsOverlap(
141
+ a: { x: number; y: number; width: number; height: number },
142
+ b: { x: number; y: number; width: number; height: number },
143
+ ): boolean {
144
+ return a.x <= b.x + b.width &&
145
+ a.x + a.width >= b.x &&
146
+ a.y <= b.y + b.height &&
147
+ a.y + a.height >= b.y;
148
+ }
149
+
150
+ export function summarizeCanvasAnnotationForContext(
151
+ annotation: CanvasAnnotation,
152
+ nodes: CanvasNodeState[],
153
+ ): CanvasAnnotationContextSummary {
154
+ const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
155
+ x: node.position.x,
156
+ y: node.position.y,
157
+ width: node.size.width,
158
+ height: node.size.height,
159
+ }));
160
+ const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
161
+ return {
162
+ id: annotation.id,
163
+ label: annotation.label ?? null,
164
+ bounds: annotation.bounds,
165
+ targetNodeIds: targetNodes.map((node) => node.id),
166
+ targetNodeTitles,
167
+ target: targetNodeTitles.length > 0 ? targetNodeTitles.join(', ') : 'empty canvas region',
168
+ };
169
+ }
170
+
107
171
  export interface CanvasSummary {
108
172
  totalNodes: number;
109
173
  totalEdges: number;
174
+ totalAnnotations: number;
175
+ annotations: CanvasAnnotationContextSummary[];
110
176
  nodesByType: Record<string, number>;
111
177
  pinnedCount: number;
112
178
  pinnedTitles: string[];
@@ -130,6 +196,8 @@ export function buildCanvasSummary(): CanvasSummary {
130
196
  return {
131
197
  totalNodes: layout.nodes.length,
132
198
  totalEdges: layout.edges.length,
199
+ totalAnnotations: layout.annotations.length,
200
+ annotations: layout.annotations.map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
133
201
  nodesByType: typeCounts,
134
202
  pinnedCount: pinnedIds.size,
135
203
  pinnedTitles,