pmx-canvas 0.6.4 → 0.6.5

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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/Readme.md +7 -1
  3. package/dist/canvas/global.css +58 -4
  4. package/dist/canvas/index.js +92 -92
  5. package/dist/canvas/mermaid-entry.js +258 -258
  6. package/dist/types/cli/agent.d.ts +1 -0
  7. package/dist/types/cli/commands/record.d.ts +12 -0
  8. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  9. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  10. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  11. package/dist/types/client/state/presentation.d.ts +3 -0
  12. package/dist/types/client/types.d.ts +1 -1
  13. package/dist/types/json-render/catalog.d.ts +2 -2
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/server/canvas-db.d.ts +2 -0
  16. package/dist/types/server/canvas-operations.d.ts +22 -0
  17. package/dist/types/server/canvas-schema.d.ts +1 -0
  18. package/dist/types/server/canvas-state.d.ts +6 -0
  19. package/dist/types/server/canvas-validation.d.ts +3 -0
  20. package/dist/types/server/html-surface.d.ts +1 -1
  21. package/dist/types/server/index.d.ts +4 -0
  22. package/dist/types/server/operations/ops/app.d.ts +2 -0
  23. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  24. package/dist/types/server/operations/ops/nodes.d.ts +3 -1
  25. package/dist/types/server/web-artifacts.d.ts +3 -0
  26. package/dist/types/shared/tour.d.ts +72 -0
  27. package/docs/cli.md +89 -0
  28. package/docs/http-api.md +79 -4
  29. package/docs/mcp.md +25 -1
  30. package/docs/node-types.md +80 -0
  31. package/package.json +1 -1
  32. package/skills/pmx-canvas/SKILL.md +10 -2
  33. package/skills/pmx-canvas/references/full-reference.md +1 -1
  34. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  35. package/src/cli/agent.ts +5 -0
  36. package/src/cli/commands/nodes.ts +10 -0
  37. package/src/cli/commands/record.ts +218 -0
  38. package/src/cli/index.ts +2 -0
  39. package/src/client/App.tsx +7 -1
  40. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  41. package/src/client/canvas/CanvasNode.tsx +8 -0
  42. package/src/client/canvas/IntentLayer.tsx +9 -2
  43. package/src/client/canvas/Presentation.tsx +108 -0
  44. package/src/client/canvas/TopBar.tsx +28 -2
  45. package/src/client/canvas/use-node-drag.ts +8 -3
  46. package/src/client/mermaid-entry.ts +39 -0
  47. package/src/client/nodes/HtmlNode.tsx +12 -13
  48. package/src/client/nodes/McpAppNode.tsx +105 -13
  49. package/src/client/nodes/MermaidNode.tsx +3 -2
  50. package/src/client/state/intent-store.ts +5 -1
  51. package/src/client/state/presentation.ts +7 -0
  52. package/src/client/state/session-store.ts +3 -0
  53. package/src/client/state/sse-bridge.ts +3 -0
  54. package/src/client/theme/global.css +58 -4
  55. package/src/json-render/server.ts +29 -0
  56. package/src/server/agent-presence.ts +15 -9
  57. package/src/server/canvas-db.ts +15 -0
  58. package/src/server/canvas-operations.ts +115 -22
  59. package/src/server/canvas-schema.ts +10 -0
  60. package/src/server/canvas-state.ts +39 -4
  61. package/src/server/canvas-validation.ts +9 -1
  62. package/src/server/demo-state.json +11 -7
  63. package/src/server/html-primitives.ts +199 -91
  64. package/src/server/html-surface.ts +8 -3
  65. package/src/server/index.ts +12 -0
  66. package/src/server/operations/composites.ts +3 -1
  67. package/src/server/operations/ops/app.ts +10 -0
  68. package/src/server/operations/ops/batch.ts +12 -3
  69. package/src/server/operations/ops/groups.ts +14 -7
  70. package/src/server/operations/ops/json-render.ts +37 -5
  71. package/src/server/operations/ops/nodes.ts +42 -16
  72. package/src/server/operations/ops/viewport.ts +32 -0
  73. package/src/server/operations/registry.ts +6 -0
  74. package/src/server/scope-fence.ts +1 -1
  75. package/src/server/server.ts +9 -6
  76. package/src/server/web-artifacts.ts +9 -2
  77. package/src/server/workboard.ts +3 -3
  78. package/src/shared/tour.ts +87 -0
