pmx-canvas 0.3.0 → 0.3.2

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 (161) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/canvas/index.js +65 -65
  3. package/dist/types/cli/agent.d.ts +9 -1
  4. package/dist/types/cli/daemon.d.ts +74 -0
  5. package/dist/types/cli/watch.d.ts +2 -2
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
  8. package/dist/types/client/canvas/Minimap.d.ts +1 -1
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +9 -1
  10. package/dist/types/client/nodes/FileNode.d.ts +1 -1
  11. package/dist/types/client/nodes/ImageNode.d.ts +1 -1
  12. package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
  13. package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
  14. package/dist/types/client/nodes/PromptNode.d.ts +1 -1
  15. package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
  16. package/dist/types/server/canvas-schema.d.ts +1 -1
  17. package/dist/types/server/html-primitives.d.ts +1 -1
  18. package/dist/types/server/index.d.ts +4 -4
  19. package/dist/types/server/operations/index.d.ts +1 -1
  20. package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
  21. package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
  22. package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
  23. package/dist/types/server/server.d.ts +8 -0
  24. package/docs/cli.md +10 -1
  25. package/docs/http-api.md +28 -0
  26. package/docs/plans/plan-009-tech-debt-backlog.md +5 -5
  27. package/docs/screenshot.png +0 -0
  28. package/docs/tech-debt-assessment-2026-07.md +2 -2
  29. package/package.json +5 -2
  30. package/skills/pmx-canvas/SKILL.md +15 -10
  31. package/skills/pmx-canvas/references/full-reference.md +17 -3
  32. package/src/cli/agent.ts +1951 -1571
  33. package/src/cli/daemon.ts +460 -0
  34. package/src/cli/index.ts +80 -323
  35. package/src/cli/watch.ts +2 -10
  36. package/src/client/App.tsx +48 -46
  37. package/src/client/canvas/AttentionHistory.tsx +11 -1
  38. package/src/client/canvas/CanvasNode.tsx +41 -29
  39. package/src/client/canvas/CanvasViewport.tsx +101 -66
  40. package/src/client/canvas/CommandPalette.tsx +61 -27
  41. package/src/client/canvas/ContextMenu.tsx +13 -20
  42. package/src/client/canvas/ContextPinBar.tsx +1 -5
  43. package/src/client/canvas/ContextPinHud.tsx +1 -6
  44. package/src/client/canvas/DockedNode.tsx +4 -4
  45. package/src/client/canvas/EdgeLayer.tsx +37 -36
  46. package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
  47. package/src/client/canvas/FocusFieldLayer.tsx +20 -22
  48. package/src/client/canvas/IntentLayer.tsx +31 -14
  49. package/src/client/canvas/Minimap.tsx +11 -16
  50. package/src/client/canvas/SelectionBar.tsx +4 -11
  51. package/src/client/canvas/ShortcutOverlay.tsx +3 -1
  52. package/src/client/canvas/SnapshotPanel.tsx +77 -95
  53. package/src/client/canvas/auto-fit.ts +15 -14
  54. package/src/client/canvas/snap-guides.ts +12 -12
  55. package/src/client/canvas/use-node-resize.ts +1 -5
  56. package/src/client/canvas/use-pan-zoom.ts +25 -26
  57. package/src/client/ext-app/bridge.ts +3 -12
  58. package/src/client/icons.tsx +63 -20
  59. package/src/client/nodes/ContextNode.tsx +14 -25
  60. package/src/client/nodes/ExtAppFrame.tsx +194 -80
  61. package/src/client/nodes/FileNode.tsx +74 -62
  62. package/src/client/nodes/GroupNode.tsx +4 -6
  63. package/src/client/nodes/HtmlNode.tsx +76 -46
  64. package/src/client/nodes/ImageNode.tsx +18 -27
  65. package/src/client/nodes/InlineFormatBar.tsx +4 -21
  66. package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
  67. package/src/client/nodes/LedgerNode.tsx +10 -4
  68. package/src/client/nodes/MarkdownNode.tsx +3 -10
  69. package/src/client/nodes/McpAppNode.tsx +26 -22
  70. package/src/client/nodes/MdFormatBar.tsx +10 -7
  71. package/src/client/nodes/PromptNode.tsx +23 -51
  72. package/src/client/nodes/ResponseNode.tsx +3 -13
  73. package/src/client/nodes/StatusNode.tsx +5 -9
  74. package/src/client/nodes/StatusSummary.tsx +2 -8
  75. package/src/client/nodes/WebpageNode.tsx +20 -14
  76. package/src/client/nodes/iframe-document-url.ts +25 -16
  77. package/src/client/nodes/image-warnings.ts +1 -7
  78. package/src/client/nodes/md-format.ts +20 -5
  79. package/src/client/state/attention-bridge.ts +4 -9
  80. package/src/client/state/attention-store.ts +1 -7
  81. package/src/client/state/canvas-store.ts +52 -36
  82. package/src/client/state/intent-bridge.ts +176 -112
  83. package/src/client/state/intent-store.ts +4 -1
  84. package/src/client/state/sse-bridge.ts +53 -70
  85. package/src/json-render/catalog.ts +12 -16
  86. package/src/json-render/charts/components.tsx +16 -20
  87. package/src/json-render/charts/extra-components.tsx +8 -16
  88. package/src/json-render/charts/extra-definitions.ts +1 -2
  89. package/src/json-render/charts/tufte-components.tsx +37 -20
  90. package/src/json-render/charts/tufte-definitions.ts +8 -2
  91. package/src/json-render/renderer/index.tsx +42 -22
  92. package/src/json-render/schema.ts +6 -3
  93. package/src/json-render/server.ts +33 -39
  94. package/src/mcp/canvas-access.ts +35 -21
  95. package/src/mcp/server.ts +132 -70
  96. package/src/server/agent-context.ts +63 -36
  97. package/src/server/ax-context.ts +7 -5
  98. package/src/server/ax-interaction.ts +176 -43
  99. package/src/server/ax-state-manager.ts +182 -39
  100. package/src/server/ax-state.ts +142 -47
  101. package/src/server/canvas-db.ts +213 -95
  102. package/src/server/canvas-operations.ts +180 -123
  103. package/src/server/canvas-provenance.ts +1 -4
  104. package/src/server/canvas-schema.ts +454 -73
  105. package/src/server/canvas-serialization.ts +27 -35
  106. package/src/server/canvas-state.ts +150 -58
  107. package/src/server/chart-template.ts +4 -5
  108. package/src/server/code-graph.ts +19 -6
  109. package/src/server/diagram-presets.ts +28 -29
  110. package/src/server/ext-app-lookup.ts +3 -12
  111. package/src/server/html-node-summary.ts +19 -10
  112. package/src/server/html-primitives.ts +326 -97
  113. package/src/server/html-surface.ts +6 -9
  114. package/src/server/image-source.ts +6 -4
  115. package/src/server/index.ts +320 -217
  116. package/src/server/intent-registry.ts +2 -5
  117. package/src/server/mcp-app-candidate.ts +5 -10
  118. package/src/server/mcp-app-host.ts +14 -38
  119. package/src/server/mcp-app-runtime.ts +12 -20
  120. package/src/server/mutation-history.ts +15 -5
  121. package/src/server/operations/composites.ts +1 -3
  122. package/src/server/operations/http.ts +2 -3
  123. package/src/server/operations/index.ts +7 -1
  124. package/src/server/operations/invoker.ts +4 -3
  125. package/src/server/operations/mcp.ts +22 -30
  126. package/src/server/operations/ops/annotation.ts +122 -10
  127. package/src/server/operations/ops/app.ts +98 -73
  128. package/src/server/operations/ops/ax-await.ts +17 -10
  129. package/src/server/operations/ops/ax-read.ts +347 -0
  130. package/src/server/operations/ops/ax-shared.ts +2 -7
  131. package/src/server/operations/ops/ax-state.ts +32 -14
  132. package/src/server/operations/ops/ax-timeline.ts +32 -19
  133. package/src/server/operations/ops/ax-work.ts +54 -37
  134. package/src/server/operations/ops/batch.ts +41 -15
  135. package/src/server/operations/ops/canvas-wire.ts +91 -0
  136. package/src/server/operations/ops/edges.ts +37 -25
  137. package/src/server/operations/ops/ext-app.ts +346 -0
  138. package/src/server/operations/ops/groups.ts +49 -20
  139. package/src/server/operations/ops/intent.ts +18 -12
  140. package/src/server/operations/ops/json-render.ts +239 -98
  141. package/src/server/operations/ops/nodes.ts +298 -109
  142. package/src/server/operations/ops/query.ts +46 -28
  143. package/src/server/operations/ops/snapshots.ts +35 -26
  144. package/src/server/operations/ops/validate.ts +2 -1
  145. package/src/server/operations/ops/viewport.ts +60 -16
  146. package/src/server/operations/ops/webview.ts +44 -18
  147. package/src/server/operations/registry.ts +2 -3
  148. package/src/server/operations/types.ts +7 -5
  149. package/src/server/operations/webview-runner.ts +1 -3
  150. package/src/server/placement.ts +8 -18
  151. package/src/server/server.ts +122 -1028
  152. package/src/server/spatial-analysis.ts +39 -25
  153. package/src/server/trace-manager.ts +3 -8
  154. package/src/server/web-artifacts.ts +23 -27
  155. package/src/server/webpage-node.ts +5 -13
  156. package/src/shared/auto-arrange.ts +12 -5
  157. package/src/shared/content-height-reporter.ts +8 -6
  158. package/src/shared/ext-app-tool-result.ts +2 -6
  159. package/src/shared/placement.ts +1 -4
  160. package/src/shared/semantic-attention.ts +39 -37
  161. package/src/shared/surface.ts +8 -4