@@ -1,4 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { derivedTour, type Tour } from '../shared/tour.js';
3
+ export type { Tour, TourStop } from '../shared/tour.js';
2
4
  import { agentPresence } from './agent-presence.js';
3
5
  import { checkFenceTarget, type FenceTarget } from './scope-fence.js';
4
6
  import type { AgentPhase, AgentPresence, AgentPresenceSnapshot } from '../shared/agent-presence.js';
@@ -547,6 +549,16 @@ export class PmxCanvas extends EventEmitter {
547
549
  );
548
550
  }
549
551
 
552
+ getTour(): Tour {
553
+ return canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes);
554
+ }
555
+
556
+ setTour(tour: Tour | null): void {
557
+ assertInsideFence('tour.set', { boardWide: true });
558
+ canvasState.setTour(tour);
559
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
560
+ }
561
+
550
562
  clear(): void {
551
563
  assertInsideFence('canvas.clear', { boardWide: true });
552
564
  for (const node of canvasState.getLayout().nodes) {
@@ -203,8 +203,10 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
203
203
  toolName: 'canvas_view',
204
204
  description:
205
205
  'Canvas viewport and layout control. Action "arrange" auto-lays-out nodes (grid/columns/etc.); "focus" pans/zooms the viewport to a node; "fit" zooms to fit all nodes in view; "clear" removes every node and edge from the canvas; "remove-annotation" deletes a human-drawn annotation by id.',
206
- actionSummary: 'arrange | focus | fit | clear | remove-annotation',
206
+ actionSummary: 'arrange | focus | fit | clear | remove-annotation | get-tour | set-tour',
207
207
  actions: {
208
+ 'get-tour': 'tour.get',
209
+ 'set-tour': 'tour.set',
208
210
  arrange: 'arrange',
209
211
  focus: 'node.focus',
210
212
  fit: 'view.fit',
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { z } from 'zod';
34
34
  import { canvasState } from '../../canvas-state.js';
35
+ import { describeCreateSize, type CanvasSizeAdjustment } from '../../canvas-operations.js';
35
36
  import {
36
37
  closeMcpAppSession,
37
38
  openMcpApp as openExternalMcpApp,
@@ -75,6 +76,7 @@ export interface OpenMcpAppCoreResult {
75
76
  toolCallId: string;
76
77
  sessionId: string;
77
78
  resourceUri: string;
79
+ sizeAdjustment?: CanvasSizeAdjustment;
78
80
  }
79
81
 
80
82
  /**
@@ -121,6 +123,7 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
121
123
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
122
124
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
123
125
  : opened.toolResult;
126
+ const existingNodeIds = new Set(canvasState.getLayout().nodes.map((node) => node.id));
124
127
  ctx.emit('ext-app-open', {
125
128
  toolCallId,
126
129
  nodeId: nodeIdSeed,
@@ -158,6 +161,11 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
158
161
  getNode: (id) => canvasState.getNode(id),
159
162
  listNodes: () => canvasState.getLayout().nodes,
160
163
  });
164
+ const node = nodeId ? canvasState.getNode(nodeId) : undefined;
165
+ const sizeAdjustment =
166
+ node && !existingNodeIds.has(node.id)
167
+ ? describeCreateSize(input.width, input.height, node.size.width, node.size.height, node.size)
168
+ : undefined;
161
169
  return {
162
170
  ok: true,
163
171
  ...(nodeId ? { id: nodeId } : {}),
@@ -165,6 +173,7 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
165
173
  toolCallId,
166
174
  sessionId: opened.sessionId,
167
175
  resourceUri: opened.resourceUri,
176
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
168
177
  };
169
178
  }
170
179
 
@@ -388,6 +397,7 @@ function webArtifactEnvelope(result: WebArtifactCanvasBuildResult, includeLogs:
388
397
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
389
398
  nodeId: result.nodeId,
390
399
  url: result.url,
400
+ ...(result.sizeAdjustment ? { sizeAdjustment: result.sizeAdjustment } : {}),
391
401
  metadata: result.metadata,
392
402
  logs: result.logs,
393
403
  ...(includeLogs ? { stdout: result.stdout, stderr: result.stderr } : {}),
@@ -145,7 +145,11 @@ function createInternalBatchNode(args: Record<string, unknown>): Record<string,
145
145
  defaultHeight: 200,
146
146
  });
147
147
 
148
- return { ok: true, ...serializeCanvasNodeCompact(created.node) };
148
+ return {
149
+ ok: true,
150
+ ...serializeCanvasNodeCompact(created.node),
151
+ ...(created.sizeAdjustment ? { sizeAdjustment: created.sizeAdjustment } : {}),
152
+ };
149
153
  }
150
154
 
151
155
  /**
@@ -165,7 +169,7 @@ function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
165
169
  const id = typeof body.id === 'string' ? body.id : '';
166
170
  const node = id ? canvasState.getNode(id) : undefined;
167
171
  const extras: Record<string, unknown> = {};
168
- for (const key of ['fetch', 'error', 'url', 'spec']) {
172
+ for (const key of ['fetch', 'error', 'url', 'spec', 'sizeAdjustment']) {
169
173
  if (body[key] !== undefined) extras[key] = body[key];
170
174
  }
171
175
  if (node) return { ok: true, ...serializeCanvasNodeCompact(node), ...extras };
@@ -183,7 +187,11 @@ function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
183
187
  if (op === 'group.add') {
184
188
  const groupId = typeof body.groupId === 'string' ? body.groupId : '';
185
189
  const group = groupId ? canvasState.getNode(groupId) : undefined;
186
- return { ok: true, ...(group ? serializeCanvasNode(group) : { id: groupId }) };
190
+ return {
191
+ ok: true,
192
+ ...(group ? serializeCanvasNode(group) : { id: groupId }),
193
+ ...(body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {}),
194
+ };
187
195
  }
188
196
 
189
197
  // pin.set/add/remove: legacy pushed { ok:true, ...{ count, nodeIds } }; wire shape drops nodeIds.
@@ -305,6 +313,7 @@ function compactBatchValue(value: unknown): unknown {
305
313
  'content',
306
314
  'position',
307
315
  'size',
316
+ 'sizeAdjustment',
308
317
  'fetch',
309
318
  'error',
310
319
  'from',
@@ -42,7 +42,7 @@ const groupCreateShape = {
42
42
 
43
43
  const groupCreateSchema = z.looseObject(groupCreateShape);
44
44
 
45
- const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>, CanvasNodeState>({
45
+ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>, ReturnType<typeof createCanvasGroup>>({
46
46
  name: 'group.create',
47
47
  mutates: true,
48
48
  input: groupCreateSchema,
@@ -69,7 +69,12 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
69
69
  formatResult: (result, input) => {
70
70
  const body = isRecord(result) ? result : {};
71
71
  const node = body.node as CanvasNodeState | undefined;
72
- const payload = node ? createdNodePayloadFromNode(node, input) : { ok: true };
72
+ const payload = node
73
+ ? {
74
+ ...createdNodePayloadFromNode(node, input),
75
+ ...(body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {}),
76
+ }
77
+ : { ok: true };
73
78
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
74
79
  },
75
80
  },
@@ -93,7 +98,7 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
93
98
  );
94
99
  }
95
100
  }
96
- const { node } = createCanvasGroup({
101
+ return createCanvasGroup({
97
102
  title,
98
103
  childIds,
99
104
  color,
@@ -103,9 +108,11 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
103
108
  height,
104
109
  ...(childLayout ? { childLayout } : {}),
105
110
  });
106
- return node;
107
111
  },
108
- serialize: (node) => buildNodeResponse(node),
112
+ serialize: ({ node, sizeAdjustment }) => ({
113
+ ...buildNodeResponse(node),
114
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
115
+ }),
109
116
  });
110
117
 
111
118
  // ── group.add ─────────────────────────────────────────────────
@@ -157,9 +164,9 @@ const groupAddOperation = defineOperation<z.infer<typeof groupAddSchema>, Record
157
164
  if (!groupId || childIds.length === 0) {
158
165
  throw new OperationError('Missing groupId or childIds.');
159
166
  }
160
- const { ok } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
167
+ const { ok, sizeAdjustment } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
161
168
  if (!ok) throw new OperationError('Group not found or no valid children.');
162
- return { ok: true, groupId };
169
+ return { ok: true, groupId, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
163
170
  },
164
171
  });
165
172
 
@@ -36,6 +36,7 @@ import {
36
36
  createCanvasGraphNode,
37
37
  createCanvasJsonRenderNode,
38
38
  createCanvasStreamingJsonRenderNode,
39
+ type CanvasSizeAdjustment,
39
40
  } from '../../canvas-operations.js';
40
41
  import { describeCanvasSchema, validateStructuredCanvasPayload } from '../../canvas-schema.js';
41
42
  import { isHtmlPrimitiveKind } from '../../html-primitives.js';
@@ -84,9 +85,13 @@ function structuredNodeToolResult(result: unknown): { content: Array<{ type: 'te
84
85
  const body = isRecord(result) ? result : {};
85
86
  const node = body.node as CanvasNodeState | undefined;
86
87
  const payload = {
87
- ...(node ? createdNodePayloadFromNode(node) : { ok: true }),
88
+ ...(node
89
+ ? createdNodePayloadFromNode(node, {}, body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {})
90
+ : { ok: true }),
88
91
  url: body.url,
89
92
  spec: body.spec,
93
+ ...(Array.isArray(body.warnings) ? { warnings: body.warnings } : {}),
94
+ ...(body.warning ? { warning: body.warning } : {}),
90
95
  };
91
96
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
92
97
  }
@@ -168,7 +173,13 @@ const jsonRenderAddOperation = defineOperation<
168
173
  throw new OperationError(error instanceof Error ? error.message : String(error));
169
174
  }
170
175
  },
171
- serialize: (result) => ({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec }),
176
+ serialize: (result) => ({
177
+ ...buildNodeResponse(result.node),
178
+ url: result.url,
179
+ spec: result.spec,
180
+ warnings: result.warnings,
181
+ ...(result.sizeAdjustment ? { sizeAdjustment: result.sizeAdjustment } : {}),
182
+ }),
172
183
  });
173
184
 
174
185
  // ── jsonrender.stream ─────────────────────────────────────────
@@ -194,6 +205,7 @@ export interface StreamJsonRenderResult {
194
205
  specVersion: number;
195
206
  elementCount: number;
196
207
  streamStatus: 'open' | 'closed';
208
+ sizeAdjustment?: CanvasSizeAdjustment;
197
209
  }
198
210
 
199
211
  /**
@@ -206,6 +218,7 @@ export function streamJsonRenderCore(input: StreamJsonRenderInput): StreamJsonRe
206
218
  const done = input.done === true;
207
219
  let nodeId = typeof input.nodeId === 'string' && input.nodeId ? input.nodeId : undefined;
208
220
  let url = '';
221
+ let sizeAdjustment: CanvasSizeAdjustment | undefined;
209
222
  if (!nodeId) {
210
223
  const created = createCanvasStreamingJsonRenderNode({
211
224
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
@@ -217,11 +230,17 @@ export function streamJsonRenderCore(input: StreamJsonRenderInput): StreamJsonRe
217
230
  });
218
231
  nodeId = created.id;
219
232
  url = created.url;
233
+ sizeAdjustment = created.sizeAdjustment;
220
234
  }
221
235
  const result = appendCanvasJsonRenderStream(nodeId, patches, done);
222
236
  if (!result.ok) throw new OperationError(result.error);
223
237
  const node = canvasState.getNode(nodeId);
224
- return { id: nodeId, url: url || String(node?.data.url ?? ''), ...result };
238
+ return {
239
+ id: nodeId,
240
+ url: url || String(node?.data.url ?? ''),
241
+ ...result,
242
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
243
+ };
225
244
  }
226
245
 
227
246
  const jsonRenderStreamShape = {
@@ -303,6 +322,7 @@ const jsonRenderStreamOperation = defineOperation<z.infer<typeof jsonRenderStrea
303
322
  specVersion: body.specVersion,
304
323
  elementCount: body.elementCount,
305
324
  streamStatus: body.streamStatus,
325
+ ...(body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {}),
306
326
  };
307
327
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
308
328
  },
@@ -558,7 +578,7 @@ const graphAddOperation = defineOperation<z.infer<typeof graphAddSchema>, Return
558
578
  ? body.highlight
559
579
  : undefined;
560
580
  const sort = body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
561
- return createCanvasGraphNode({
581
+ const created = createCanvasGraphNode({
562
582
  title,
563
583
  graphType,
564
584
  data,
@@ -600,12 +620,24 @@ const graphAddOperation = defineOperation<z.infer<typeof graphAddSchema>, Return
600
620
  ...(width !== undefined ? { width } : {}),
601
621
  ...(nodeHeight !== undefined ? { heightPx: nodeHeight } : {}),
602
622
  });
623
+ return {
624
+ ...created,
625
+ ...(typeof body.height === 'number' && nodeHeight === undefined
626
+ ? { warning: '`height` controls graph plot height; use `nodeHeight` to set the node frame height.' }
627
+ : {}),
628
+ };
603
629
  } catch (error) {
604
630
  if (error instanceof OperationError) throw error;
605
631
  throw new OperationError(error instanceof Error ? error.message : String(error));
606
632
  }
607
633
  },
608
- serialize: (result) => ({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec }),
634
+ serialize: (result) => ({
635
+ ...buildNodeResponse(result.node),
636
+ url: result.url,
637
+ spec: result.spec,
638
+ ...(result.sizeAdjustment ? { sizeAdjustment: result.sizeAdjustment } : {}),
639
+ ...('warning' in result ? { warning: result.warning } : {}),
640
+ }),
609
641
  });
610
642
 
611
643
  // ── schema.describe ───────────────────────────────────────────
@@ -27,6 +27,7 @@ import {
27
27
  MCP_APP_NODE_DEFAULT_SIZE,
28
28
  IMAGE_NODE_DEFAULT_SIZE,
29
29
  LEDGER_NODE_DEFAULT_SIZE,
30
+ type CanvasSizeAdjustment,
30
31
  } from '../../canvas-operations.js';
31
32
  import { normalizeNodeAxCapabilities } from '../../ax-interaction.js';
32
33
  import { applyFileContentToNodeData, readFileNodeContent } from '../../file-content.js';
@@ -377,12 +378,13 @@ export function agentSafeFullLayoutPayload(layout: CanvasLayout): Record<string,
377
378
  export function createdNodePayloadFromNode(
378
379
  node: CanvasNodeState,
379
380
  options: Record<string, unknown> = {},
381
+ extras: Record<string, unknown> = {},
380
382
  ): Record<string, unknown> {
381
383
  if (!wantsFullPayload(options)) {
382
- return { ok: true, node: compactNodePayload(node), id: node.id, nodeId: node.id };
384
+ return { ok: true, node: compactNodePayload(node), id: node.id, nodeId: node.id, ...extras };
383
385
  }
384
386
  const serialized = serializeCanvasNodeForAgent(node);
385
- return { ok: true, node: serialized, ...serialized, nodeId: node.id };
387
+ return { ok: true, node: serialized, ...serialized, nodeId: node.id, ...extras };
386
388
  }
387
389
 
388
390
  // ── Operation cores (the SDK wraps these directly) ────────────
@@ -395,7 +397,7 @@ export function createdNodePayloadFromNode(
395
397
  export function createBasicCanvasNode(
396
398
  body: Record<string, unknown>,
397
399
  options: { fileMode: 'auto' | 'path' },
398
- ): { node: CanvasNodeState; needsCodeGraphRecompute: boolean } {
400
+ ): { node: CanvasNodeState; needsCodeGraphRecompute: boolean; sizeAdjustment?: CanvasSizeAdjustment } {
399
401
  const type = typeof body.type === 'string' ? body.type : '';
400
402
  const extraData = isRecord(body.data) ? body.data : undefined;
401
403
  if (type === 'html') {
@@ -455,7 +457,7 @@ export function createBasicCanvasNode(
455
457
  const geometry = resolveCreateGeometry(body);
456
458
  const defaults = defaultNodeSize(type);
457
459
  try {
458
- const { node, needsCodeGraphRecompute } = addCanvasNode({
460
+ const { node, needsCodeGraphRecompute, sizeAdjustment } = addCanvasNode({
459
461
  type: type as CanvasNodeState['type'],
460
462
  ...(typeof body.title === 'string'
461
463
  ? { title: body.title }
@@ -481,7 +483,7 @@ export function createBasicCanvasNode(
481
483
  defaultHeight: defaults.height,
482
484
  fileMode: options.fileMode,
483
485
  });
484
- return { node, needsCodeGraphRecompute };
486
+ return { node, needsCodeGraphRecompute, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
485
487
  } catch (error) {
486
488
  if (error instanceof OperationError) throw error;
487
489
  throw new OperationError(error instanceof Error ? error.message : String(error));
@@ -641,15 +643,15 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
641
643
 
642
644
  const extraData = isRecord(body.data) ? body.data : undefined;
643
645
  const geometry = resolveCreateGeometry(body);
644
- const { id, node } = addCanvasNode({
646
+ const { id, node, sizeAdjustment } = addCanvasNode({
645
647
  type: 'webpage',
646
648
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
647
649
  content: normalizedUrl,
648
650
  ...(extraData ? { data: extraData } : {}),
649
651
  ...(body.strictSize === true ? { strictSize: true } : {}),
650
652
  ...geometry,
651
- ...(geometry.width === undefined ? { width: WEBPAGE_NODE_DEFAULT_SIZE.width } : {}),
652
- ...(geometry.height === undefined ? { height: WEBPAGE_NODE_DEFAULT_SIZE.height } : {}),
653
+ defaultWidth: WEBPAGE_NODE_DEFAULT_SIZE.width,
654
+ defaultHeight: WEBPAGE_NODE_DEFAULT_SIZE.height,
653
655
  });
654
656
 
655
657
  // The node should appear before the (slow) page fetch completes; the
@@ -662,6 +664,7 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
662
664
  extras: {
663
665
  fetch: refreshed.ok ? { ok: true } : { ok: false, error: refreshed.error ?? 'Failed to fetch webpage content.' },
664
666
  ...(refreshed.ok ? {} : { error: refreshed.error }),
667
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
665
668
  },
666
669
  };
667
670
  }
@@ -691,7 +694,7 @@ function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
691
694
  const axCapabilities =
692
695
  normalizeNodeAxCapabilities(body.axCapabilities) ??
693
696
  (declared ? { enabled: declared.enabled, allowed: [...declared.allowed] } : undefined);
694
- const { node } = addCanvasNode({
697
+ const { node, sizeAdjustment } = addCanvasNode({
695
698
  type: 'html',
696
699
  title: built.title,
697
700
  data: {
@@ -718,6 +721,7 @@ function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
718
721
  htmlBytes: Buffer.byteLength(built.html, 'utf-8'),
719
722
  defaultSize: built.defaultSize,
720
723
  },
724
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
721
725
  },
722
726
  };
723
727
  }
@@ -733,14 +737,14 @@ function createGroupNode(body: Record<string, unknown>): NodeAddResult {
733
737
  body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
734
738
  ? body.childLayout
735
739
  : undefined;
736
- const { node } = createCanvasGroup({
740
+ const { node, sizeAdjustment } = createCanvasGroup({
737
741
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
738
742
  childIds,
739
743
  ...(typeof body.color === 'string' ? { color: body.color } : {}),
740
744
  ...(childLayout ? { childLayout } : {}),
741
745
  ...geometry,
742
746
  });
743
- return { node };
747
+ return { node, ...(sizeAdjustment ? { extras: { sizeAdjustment } } : {}) };
744
748
  }
745
749
 
746
750
  const nodeAddShape = {
@@ -910,7 +914,8 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
910
914
  };
911
915
  }
912
916
  const node = body.node as CanvasNodeState | undefined;
913
- const payload = node ? createdNodePayloadFromNode(node, input) : { ok: true };
917
+ const extras = body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {};
918
+ const payload = node ? createdNodePayloadFromNode(node, input, extras) : { ok: true };
914
919
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
915
920
  },
916
921
  },
@@ -954,13 +959,13 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
954
959
  if (type === 'group') {
955
960
  return createGroupNode(body);
956
961
  }
957
- const { node, needsCodeGraphRecompute } = createBasicCanvasNode(body, { fileMode: 'auto' });
962
+ const { node, needsCodeGraphRecompute, sizeAdjustment } = createBasicCanvasNode(body, { fileMode: 'auto' });
958
963
  if (needsCodeGraphRecompute) {
959
964
  scheduleCodeGraphRecompute(() => {
960
965
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
961
966
  });
962
967
  }
963
- return { node };
968
+ return { node, ...(sizeAdjustment ? { extras: { sizeAdjustment } } : {}) };
964
969
  },
965
970
  serialize: ({ node, extras }) => ({ ...buildNodeResponse(node), ...(extras ?? {}) }),
966
971
  });
@@ -1129,7 +1134,10 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1129
1134
  const body = isRecord(result) ? result : {};
1130
1135
  const node = body.node as CanvasNodeState | undefined;
1131
1136
  if (node) {
1132
- const payload = createdNodePayloadFromNode(node, input);
1137
+ const payload = {
1138
+ ...createdNodePayloadFromNode(node, input),
1139
+ ...(body.sizeAdjustment ? { sizeAdjustment: body.sizeAdjustment } : {}),
1140
+ };
1133
1141
  return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
1134
1142
  }
1135
1143
  // No `node` field: a webpage-refresh result ({ ok, id, fetch?, error? }) or
@@ -1224,7 +1232,25 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1224
1232
  }
1225
1233
  }
1226
1234
  const updated = canvasState.getNode(id);
1227
- return updated ? buildNodeResponse(updated) : { ok: true, id };
1235
+ const fitted =
1236
+ updated &&
1237
+ existing.type === 'group' &&
1238
+ (updated.size.width !== (patch.size?.width ?? existing.size.width) ||
1239
+ updated.size.height !== (patch.size?.height ?? existing.size.height));
1240
+ return updated
1241
+ ? {
1242
+ ...buildNodeResponse(updated),
1243
+ ...(fitted
1244
+ ? {
1245
+ sizeAdjustment: {
1246
+ requested: { width: patch.size?.width ?? null, height: patch.size?.height ?? null },
1247
+ applied: updated.size,
1248
+ reason: 'fit-to-children',
1249
+ },
1250
+ }
1251
+ : {}),
1252
+ }
1253
+ : { ok: true, id };
1228
1254
  },
1229
1255
  });
1230
1256
 
@@ -13,6 +13,7 @@
13
13
  * This module must never import server.ts or index.ts.
14
14
  */
15
15
  import { z } from 'zod';
16
+ import { tourSchema, derivedTour } from '../../../shared/tour.js';
16
17
  import { canvasState } from '../../canvas-state.js';
17
18
  import { arrangeCanvasNodes, clearCanvas, fitCanvasView, setClientViewportSize } from '../../canvas-operations.js';
18
19
  import { validateCanvasLayout } from '../../canvas-validation.js';
@@ -272,7 +273,38 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
272
273
  },
273
274
  });
274
275
 
276
+ const tourSetShape = { tour: tourSchema.nullable().describe('Ordered camera stops, or null to derive from groups') };
277
+ const tourSetOperation = defineOperation({
278
+ name: 'tour.set',
279
+ mutates: true,
280
+ input: z.object(tourSetShape),
281
+ inputShape: tourSetShape,
282
+ http: { method: 'POST', path: '/api/canvas/tour' },
283
+ mcp: { toolName: 'canvas_set_tour', description: 'Persist an ordered board presentation tour.' },
284
+ handler: (input) => {
285
+ canvasState.setTour(input.tour);
286
+ return { ok: true };
287
+ },
288
+ });
289
+ const tourGetOperation = defineOperation({
290
+ name: 'tour.get',
291
+ mutates: false,
292
+ input: z.object({}),
293
+ inputShape: {},
294
+ http: { method: 'GET', path: '/api/canvas/tour' },
295
+ mcp: {
296
+ toolName: 'canvas_get_tour',
297
+ description: 'Read the board tour, deriving groups in reading order when absent.',
298
+ },
299
+ handler: () => ({
300
+ tour: canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes),
301
+ derived: !canvasState.getTour(),
302
+ }),
303
+ });
304
+
275
305
  export const viewportOperations: Operation[] = [
306
+ tourSetOperation,
307
+ tourGetOperation,
276
308
  arrangeOperation,
277
309
  focusOperation,
278
310
  fitOperation,
@@ -296,6 +296,12 @@ const PRESENCE_EXEMPT_OPS = new Set([
296
296
  'ax.activity.ingest',
297
297
  'human.presence.set',
298
298
  'human.presence.get',
299
+ // Camera and attention controls are observation/navigation, not edits.
300
+ // They must not create a writer, bump opCount, or refresh an existing one.
301
+ 'viewport.set',
302
+ 'view.fit',
303
+ 'node.focus',
304
+ 'ax.focus.set',
299
305
  ]);
300
306
 
301
307
  /** Layout mutations AND non-GET AX writes (work items, gates, evidence, steering) are agent activity. */
@@ -111,7 +111,7 @@ function createTarget(input: Record<string, unknown>, extraNodeIds: string[] = [
111
111
  }
112
112
 
113
113
  /** Ops that rewrite the whole board — never allowed under a fence. */
114
- const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard']);
114
+ const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard', 'tour.set']);
115
115
 
116
116
  /** Describe what a registry op would write, in fence terms. */
117
117
  export function describeOpTarget(op: Operation, rawInput: unknown): FenceTarget {
@@ -143,7 +143,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
143
143
  let snapshot = startSnapshotId
144
144
  ? (canvasState.listSnapshots({ all: true }).find((entry) => entry.id === startSnapshotId) ?? null)
145
145
  : null;
146
- let unchanged = false;
146
+ let unchanged = !startSnapshotId && canvasState.getLayout().nodes.length === 0;
147
147
  // A session that changed NOTHING on the board leaves no snapshot behind —
148
148
  // session churn (idle timeouts, receipt tests, attach/detach cycles) was
149
149
  // flooding the History drawer with identical boards.
@@ -184,6 +184,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
184
184
  // workbench session and would overwrite it. The label is what the receipt shows.
185
185
  emitPrimaryWorkbenchEvent('agent-session-ended', {
186
186
  label: presence.label,
187
+ parentAgentId: presence.parentAgentId ?? null,
187
188
  endedAt,
188
189
  endedBy,
189
190
  counts,
@@ -1431,15 +1432,17 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1431
1432
  // escaped diagram source, rendered by /canvas/mermaid-entry.js.
1432
1433
  const inlineAssets = url.searchParams.get('inline-assets') === '1';
1433
1434
  const doc = buildHtmlSurfaceDocument(
1434
- buildMermaidSurfaceHtml(source, inlineAssets ? mermaidEntryJsInline() : null),
1435
+ buildMermaidSurfaceHtml(
1436
+ source,
1437
+ inlineAssets ? mermaidEntryJsInline() : null,
1438
+ node.data.fit === 'none' ? 'none' : 'contain',
1439
+ url.searchParams.get('frameToken') ?? '',
1440
+ ),
1435
1441
  {
1436
1442
  theme,
1437
1443
  title: surfaceTitle,
1438
1444
  themeToken: url.searchParams.get('themeToken') ?? undefined,
1439
- // Content-height reporter nonce (lets the node grow to fit the diagram).
1440
- ...(url.searchParams.get('frameToken')
1441
- ? { contentHeightToken: url.searchParams.get('frameToken') as string }
1442
- : {}),
1445
+ // Mermaid reports its natural height itself, before contain scaling.
1443
1446
  // Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
1444
1447
  // are styled without depending on the <link> subresource.
1445
1448
  ...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
@@ -13,7 +13,7 @@ import {
13
13
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
14
  import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
15
15
  import { canvasState, type CanvasNodeState } from './canvas-state.js';
16
- import { emitCanvasLayoutUpdate } from './canvas-operations.js';
16
+ import { emitCanvasLayoutUpdate, type CanvasSizeAdjustment } from './canvas-operations.js';
17
17
  import { findOpenCanvasPosition } from './placement.js';
18
18
 
19
19
  const BUNDLED_WEB_ARTIFACT_SCRIPTS_DIR = join(import.meta.dir, 'web-artifacts', 'scripts');
@@ -95,12 +95,14 @@ export interface WebArtifactSourceContext {
95
95
  export interface WebArtifactCanvasOpenResult {
96
96
  nodeId: string;
97
97
  url: string;
98
+ sizeAdjustment: CanvasSizeAdjustment;
98
99
  }
99
100
 
100
101
  export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
101
102
  openedInCanvas: boolean;
102
103
  nodeId?: string;
103
104
  url?: string;
105
+ sizeAdjustment?: CanvasSizeAdjustment;
104
106
  startedAt: string;
105
107
  completedAt: string;
106
108
  durationMs: number;
@@ -740,7 +742,11 @@ export function openWebArtifactInCanvas(input: {
740
742
  // This keeps web-artifacts.ts server-independent so the registry op
741
743
  // (operations/ops/app.ts) can import it without pulling in server.ts.
742
744
  emitCanvasLayoutUpdate();
743
- return { nodeId: id, url };
745
+ return {
746
+ nodeId: id,
747
+ url,
748
+ sizeAdjustment: { requested: { width: null, height: null }, applied: node.size, reason: 'defaulted' },
749
+ };
744
750
  }
745
751
 
746
752
  export async function buildWebArtifactOnCanvas(
@@ -778,6 +784,7 @@ export async function buildWebArtifactOnCanvas(
778
784
  openedInCanvas: true,
779
785
  nodeId: opened.nodeId,
780
786
  url: opened.url,
787
+ sizeAdjustment: opened.sizeAdjustment,
781
788
  ...timing,
782
789
  };
783
790
  }
@@ -82,14 +82,14 @@ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
82
82
  }
83
83
  elements[columnId] = {
84
84
  type: 'Stack',
85
- props: { direction: 'vertical', gap: 'sm' },
85
+ props: { direction: 'vertical', gap: 'sm', align: 'stretch' },
86
86
  children: columnChildren,
87
87
  };
88
88
  columnIds.push(columnId);
89
89
  }
90
90
  elements.board = {
91
- type: 'Stack',
92
- props: { direction: 'horizontal', gap: 'md', align: 'start' },
91
+ type: 'Grid',
92
+ props: { columns: columnIds.length, gap: 'md' },
93
93
  children: columnIds,
94
94
  };
95
95
  return { root: 'board', elements };