@@ -124,7 +124,7 @@ export class IntentRegistry {
124
124
 
125
125
  const now = Date.now();
126
126
  const ttl = typeof input.ttlMs === 'number' ? input.ttlMs : DEFAULT_INTENT_TTL_MS;
127
- const id = input.id && this.intents.has(input.id) ? input.id : input.id ?? nextIntentId();
127
+ const id = input.id && this.intents.has(input.id) ? input.id : (input.id ?? nextIntentId());
128
128
  this.pruneVetoTombstones();
129
129
  if (this.vetoedIntentIds.has(id)) {
130
130
  throw new OperationError(`Intent "${id}" was vetoed. Use a new id for a revised intent.`, 409);
@@ -234,10 +234,7 @@ export class IntentRegistry {
234
234
  throw new OperationError(`No live intent "${id}" to commit.`, 409);
235
235
  }
236
236
  if (!allowedKinds.includes(intent.kind)) {
237
- throw new OperationError(
238
- `Intent "${id}" has kind "${intent.kind}", which cannot commit this mutation.`,
239
- 409,
240
- );
237
+ throw new OperationError(`Intent "${id}" has kind "${intent.kind}", which cannot commit this mutation.`, 409);
241
238
  }
242
239
  this.committingIntentIds.add(id);
243
240
  return intent;
@@ -134,16 +134,12 @@ function toSafeExternalUrl(url: string): string | null {
134
134
  return parsed.toString();
135
135
  }
136
136
 
137
- export function getMcpAppCandidateFromToolCompletion(
138
- data: McpAppToolCompletionInput,
139
- ): McpAppToolCandidate | null {
137
+ export function getMcpAppCandidateFromToolCompletion(data: McpAppToolCompletionInput): McpAppToolCandidate | null {
140
138
  const inlineUrls = [
141
139
  ...(typeof data.content === 'string' ? [data.content] : []),
142
140
  ...(typeof data.detailedContent === 'string' ? [data.detailedContent] : []),
143
141
  ...collectToolResultTextFragments(data.result),
144
- ].flatMap((value) =>
145
- (value.match(/https?:\/\/[^\s<>"'`]+/gi) ?? []).map((url) => ({ url, keyHint: 'inline' })),
146
- );
142
+ ].flatMap((value) => (value.match(/https?:\/\/[^\s<>"'`]+/gi) ?? []).map((url) => ({ url, keyHint: 'inline' })));
147
143
 
148
144
  const nestedUrls = collectToolResultUrlCandidates(data.result);
149
145
  const candidates = [...inlineUrls, ...nestedUrls];
@@ -156,10 +152,9 @@ export function getMcpAppCandidateFromToolCompletion(
156
152
  const safe = toSafeExternalUrl(entry.url);
157
153
  if (!safe) continue;
158
154
  const key = entry.keyHint.toLowerCase();
159
- const hintedByKey =
160
- /resource|resource_link|resourceurl|resource_url|app|uri|url|link|viewer|preview|canvas/.test(
161
- key,
162
- );
155
+ const hintedByKey = /resource|resource_link|resourceurl|resource_url|app|uri|url|link|viewer|preview|canvas/.test(
156
+ key,
157
+ );
163
158
  const hintedByUrl = isLikelyMcpAppWebUrl(safe);
164
159
  if (!hintedByKey && !hintedByUrl && !sourceHasMcp) continue;
165
160
  return {
@@ -66,11 +66,7 @@ function ensureConfigDir(): void {
66
66
  }
67
67
  }
68
68
 
69
- const DEFAULT_MCP_APP_HOST_STATE_FILE = join(
70
- PMX_CANVAS_CONFIG_DIR,
71
- 'workbench',
72
- 'mcp-app-host-state.json',
73
- );
69
+ const DEFAULT_MCP_APP_HOST_STATE_FILE = join(PMX_CANVAS_CONFIG_DIR, 'workbench', 'mcp-app-host-state.json');
74
70
 
75
71
  function sessionDiagLogPath(): string {
76
72
  return String(process.env.PMX_SESSION_LOG || process.env.PMX_TEST_LOG || '').trim();
@@ -158,9 +154,7 @@ function resetRuntimeMetrics(): void {
158
154
  function clearPersistedRuntimeSessionsOnLoad(): void {
159
155
  const hadSessions = sessions.size;
160
156
  const hadMetrics =
161
- metrics.hostedOpens > 0 ||
162
- metrics.fallbackTotal > 0 ||
163
- Object.keys(metrics.fallbackByReason).length > 0;
157
+ metrics.hostedOpens > 0 || metrics.fallbackTotal > 0 || Object.keys(metrics.fallbackByReason).length > 0;
164
158
  if (hadSessions === 0 && !activeSessionId && !hadMetrics) return;
165
159
 
166
160
  sessions.clear();
@@ -192,9 +186,7 @@ function ensureStateLoaded(): void {
192
186
  const normalized: McpAppHostCapability = {
193
187
  serverName: entry.serverName.trim(),
194
188
  state:
195
- entry.state === 'supported' ||
196
- entry.state === 'unsupported' ||
197
- entry.state === 'degraded'
189
+ entry.state === 'supported' || entry.state === 'unsupported' || entry.state === 'degraded'
198
190
  ? entry.state
199
191
  : 'degraded',
200
192
  reasonCode:
@@ -204,9 +196,7 @@ function ensureStateLoaded(): void {
204
196
  runtimeReady: entry.runtimeReady === true,
205
197
  serverSupportsHost: entry.serverSupportsHost === true,
206
198
  updatedAt:
207
- typeof entry.updatedAt === 'string' && entry.updatedAt.trim().length > 0
208
- ? entry.updatedAt
209
- : nowIso(),
199
+ typeof entry.updatedAt === 'string' && entry.updatedAt.trim().length > 0 ? entry.updatedAt : nowIso(),
210
200
  };
211
201
  capabilities.set(normalized.serverName, normalized);
212
202
  }
@@ -235,20 +225,15 @@ function ensureStateLoaded(): void {
235
225
  ? entry.state
236
226
  : 'background',
237
227
  createdAt:
238
- typeof entry.createdAt === 'string' && entry.createdAt.trim().length > 0
239
- ? entry.createdAt
240
- : nowIso(),
228
+ typeof entry.createdAt === 'string' && entry.createdAt.trim().length > 0 ? entry.createdAt : nowIso(),
241
229
  lastSeenAt:
242
- typeof entry.lastSeenAt === 'string' && entry.lastSeenAt.trim().length > 0
243
- ? entry.lastSeenAt
244
- : nowIso(),
230
+ typeof entry.lastSeenAt === 'string' && entry.lastSeenAt.trim().length > 0 ? entry.lastSeenAt : nowIso(),
245
231
  fallbackReason:
246
232
  typeof entry.fallbackReason === 'string' && entry.fallbackReason.trim().length > 0
247
233
  ? entry.fallbackReason
248
234
  : null,
249
235
  lastExternalOpenAt:
250
- typeof entry.lastExternalOpenAt === 'string' &&
251
- entry.lastExternalOpenAt.trim().length > 0
236
+ typeof entry.lastExternalOpenAt === 'string' && entry.lastExternalOpenAt.trim().length > 0
252
237
  ? entry.lastExternalOpenAt
253
238
  : null,
254
239
  };
@@ -268,12 +253,8 @@ function ensureStateLoaded(): void {
268
253
  if (parsed.metrics && typeof parsed.metrics === 'object') {
269
254
  const loadedHostedOpens = Number(parsed.metrics.hostedOpens ?? 0);
270
255
  const loadedFallbackTotal = Number(parsed.metrics.fallbackTotal ?? 0);
271
- metrics.hostedOpens = Number.isFinite(loadedHostedOpens)
272
- ? Math.max(0, Math.floor(loadedHostedOpens))
273
- : 0;
274
- metrics.fallbackTotal = Number.isFinite(loadedFallbackTotal)
275
- ? Math.max(0, Math.floor(loadedFallbackTotal))
276
- : 0;
256
+ metrics.hostedOpens = Number.isFinite(loadedHostedOpens) ? Math.max(0, Math.floor(loadedHostedOpens)) : 0;
257
+ metrics.fallbackTotal = Number.isFinite(loadedFallbackTotal) ? Math.max(0, Math.floor(loadedFallbackTotal)) : 0;
277
258
  if (parsed.metrics.fallbackByReason && typeof parsed.metrics.fallbackByReason === 'object') {
278
259
  for (const [reason, count] of Object.entries(parsed.metrics.fallbackByReason)) {
279
260
  const normalizedReason = reason.trim();
@@ -370,10 +351,9 @@ function supportsHostSessionByHint(input: McpAppCandidateInput): boolean {
370
351
  const keyHint = String(input.keyHint || '').toLowerCase();
371
352
  const inferredType = String(input.inferredType || '').toLowerCase();
372
353
 
373
- const keySuggestsApps =
374
- /resource|resource_link|resourceurl|resource_url|app|uri|url|link|viewer|preview|canvas/.test(
375
- keyHint,
376
- );
354
+ const keySuggestsApps = /resource|resource_link|resourceurl|resource_url|app|uri|url|link|viewer|preview|canvas/.test(
355
+ keyHint,
356
+ );
377
357
  const sourceSuggestsApps =
378
358
  sourceServer.includes('mcp') ||
379
359
  sourceServer.includes('excalidraw') ||
@@ -533,10 +513,7 @@ export function preRegisterKnownMcpAppHostCapabilities(serverNames: string[]): v
533
513
  }
534
514
  }
535
515
 
536
- function resolveCapabilityForCandidate(
537
- input: McpAppCandidateInput,
538
- trustedDomain: boolean,
539
- ): McpAppHostCapability {
516
+ function resolveCapabilityForCandidate(input: McpAppCandidateInput, trustedDomain: boolean): McpAppHostCapability {
540
517
  const runtimeReady = isHostRuntimeEnabled();
541
518
  const serverName = normalizeServerName(input.sourceServer);
542
519
  const serverSupportsHost = supportsHostSessionByHint(input);
@@ -645,8 +622,7 @@ function findMatchingOpenSession(input: McpAppCandidateInput): McpAppHostSession
645
622
  function registerFallback(reasonCode: string): void {
646
623
  metrics.fallbackTotal += 1;
647
624
  const normalizedReason = reasonCode.trim() || 'fallback';
648
- metrics.fallbackByReason[normalizedReason] =
649
- (metrics.fallbackByReason[normalizedReason] ?? 0) + 1;
625
+ metrics.fallbackByReason[normalizedReason] = (metrics.fallbackByReason[normalizedReason] ?? 0) + 1;
650
626
  }
651
627
 
652
628
  export function routeMcpAppCandidateToHost(input: McpAppCandidateInput): McpAppHostRoutingResult {
@@ -13,15 +13,8 @@ import type {
13
13
  Tool,
14
14
  } from '@modelcontextprotocol/sdk/types.js';
15
15
  import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
16
- import {
17
- EXTENSION_ID,
18
- RESOURCE_MIME_TYPE,
19
- } from '@modelcontextprotocol/ext-apps/server';
20
- import type {
21
- McpUiClientCapabilities,
22
- McpUiResourceCsp,
23
- McpUiResourceMeta,
24
- } from '@modelcontextprotocol/ext-apps';
16
+ import { EXTENSION_ID, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
17
+ import type { McpUiClientCapabilities, McpUiResourceCsp, McpUiResourceMeta } from '@modelcontextprotocol/ext-apps';
25
18
  import { getToolUiResourceUri } from '@modelcontextprotocol/ext-apps/app-bridge';
26
19
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
27
20
 
@@ -39,9 +32,7 @@ export interface ExternalMcpStdioTransportConfig {
39
32
  env?: Record<string, string>;
40
33
  }
41
34
 
42
- export type ExternalMcpTransportConfig =
43
- | ExternalMcpHttpTransportConfig
44
- | ExternalMcpStdioTransportConfig;
35
+ export type ExternalMcpTransportConfig = ExternalMcpHttpTransportConfig | ExternalMcpStdioTransportConfig;
45
36
 
46
37
  export interface OpenMcpAppInput {
47
38
  transport: ExternalMcpTransportConfig;
@@ -220,10 +211,7 @@ async function createSession(
220
211
  timeoutMs?: number,
221
212
  ): Promise<McpAppSession> {
222
213
  const transport = buildTransport(transportConfig);
223
- const client = new Client(
224
- { name: 'pmx-canvas-app-host', version: '0.1.0' },
225
- { capabilities: clientCapabilities },
226
- );
214
+ const client = new Client({ name: 'pmx-canvas-app-host', version: '0.1.0' }, { capabilities: clientCapabilities });
227
215
  await client.connect(transport, requestOptions(timeoutMs));
228
216
 
229
217
  const toolList = await client.listTools(undefined, requestOptions(timeoutMs));
@@ -369,10 +357,14 @@ export async function openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResu
369
357
  }
370
358
 
371
359
  const toolInput = isRecord(input.toolArguments) ? input.toolArguments : {};
372
- const rawToolResult = await session.client.callTool({
373
- name: tool.name,
374
- arguments: toolInput,
375
- }, undefined, options);
360
+ const rawToolResult = await session.client.callTool(
361
+ {
362
+ name: tool.name,
363
+ arguments: toolInput,
364
+ },
365
+ undefined,
366
+ options,
367
+ );
376
368
  const toolResult = normalizeExtAppToolResult({ result: rawToolResult });
377
369
  const readResult = await session.client.readResource({ uri: resourceUri }, options);
378
370
  const resourceMeta = resourceMetaFromReadResult(readResult);
@@ -254,10 +254,14 @@ export function diffLayouts(
254
254
  const changes: string[] = [];
255
255
 
256
256
  if (snapNode.position.x !== curNode.position.x || snapNode.position.y !== curNode.position.y) {
257
- changes.push(`moved (${snapNode.position.x},${snapNode.position.y}) → (${curNode.position.x},${curNode.position.y})`);
257
+ changes.push(
258
+ `moved (${snapNode.position.x},${snapNode.position.y}) → (${curNode.position.x},${curNode.position.y})`,
259
+ );
258
260
  }
259
261
  if (snapNode.size.width !== curNode.size.width || snapNode.size.height !== curNode.size.height) {
260
- changes.push(`resized ${snapNode.size.width}x${snapNode.size.height} → ${curNode.size.width}x${curNode.size.height}`);
262
+ changes.push(
263
+ `resized ${snapNode.size.width}x${snapNode.size.height} → ${curNode.size.width}x${curNode.size.height}`,
264
+ );
261
265
  }
262
266
  if (snapNode.collapsed !== curNode.collapsed) {
263
267
  changes.push(curNode.collapsed ? 'collapsed' : 'expanded');
@@ -314,8 +318,12 @@ export function diffLayouts(
314
318
  export function formatDiff(diff: SnapshotDiffResult): string {
315
319
  const lines: string[] = [`Diff: current canvas vs snapshot "${diff.snapshotName}"`, ''];
316
320
 
317
- const total = diff.addedNodes.length + diff.removedNodes.length + diff.modifiedNodes.length
318
- + diff.addedEdges.length + diff.removedEdges.length;
321
+ const total =
322
+ diff.addedNodes.length +
323
+ diff.removedNodes.length +
324
+ diff.modifiedNodes.length +
325
+ diff.addedEdges.length +
326
+ diff.removedEdges.length;
319
327
 
320
328
  if (total === 0) {
321
329
  lines.push('No differences — canvas matches the snapshot exactly.');
@@ -365,7 +373,9 @@ export function formatDiff(diff: SnapshotDiffResult): string {
365
373
  lines.push('');
366
374
  }
367
375
 
368
- lines.push(`Summary: +${diff.addedNodes.length} -${diff.removedNodes.length} ~${diff.modifiedNodes.length} nodes, +${diff.addedEdges.length} -${diff.removedEdges.length} edges`);
376
+ lines.push(
377
+ `Summary: +${diff.addedNodes.length} -${diff.removedNodes.length} ~${diff.modifiedNodes.length} nodes, +${diff.addedEdges.length} -${diff.removedEdges.length} edges`,
378
+ );
369
379
 
370
380
  return lines.join('\n');
371
381
  }
@@ -345,9 +345,7 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
345
345
  * Deriving it from the composites keeps suppression in lockstep with them — a
346
346
  * newly folded action is automatically suppressed from standalone registration.
347
347
  */
348
- export function compositeFoldedOpNames(
349
- definitions: CompositeToolDefinition[] = compositeToolDefinitions,
350
- ): Set<string> {
348
+ export function compositeFoldedOpNames(definitions: CompositeToolDefinition[] = compositeToolDefinitions): Set<string> {
351
349
  const names = new Set<string>();
352
350
  for (const def of definitions) {
353
351
  // Single-discriminator composites: the flat `actions` map values.
@@ -69,9 +69,8 @@ async function defaultReadInput(
69
69
  return { ...query, ...params };
70
70
  }
71
71
  const body = await readJsonValue(req);
72
- const record = body !== null && typeof body === 'object' && !Array.isArray(body)
73
- ? body as Record<string, unknown>
74
- : {};
72
+ const record =
73
+ body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : {};
75
74
  return { ...query, ...record, ...params };
76
75
  }
77
76
 
@@ -20,6 +20,9 @@ import { batchOperations } from './ops/batch.js';
20
20
  import { webviewOperations } from './ops/webview.js';
21
21
  import { appOperations } from './ops/app.js';
22
22
  import { intentOperations } from './ops/intent.js';
23
+ import { extAppOperations } from './ops/ext-app.js';
24
+ import { axReadOperations } from './ops/ax-read.js';
25
+ import { canvasWireOperations } from './ops/canvas-wire.js';
23
26
 
24
27
  for (const op of [
25
28
  ...nodeOperations,
@@ -39,6 +42,9 @@ for (const op of [
39
42
  ...webviewOperations,
40
43
  ...appOperations,
41
44
  ...intentOperations,
45
+ ...extAppOperations,
46
+ ...axReadOperations,
47
+ ...canvasWireOperations,
42
48
  ]) {
43
49
  registerOperation(op);
44
50
  }
@@ -53,7 +59,7 @@ export {
53
59
  export { dispatchOperationRoute } from './http.js';
54
60
  export { runCanvasBatchOperation, type BatchEnvelope } from './ops/batch.js';
55
61
  // OpenMcpAppCoreResult is the SDK's cast target for executeOperation('mcpapp.open').
56
- export { type OpenMcpAppCoreResult } from './ops/app.js';
62
+ export type { OpenMcpAppCoreResult } from './ops/app.js';
57
63
  export { LocalOperationInvoker, HttpOperationInvoker, type OperationInvoker } from './invoker.js';
58
64
  export { registerOperationTools, registerCompositeTools, type OperationToolHost } from './mcp.js';
59
65
  export { compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
@@ -77,9 +77,10 @@ export class HttpOperationInvoker implements OperationInvoker {
77
77
  }
78
78
  if (!response.ok) {
79
79
  if (route.errorBodyAsResult) return parsed;
80
- const message = parsed !== null && typeof parsed === 'object' && 'error' in parsed
81
- ? String((parsed as { error?: unknown }).error)
82
- : `HTTP ${response.status}`;
80
+ const message =
81
+ parsed !== null && typeof parsed === 'object' && 'error' in parsed
82
+ ? String((parsed as { error?: unknown }).error)
83
+ : `HTTP ${response.status}`;
83
84
  throw new OperationError(message, toOperationErrorStatus(response.status));
84
85
  }
85
86
  return parsed;
@@ -18,10 +18,7 @@ export interface OperationToolHost extends OperationMcpToolHost {
18
18
  invoker(): OperationInvoker;
19
19
  }
20
20
 
21
- export function registerOperationTools(
22
- server: McpServer,
23
- getHost: () => Promise<OperationToolHost>,
24
- ): void {
21
+ export function registerOperationTools(server: McpServer, getHost: () => Promise<OperationToolHost>): void {
25
22
  // Ops folded by a composite are NOT registered standalone: their legacy
26
23
  // single-purpose tools were removed in v0.3.0 (docs/api-stability.md). The op
27
24
  // itself is untouched — it stays reachable via its composite and canvas_batch.
@@ -191,32 +188,27 @@ export function registerCompositeTools(
191
188
  definitions: CompositeToolDefinition[] = compositeToolDefinitions,
192
189
  ): void {
193
190
  for (const def of definitions) {
194
- server.tool(
195
- def.toolName,
196
- def.description,
197
- buildCompositeShape(def),
198
- async (input: Record<string, unknown>) => {
199
- try {
200
- const host = await getHost();
201
- const opName = resolveCompositeOp(def, input);
202
- const op = getOperation(opName);
203
- // Strip the composite discriminators (action + any extra, e.g. `kind`)
204
- // and undo any field remap; the rest is the op's raw MCP args — the same
205
- // value the standalone tool would receive.
206
- const rest = stripCompositeDiscriminators(def, input);
207
- const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
208
- const result = await host.invoker().invoke(opName, opInput);
209
- if (op.mcp?.formatResult) {
210
- return await op.mcp.formatResult(result, rest, host);
211
- }
212
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
213
- } catch (error) {
214
- return {
215
- content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
216
- isError: true,
217
- };
191
+ server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
192
+ try {
193
+ const host = await getHost();
194
+ const opName = resolveCompositeOp(def, input);
195
+ const op = getOperation(opName);
196
+ // Strip the composite discriminators (action + any extra, e.g. `kind`)
197
+ // and undo any field remap; the rest is the op's raw MCP args — the same
198
+ // value the standalone tool would receive.
199
+ const rest = stripCompositeDiscriminators(def, input);
200
+ const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
201
+ const result = await host.invoker().invoke(opName, opInput);
202
+ if (op.mcp?.formatResult) {
203
+ return await op.mcp.formatResult(result, rest, host);
218
204
  }
219
- },
220
- );
205
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
206
+ } catch (error) {
207
+ return {
208
+ content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
209
+ isError: true,
210
+ };
211
+ }
212
+ });
221
213
  }
222
214
  }
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Annotation mutation op (plan-008 Wave 1): annotation.remove.
2
+ * Annotation ops: annotation.remove (plan-008 Wave 1) and annotation.add
3
+ * (plan-009 C1 slice 3 — the legacy POST /api/canvas/annotation handler,
4
+ * wire-identical).
3
5
  *
4
- * DELETE-by-id of a human-drawn canvas annotation. mutates: true → the registry
5
- * auto-emits one canvas-layout-update after success, matching the legacy
6
- * handleCanvasRemoveAnnotation handler (which emitted the layout update only on a
7
- * successful removal). A non-existent id is a 404 OperationError carrying the
8
- * exact legacy message; an omitted id is a 400 so composite callers get a loud
9
- * input error instead of a misleading "not found". HTTP renders errors as
10
- * { ok:false, error } and MCP renders them as isError tool results.
6
+ * annotation.remove: DELETE-by-id of a human-drawn canvas annotation.
7
+ * mutates: true → the registry auto-emits one canvas-layout-update after
8
+ * success, matching the legacy handler. A non-existent id is a 404
9
+ * OperationError carrying the exact legacy message; an omitted id is a 400 so
10
+ * composite callers get a loud input error instead of a misleading "not
11
+ * found". HTTP renders errors as { ok:false, error } and MCP renders them as
12
+ * isError tool results.
11
13
  *
12
14
  * This module must never import server.ts or index.ts.
13
15
  */
14
16
  import { z } from 'zod';
15
- import { canvasState } from '../../canvas-state.js';
17
+ import { canvasState, type CanvasAnnotation } from '../../canvas-state.js';
18
+ import { summarizeCanvasAnnotation } from '../../canvas-serialization.js';
16
19
  import { defineOperation, OperationError, type Operation } from '../types.js';
17
20
 
18
21
  const annotationRemoveShape = {
@@ -57,4 +60,113 @@ const annotationRemoveOperation = defineOperation<z.infer<typeof annotationRemov
57
60
  },
58
61
  });
59
62
 
60
- export const annotationOperations: Operation[] = [annotationRemoveOperation];
63
+ // ── annotation.add (helpers moved from server.ts) ─────────────
64
+
65
+ function annotationBounds(points: CanvasAnnotation['points']): CanvasAnnotation['bounds'] {
66
+ const xs = points.map((point) => point.x);
67
+ const ys = points.map((point) => point.y);
68
+ const minX = Math.min(...xs);
69
+ const minY = Math.min(...ys);
70
+ const maxX = Math.max(...xs);
71
+ const maxY = Math.max(...ys);
72
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
73
+ }
74
+
75
+ function textAnnotationBounds(
76
+ point: CanvasAnnotation['points'][number],
77
+ text: string,
78
+ width: number,
79
+ ): CanvasAnnotation['bounds'] {
80
+ return {
81
+ x: point.x,
82
+ y: point.y - width,
83
+ width: Math.max(width, text.length * width * 0.62),
84
+ height: width * 1.2,
85
+ };
86
+ }
87
+
88
+ function parseAnnotationPoints(value: unknown): CanvasAnnotation['points'] {
89
+ if (!Array.isArray(value)) return [];
90
+ return value
91
+ .map((point) => {
92
+ if (!point || typeof point !== 'object' || Array.isArray(point)) return null;
93
+ const record = point as Record<string, unknown>;
94
+ if (typeof record.x !== 'number' || typeof record.y !== 'number') return null;
95
+ if (!Number.isFinite(record.x) || !Number.isFinite(record.y)) return null;
96
+ return { x: record.x, y: record.y };
97
+ })
98
+ .filter((point): point is CanvasAnnotation['points'][number] => point !== null);
99
+ }
100
+
101
+ const annotationAddShape = {
102
+ type: z.unknown().optional().describe("Annotation type: 'freehand' (default) or 'text'"),
103
+ points: z.unknown().optional().describe('Annotation points: [{ x, y }, …]'),
104
+ width: z.unknown().optional().describe('Stroke width (freehand) or font size (text)'),
105
+ color: z.unknown().optional().describe("'currentColor' or a #rrggbb hex color"),
106
+ label: z.unknown().optional().describe('Short annotation label'),
107
+ text: z.unknown().optional().describe('Text annotation content'),
108
+ id: z.unknown().optional().describe('Explicit annotation id'),
109
+ };
110
+
111
+ const annotationAddSchema = z.looseObject(annotationAddShape);
112
+
113
+ const annotationAddOperation = defineOperation<z.infer<typeof annotationAddSchema>, Record<string, unknown>>({
114
+ name: 'annotation.add',
115
+ mutates: true,
116
+ input: annotationAddSchema,
117
+ inputShape: annotationAddShape,
118
+ http: {
119
+ method: 'POST',
120
+ path: '/api/canvas/annotation',
121
+ },
122
+ handler: (input) => {
123
+ const body: Record<string, unknown> = input;
124
+ const type = body.type === 'text' ? 'text' : 'freehand';
125
+ const points = parseAnnotationPoints(body.points);
126
+ if (points.length < (type === 'text' ? 1 : 2)) {
127
+ throw new OperationError(
128
+ type === 'text' ? 'Text annotation requires a valid point.' : 'Annotation requires at least two valid points.',
129
+ );
130
+ }
131
+
132
+ const defaultWidth = type === 'text' ? 24 : 4;
133
+ const maxWidth = type === 'text' ? 96 : 24;
134
+ const width =
135
+ typeof body.width === 'number' && Number.isFinite(body.width)
136
+ ? Math.min(maxWidth, Math.max(1, body.width))
137
+ : defaultWidth;
138
+ const color =
139
+ typeof body.color === 'string' && (body.color === 'currentColor' || /^#[0-9a-fA-F]{6}$/.test(body.color))
140
+ ? body.color
141
+ : 'currentColor';
142
+ const label =
143
+ typeof body.label === 'string' && body.label.trim().length > 0 ? body.label.trim().slice(0, 160) : undefined;
144
+ const text =
145
+ type === 'text' && typeof body.text === 'string' && body.text.trim().length > 0
146
+ ? body.text.trim().slice(0, 240)
147
+ : undefined;
148
+ if (type === 'text' && !text) {
149
+ throw new OperationError('Text annotation requires text.');
150
+ }
151
+ const id =
152
+ typeof body.id === 'string' && body.id.trim().length > 0
153
+ ? body.id.trim().slice(0, 120)
154
+ : `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
155
+ const annotation: CanvasAnnotation = {
156
+ id,
157
+ type,
158
+ points,
159
+ bounds: type === 'text' ? textAnnotationBounds(points[0]!, text!, width) : annotationBounds(points),
160
+ color,
161
+ width,
162
+ ...(text ? { text } : {}),
163
+ ...((label ?? text) ? { label: label ?? text } : {}),
164
+ createdAt: new Date().toISOString(),
165
+ };
166
+
167
+ canvasState.addAnnotation(annotation);
168
+ return { ok: true, annotation: summarizeCanvasAnnotation(annotation) };
169
+ },
170
+ });
171
+
172
+ export const annotationOperations: Operation[] = [annotationRemoveOperation, annotationAddOperation];