pmx-canvas 0.2.7 → 0.3.0

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 (57) hide show
  1. package/CHANGELOG.md +110 -1
  2. package/Readme.md +13 -11
  3. package/dist/json-render/index.css +1 -1
  4. package/dist/types/mcp/canvas-access.d.ts +0 -7
  5. package/dist/types/server/ax-state-manager.d.ts +3 -2
  6. package/dist/types/server/bundled-skills.d.ts +3 -3
  7. package/dist/types/server/canvas-state.d.ts +2 -0
  8. package/dist/types/server/ext-app-lookup.d.ts +1 -3
  9. package/dist/types/server/operations/composites.d.ts +29 -23
  10. package/dist/types/server/operations/http.d.ts +2 -1
  11. package/dist/types/shared/ax-intent.d.ts +1 -1
  12. package/docs/RELEASE.md +23 -8
  13. package/docs/ax-host-adapter-contract.md +7 -7
  14. package/docs/ax-state-contract.md +7 -6
  15. package/docs/bun-webview-integration.md +5 -5
  16. package/docs/cli.md +1 -1
  17. package/docs/http-api.md +7 -3
  18. package/docs/mcp.md +121 -100
  19. package/docs/node-types.md +45 -35
  20. package/docs/plans/plan-006-mcp-tool-consolidation.md +3 -1
  21. package/docs/plans/plan-008-registry-finish.md +2 -0
  22. package/docs/plans/plan-009-tech-debt-backlog.md +51 -0
  23. package/docs/screenshot.png +0 -0
  24. package/docs/tech-debt-assessment-2026-06.md +1 -1
  25. package/docs/tech-debt-assessment-2026-07.md +135 -0
  26. package/package.json +1 -1
  27. package/skills/data-analysis/SKILL.md +3 -3
  28. package/skills/frontend-design/SKILL.md +3 -2
  29. package/skills/json-render-mcp/SKILL.md +4 -3
  30. package/skills/playwright-cli/SKILL.md +1 -1
  31. package/skills/pmx-canvas/SKILL.md +9 -6
  32. package/skills/pmx-canvas/evals/evals.json +2 -2
  33. package/skills/pmx-canvas/references/ax-html-control-surface.md +3 -1
  34. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +2 -2
  35. package/skills/pmx-canvas/references/full-reference.md +48 -53
  36. package/skills/pmx-canvas/references/html-primitives.md +3 -3
  37. package/skills/tufte-viz/SKILL.md +5 -4
  38. package/skills/web-artifacts-builder/SKILL.md +4 -4
  39. package/src/mcp/canvas-access.ts +4 -99
  40. package/src/mcp/server.ts +74 -201
  41. package/src/server/ax-state-manager.ts +3 -2
  42. package/src/server/bundled-skills.ts +3 -3
  43. package/src/server/canvas-schema.ts +46 -29
  44. package/src/server/canvas-state.ts +5 -0
  45. package/src/server/code-graph.ts +1 -1
  46. package/src/server/ext-app-lookup.ts +1 -8
  47. package/src/server/index.ts +1 -1
  48. package/src/server/intent-registry.ts +1 -1
  49. package/src/server/operations/composites.ts +39 -39
  50. package/src/server/operations/http.ts +3 -2
  51. package/src/server/operations/mcp.ts +7 -6
  52. package/src/server/operations/ops/app.ts +4 -4
  53. package/src/server/operations/ops/nodes.ts +5 -5
  54. package/src/server/operations/ops/query.ts +19 -8
  55. package/src/server/operations/ops/snapshots.ts +6 -6
  56. package/src/server/server.ts +30 -2
  57. package/src/shared/ax-intent.ts +1 -1
@@ -885,7 +885,7 @@ export class PmxCanvas extends EventEmitter {
885
885
 
886
886
  setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): { count: number; nodeIds: string[] } {
887
887
  const result = setCanvasContextPins(nodeIds, mode);
888
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
888
+ emitPrimaryWorkbenchEvent('context-pins-changed', { count: result.count, nodeIds: result.nodeIds });
889
889
  return result;
890
890
  }
891
891
 
@@ -4,7 +4,7 @@
4
4
  * Intents are EPHEMERAL PRESENCE, deliberately modelled like the attention /
5
5
  * timeline ephemerality layer rather than canvas-bound state:
6
6
  * - an in-memory Map (NOT CanvasStateManager) — never serialized, never
7
- * snapshotted, never returned by canvas_get_layout;
7
+ * snapshotted, never returned by canvas_query (layout action);
8
8
  * - count-capped (oldest evicted) and TTL-swept so a ghost can never linger;
9
9
  * - emitted over the same workbench SSE stream as `ax-intent` /
10
10
  * `ax-intent-clear` frames via an INJECTED emitter (server.ts wires it,
@@ -9,17 +9,22 @@
9
9
  * to the standalone `canvas_add_edge` — same op, same arg mapping, same result
10
10
  * shape — by construction. No handler logic lives here.
11
11
  *
12
- * Migration (docs/api-stability.md + plan-006): composites land ADDITIVELY in
13
- * v0.2 alongside the legacy single-purpose tools (the tool surface grows, then
14
- * shrinks when the legacy tools are removed in v0.3). Every action here maps to a
15
- * registry-backed operation (plan-005 slices 1–7 + plan-008 Wave 1).
12
+ * Migration (docs/api-stability.md + plan-006): composites landed ADDITIVELY in
13
+ * v0.2 alongside the legacy single-purpose tools. As of v0.3.0 the legacy tools
14
+ * those composites fold are REMOVED (registration-suppressed see
15
+ * `compositeFoldedOpNames` below); every action here maps to a registry-backed
16
+ * operation (plan-005 slices 1–7 + plan-008 Wave 1) that is now reachable ONLY
17
+ * through its composite (or `canvas_batch`).
16
18
  *
17
- * Still deferred (its legacy standalone tool keeps working; see plan-008): the
18
- * `canvas_snapshot` composite (the v0.3 name collision). The action enums are
19
- * forward-compatible: adding an action later is additive. (`canvas_webview`
20
- * shipped in plan-008 Wave 3 via runner injection; `canvas_app` shipped in Wave 4
21
- * open-mcp-app / diagram / build-artifact. Wave 5 folded the last three legacy
22
- * tools deprecate-only NO per-action input-injection mechanism was needed:
19
+ * Deferred to v0.4: the `canvas_snapshot` composite (still a name collision
20
+ * see below). Its 6 legacy snapshot standalones (`canvas_snapshot`,
21
+ * `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`,
22
+ * `canvas_gc_snapshots`, `canvas_diff`) stay registered in v0.3.0 but are marked
23
+ * deprecated (description-prefixed) per docs/api-stability.md's
24
+ * deprecate-one-minor-before-removal rule. (`canvas_webview` shipped in plan-008
25
+ * Wave 3 via runner injection; `canvas_app` shipped in Wave 4 — open-mcp-app /
26
+ * diagram / build-artifact. Wave 5 folded the last three legacy tools
27
+ * deprecate-only — NO per-action input-injection mechanism was needed:
23
28
  * `canvas_add_html_node` / `canvas_add_html_primitive` → `canvas_node` action
24
29
  * "add" (type:"html" [+ primitive]); `canvas_refresh_webpage_node` → `canvas_node`
25
30
  * action "update" (refresh:true). `canvas_screenshot` stays standalone — it
@@ -30,8 +35,8 @@
30
35
  * name is ALREADY a legacy standalone tool (the save-snapshot tool, op
31
36
  * `snapshot.save`), so it cannot be added additively without a name clash, and
32
37
  * repurposing `canvas_snapshot` to be action-discriminated now would break
33
- * existing callers. It lands in v0.3, in the same change that removes the legacy
34
- * single-purpose snapshot tools and frees the name.
38
+ * existing callers. It lands in v0.4, in the same change that removes the 6
39
+ * kept-but-deprecated legacy snapshot tools and frees the name.
35
40
  *
36
41
  * This module must never import server.ts or index.ts.
37
42
  */
@@ -47,7 +52,7 @@ import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
47
52
  * - Two-discriminator (`canvas_ax_gate`, plan-007 Slice C): a `kind` × `action`
48
53
  * matrix folds 9 ops into one tool. Set `extraDiscriminatorShape` (the `kind`
49
54
  * enum), `memberOps` (the op names — used to derive the schema union + the
50
- * deprecation notes), `actionEnum` (the action discriminator values), and
55
+ * folded-op set), `actionEnum` (the action discriminator values), and
51
56
  * `resolveOp` (maps `{ kind, action }` → op name, or undefined for an invalid
52
57
  * combo → a loud error at dispatch). The flat `actions` map is left empty for
53
58
  * these; the matrix path uses `resolveOp` instead.
@@ -78,8 +83,9 @@ export interface CompositeToolDefinition {
78
83
  actionEnum?: readonly string[];
79
84
  /**
80
85
  * Two-discriminator extension: every member op name. Used to build the schema
81
- * union (all member-op fields, optional) and to derive a deprecation note per
82
- * member op (each mapped back to its (kind, action) by `describeOp`).
86
+ * union (all member-op fields, optional) and to populate the folded-op set
87
+ * (`compositeFoldedOpNames`) that suppresses each member op's standalone
88
+ * registration.
83
89
  */
84
90
  memberOps?: string[];
85
91
  /**
@@ -331,36 +337,30 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
331
337
  ];
332
338
 
333
339
  /**
334
- * Deprecation notes for the legacy single-purpose tools, DERIVED from the
335
- * composites: every operation a composite folds gets a `Deprecated: use
336
- * canvas_x with action "y".` prefix on its standalone tool description, steering
337
- * agents to the composite during the v0.2 overlap window (the legacy tools and
338
- * these notes are removed together in v0.3). Keyed by registry operation name.
339
- * Deriving it keeps the deprecation list in lockstep with the composites — a new
340
- * folded action automatically deprecates the tool it replaces.
340
+ * Operation names FOLDED by a composite, DERIVED from the composites: every op
341
+ * a composite folds had its standalone single-purpose tool REMOVED in v0.3.0
342
+ * (see docs/api-stability.md). This set is the do-not-register list
343
+ * `registerOperationTools` skips any op whose name is in it, since the op is
344
+ * only reachable through its composite (and through `canvas_batch`) now.
345
+ * Deriving it from the composites keeps suppression in lockstep with them — a
346
+ * newly folded action is automatically suppressed from standalone registration.
341
347
  */
342
- export function buildCompositeDeprecationNotes(
348
+ export function compositeFoldedOpNames(
343
349
  definitions: CompositeToolDefinition[] = compositeToolDefinitions,
344
- ): Map<string, string> {
345
- const notes = new Map<string, string>();
350
+ ): Set<string> {
351
+ const names = new Set<string>();
346
352
  for (const def of definitions) {
347
- // Single-discriminator composites: one note per (action → op).
348
- for (const [action, opName] of Object.entries(def.actions)) {
349
- notes.set(opName, `Deprecated: use ${def.toolName} with action "${action}". `);
353
+ // Single-discriminator composites: the flat `actions` map values.
354
+ for (const opName of Object.values(def.actions)) {
355
+ names.add(opName);
350
356
  }
351
- // Two-discriminator composites (canvas_ax_gate): one note per member op,
352
- // each mapped back to its (kind, action) so the deprecation points exactly
353
- // at the composite invocation that replaces the legacy tool.
354
- if (def.memberOps && def.describeOp) {
357
+ // Two-discriminator composites (canvas_ax_gate): the explicit `memberOps`
358
+ // list (the flat `actions` map is empty for these).
359
+ if (def.memberOps) {
355
360
  for (const opName of def.memberOps) {
356
- const combo = def.describeOp(opName);
357
- if (!combo) continue;
358
- notes.set(
359
- opName,
360
- `Deprecated: use ${def.toolName} with kind "${combo.kind}" action "${combo.action}". `,
361
- );
361
+ names.add(opName);
362
362
  }
363
363
  }
364
364
  }
365
- return notes;
365
+ return names;
366
366
  }
@@ -38,7 +38,8 @@ function matchPath(template: string, pathname: string): Record<string, string> |
38
38
  /**
39
39
  * Shared body reader: preserves the parsed JSON value as-is (object, array,
40
40
  * or primitive) — per-op `readInput` decides what to do with non-object
41
- * bodies; the shared reader never coerces.
41
+ * bodies; the shared reader never coerces. A non-empty body that fails to
42
+ * parse is a 400 (OperationError), never a silent empty input.
42
43
  */
43
44
  export async function readJsonValue(req: Request): Promise<unknown> {
44
45
  let text = '';
@@ -51,7 +52,7 @@ export async function readJsonValue(req: Request): Promise<unknown> {
51
52
  try {
52
53
  return JSON.parse(text) as unknown;
53
54
  } catch {
54
- return undefined;
55
+ throw new OperationError('Malformed JSON body.');
55
56
  }
56
57
  }
57
58
 
@@ -12,7 +12,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  import { getOperation, listOperations } from './registry.js';
13
13
  import type { OperationInvoker } from './invoker.js';
14
14
  import { OperationError, type OperationMcpToolHost } from './types.js';
15
- import { buildCompositeDeprecationNotes, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
15
+ import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
16
16
 
17
17
  export interface OperationToolHost extends OperationMcpToolHost {
18
18
  invoker(): OperationInvoker;
@@ -22,16 +22,17 @@ export function registerOperationTools(
22
22
  server: McpServer,
23
23
  getHost: () => Promise<OperationToolHost>,
24
24
  ): void {
25
- // Legacy tools folded by a composite get a "Deprecated: use canvas_x …" prefix
26
- // (plan-006 step 2) so agents migrate during the v0.2 overlap window.
27
- const deprecations = buildCompositeDeprecationNotes();
25
+ // Ops folded by a composite are NOT registered standalone: their legacy
26
+ // single-purpose tools were removed in v0.3.0 (docs/api-stability.md). The op
27
+ // itself is untouched — it stays reachable via its composite and canvas_batch.
28
+ const foldedOpNames = compositeFoldedOpNames();
28
29
  for (const op of listOperations()) {
30
+ if (foldedOpNames.has(op.name)) continue;
29
31
  const tool = op.mcp;
30
32
  if (!tool) continue;
31
- const note = deprecations.get(op.name);
32
33
  server.tool(
33
34
  tool.toolName,
34
- note ? note + tool.description : tool.description,
35
+ tool.description,
35
36
  { ...op.inputShape, ...(tool.extraShape ?? {}) },
36
37
  async (input: Record<string, unknown>) => {
37
38
  try {
@@ -94,10 +94,10 @@ export async function openMcpAppCore(
94
94
  // event is dropped, so the node would never be created and the just-opened
95
95
  // external session would leak with a null nodeId. Reject loudly BEFORE opening
96
96
  // any session rather than silently corrupting. mcpapp.open / diagram.open are
97
- // not batchable — call canvas_open_mcp_app / canvas_add_diagram (or canvas_app).
97
+ // not batchable — call canvas_app (action "open-mcp-app" / "diagram") instead.
98
98
  if (isEmitSuppressed()) {
99
99
  throw new OperationError(
100
- 'mcpapp.open / diagram.open cannot run inside canvas_batch: the canvas node is created from the ext-app-open SSE event, which batch suppresses. Call the op directly (canvas_open_mcp_app / canvas_add_diagram, or canvas_app).',
100
+ 'mcpapp.open / diagram.open cannot run inside canvas_batch: the canvas node is created from the ext-app-open SSE event, which batch suppresses. Call canvas_app with action "open-mcp-app" or "diagram" outside the batch.',
101
101
  );
102
102
  }
103
103
  const targetNode = input.nodeId ? canvasState.getNode(input.nodeId) : undefined;
@@ -246,7 +246,7 @@ const openMcpAppOperation = defineOperation<z.infer<typeof openMcpAppSchema>, Op
246
246
  mcp: {
247
247
  toolName: 'canvas_open_mcp_app',
248
248
  description:
249
- 'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_add_diagram for the built-in Excalidraw preset.',
249
+ 'Connect to an external MCP server that declares a ui:// app resource, call the specified tool, and open the resulting MCP App inside a canvas mcp-app node. This is a full external-MCP transport call, not the CLI kind shortcut; use canvas_app action "diagram" for the built-in Excalidraw preset.',
250
250
  formatResult: (result) => ({
251
251
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
252
252
  }),
@@ -283,7 +283,7 @@ const diagramOperation = defineOperation<z.infer<typeof diagramSchema>, OpenMcpA
283
283
  mcp: {
284
284
  toolName: 'canvas_add_diagram',
285
285
  description:
286
- 'Draw a hand-drawn diagram on the canvas via the hosted Excalidraw MCP app. Pass an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows, text). The diagram opens inside an mcp-app node that supports fullscreen editing. For other MCP apps, use canvas_open_mcp_app.',
286
+ 'Draw a hand-drawn diagram on the canvas via the hosted Excalidraw MCP app. Pass an array of Excalidraw elements (rectangles, ellipses, diamonds, arrows, text). The diagram opens inside an mcp-app node that supports fullscreen editing. For other MCP apps, use canvas_app action "open-mcp-app".',
287
287
  formatResult: (result) => ({
288
288
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
289
289
  }),
@@ -654,7 +654,7 @@ function createGroupNode(body: Record<string, unknown>): NodeAddResult {
654
654
 
655
655
  const nodeAddShape = {
656
656
  intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
657
- type: z.string().optional().catch(undefined).describe('Node type (prefer canvas_create_group for groups)'),
657
+ type: z.string().optional().catch(undefined).describe('Node type (prefer canvas_group {action:"create"} for groups)'),
658
658
  title: z.string().optional().catch(undefined).describe('Node title'),
659
659
  content: z.string().optional().catch(undefined).describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
660
660
  path: z.string().optional().catch(undefined).describe('Compatibility alias for image node content. Prefer content for image paths.'),
@@ -665,7 +665,7 @@ const nodeAddShape = {
665
665
  height: z.number().optional().catch(undefined).describe('Height in pixels (default: 600)'),
666
666
  strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
667
667
  children: z.unknown().optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
668
- childIds: z.unknown().optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_create_group for groups.'),
668
+ childIds: z.unknown().optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_group {action:"create"} for groups.'),
669
669
  childLayout: z.enum(['grid', 'column', 'flow']).optional().catch(undefined).describe('Group-only optional layout for grouped children.'),
670
670
  color: z.string().optional().catch(undefined).describe('Group-only frame accent color.'),
671
671
  toolName: z.string().optional().catch(undefined).describe('Trace node tool or operation label'),
@@ -708,12 +708,12 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
708
708
  },
709
709
  mcp: {
710
710
  toolName: 'canvas_add_node',
711
- description: 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated node tools: json-render -> canvas_add_json_render_node, graph -> canvas_add_graph_node, web-artifact -> canvas_build_web_artifact, external apps -> canvas_open_mcp_app, html (preferred) -> canvas_add_html_node, groups -> canvas_create_group. Call canvas_describe_schema for the full nodeTypeRouting table.',
711
+ description: 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
712
712
  extraShape: {
713
713
  type: z.enum(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'webpage', 'mcp-app', 'html', 'group'])
714
- .describe('Node type (prefer canvas_create_group for groups)'),
714
+ .describe('Node type (prefer canvas_group {action:"create"} for groups)'),
715
715
  children: z.array(z.string()).optional().describe('Group-only alias for childIds. Node IDs to include in a generic group node.'),
716
- childIds: z.array(z.string()).optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_create_group for groups.'),
716
+ childIds: z.array(z.string()).optional().describe('Group-only field. Node IDs to include in a generic group node. Prefer canvas_group {action:"create"} for groups.'),
717
717
  axCapabilities: z.object({
718
718
  enabled: z.boolean().optional(),
719
719
  allowed: z.array(z.string()).optional(),
@@ -6,9 +6,9 @@
6
6
  * - pin.set: legacy POST /api/canvas/context-pins emitted ONLY
7
7
  * context-pins-changed (no canvas-layout-update), so mutates: false with a
8
8
  * manual ctx.emit. The injected emitter adds the sessionId/timestamp fields
9
- * the legacy handler set explicitly. (The SDK's setContextPins emits a
10
- * layout update insteadthat local/remote drift is erased here in favor of
11
- * the HTTP wire behavior.)
9
+ * the legacy handler set explicitly. (The SDK's setContextPins emits the
10
+ * same context-pins-changed eventits old layout-update drift was erased
11
+ * in v0.3.0.)
12
12
  * - canvas.undo / canvas.redo: legacy handlers emitted canvas-viewport-update
13
13
  * then canvas-layout-update only after an entry was actually undone/redone,
14
14
  * so mutates: false with conditional manual emits. (The SDK's undo/redo also
@@ -87,7 +87,7 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
87
87
 
88
88
  const searchShape = {
89
89
  q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
90
- limit: z.unknown().optional().describe('Max results to return (default: 10). Applied by the MCP tool only; the HTTP route returns all matches.'),
90
+ limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
91
91
  };
92
92
 
93
93
  const searchSchema = z.looseObject(searchShape);
@@ -108,9 +108,12 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
108
108
  query: z.string().describe('Search query — matches against node titles, content, and file paths'),
109
109
  limit: z.number().optional().describe('Max results to return (default: 10)'),
110
110
  },
111
- // The HTTP route only reads ?q= (legacy behavior); map the MCP-facing
112
- // `query` arg onto it. The limit cap stays MCP-side, like the legacy tool.
113
- buildInput: (input) => ({ q: typeof input.query === 'string' ? input.query : '' }),
111
+ // Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
112
+ // `limit` on every transport; the MCP tool additionally defaults it to 10.
113
+ buildInput: (input) => ({
114
+ q: typeof input.query === 'string' ? input.query : '',
115
+ ...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
116
+ }),
114
117
  formatResult: (result, input) => {
115
118
  const body = isRecord(result) ? result : {};
116
119
  const results = Array.isArray(body.results) ? body.results : [];
@@ -132,7 +135,15 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
132
135
  if (!q.trim()) {
133
136
  return { results: [], query: q };
134
137
  }
135
- return { results: searchNodes(canvasState.getLayout().nodes, q), query: q };
138
+ const rawLimit = input.limit;
139
+ const limit = typeof rawLimit === 'number'
140
+ ? rawLimit
141
+ : typeof rawLimit === 'string' && rawLimit.trim() !== '' ? Number(rawLimit) : Number.NaN;
142
+ const results = searchNodes(canvasState.getLayout().nodes, q);
143
+ return {
144
+ results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
145
+ query: q,
146
+ };
136
147
  },
137
148
  });
138
149
 
@@ -69,7 +69,7 @@ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>
69
69
  },
70
70
  mcp: {
71
71
  toolName: 'canvas_list_snapshots',
72
- description: 'List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
72
+ description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
73
73
  extraShape: {
74
74
  limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
75
75
  query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
@@ -117,7 +117,7 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
117
117
  },
118
118
  mcp: {
119
119
  toolName: 'canvas_snapshot',
120
- description: 'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
120
+ description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
121
121
  extraShape: {
122
122
  name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
123
123
  },
@@ -154,7 +154,7 @@ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Re
154
154
  },
155
155
  mcp: {
156
156
  toolName: 'canvas_gc_snapshots',
157
- description: 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
157
+ description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
158
158
  extraShape: {
159
159
  keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
160
160
  dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
@@ -207,7 +207,7 @@ const snapshotDiffOperation = defineOperation<z.infer<typeof snapshotDiffSchema>
207
207
  },
208
208
  mcp: {
209
209
  toolName: 'canvas_diff',
210
- description: 'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
210
+ description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
211
211
  extraShape: {
212
212
  snapshot: z.string().describe('Snapshot name or ID to compare against'),
213
213
  },
@@ -289,7 +289,7 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
289
289
  },
290
290
  mcp: {
291
291
  toolName: 'canvas_restore',
292
- description: 'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
292
+ description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
293
293
  extraShape: {
294
294
  id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
295
295
  },
@@ -338,7 +338,7 @@ const snapshotDeleteOperation = defineOperation<z.infer<typeof snapshotDeleteSch
338
338
  },
339
339
  mcp: {
340
340
  toolName: 'canvas_delete_snapshot',
341
- description: 'Delete a saved snapshot by ID.',
341
+ description: 'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete a saved snapshot by ID.',
342
342
  extraShape: {
343
343
  id: z.string().describe('Snapshot ID to delete'),
344
344
  },
@@ -920,7 +920,8 @@ function rotatePrimaryWorkbenchSessionIfNeeded(): void {
920
920
  primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
921
921
  }
922
922
 
923
- async function readJson(req: Request): Promise<Record<string, unknown>> {
923
+ /** Null means a non-empty body failed to parse — handlers must 400, not silently no-op. */
924
+ async function readJson(req: Request): Promise<Record<string, unknown> | null> {
924
925
  let text = '';
925
926
  try {
926
927
  text = await req.text();
@@ -937,10 +938,14 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
937
938
  return value as Record<string, unknown>;
938
939
  } catch (error) {
939
940
  logWorkbenchWarning('readJson', error);
940
- return {};
941
+ return null;
941
942
  }
942
943
  }
943
944
 
945
+ function malformedJsonResponse(): Response {
946
+ return responseJson({ ok: false, error: 'Malformed JSON body.' }, 400);
947
+ }
948
+
944
949
  function htmlEscape(value: string): string {
945
950
  return value
946
951
  .replaceAll('&', '&amp;')
@@ -1228,6 +1233,7 @@ function addFrameDocument(html: string, sandbox: string): string {
1228
1233
 
1229
1234
  async function handleCreateFrameDocument(req: Request): Promise<Response> {
1230
1235
  const body = await readJson(req);
1236
+ if (body === null) return malformedJsonResponse();
1231
1237
  const html = body.html;
1232
1238
  if (typeof html !== 'string' || !html) {
1233
1239
  return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
@@ -1370,6 +1376,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1370
1376
 
1371
1377
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1372
1378
  const body = await readJson(req);
1379
+ if (body === null) return malformedJsonResponse();
1373
1380
  const updates = Array.isArray(body.updates) ? body.updates : [];
1374
1381
  const result = body.recordHistory === false
1375
1382
  ? (() => {
@@ -1388,6 +1395,7 @@ async function handleCanvasUpdate(req: Request): Promise<Response> {
1388
1395
 
1389
1396
  async function handleCanvasViewport(req: Request): Promise<Response> {
1390
1397
  const body = await readJson(req);
1398
+ if (body === null) return malformedJsonResponse();
1391
1399
  const next = {
1392
1400
  x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
1393
1401
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
@@ -1438,6 +1446,7 @@ function parseAnnotationPoints(value: unknown): CanvasAnnotation['points'] {
1438
1446
 
1439
1447
  async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
1440
1448
  const body = await readJson(req);
1449
+ if (body === null) return malformedJsonResponse();
1441
1450
  const type = body.type === 'text' ? 'text' : 'freehand';
1442
1451
  const points = parseAnnotationPoints(body.points);
1443
1452
  if (points.length < (type === 'text' ? 1 : 2)) {
@@ -1524,6 +1533,7 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
1524
1533
  }
1525
1534
 
1526
1535
  const body = await readJson(req);
1536
+ if (body === null) return malformedJsonResponse();
1527
1537
  const rawUrl = typeof body.url === 'string' ? body.url : undefined;
1528
1538
  let url: string | undefined;
1529
1539
  if (rawUrl && rawUrl.trim().length > 0) {
@@ -1544,6 +1554,7 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
1544
1554
 
1545
1555
  async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
1546
1556
  const body = await readJson(req);
1557
+ if (body === null) return malformedJsonResponse();
1547
1558
  const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
1548
1559
  const next = canvasState.setTheme(theme);
1549
1560
  broadcastWorkbenchEvent('theme-changed', {
@@ -1823,6 +1834,7 @@ function handleRead(pathLike: string): Response {
1823
1834
 
1824
1835
  async function handleExtAppCallTool(req: Request): Promise<Response> {
1825
1836
  const body = await readJson(req);
1837
+ if (body === null) return malformedJsonResponse();
1826
1838
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1827
1839
  const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
1828
1840
  if (!sessionId || !toolName) {
@@ -1893,6 +1905,7 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
1893
1905
 
1894
1906
  async function handleExtAppReadResource(req: Request): Promise<Response> {
1895
1907
  const body = await readJson(req);
1908
+ if (body === null) return malformedJsonResponse();
1896
1909
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1897
1910
  const uri = typeof body.uri === 'string' ? body.uri.trim() : '';
1898
1911
  if (!sessionId || !uri) {
@@ -1909,6 +1922,7 @@ async function handleExtAppReadResource(req: Request): Promise<Response> {
1909
1922
 
1910
1923
  async function handleExtAppListTools(req: Request): Promise<Response> {
1911
1924
  const body = await readJson(req);
1925
+ if (body === null) return malformedJsonResponse();
1912
1926
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1913
1927
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1914
1928
 
@@ -1922,6 +1936,7 @@ async function handleExtAppListTools(req: Request): Promise<Response> {
1922
1936
 
1923
1937
  async function handleExtAppListResources(req: Request): Promise<Response> {
1924
1938
  const body = await readJson(req);
1939
+ if (body === null) return malformedJsonResponse();
1925
1940
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1926
1941
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1927
1942
 
@@ -1935,6 +1950,7 @@ async function handleExtAppListResources(req: Request): Promise<Response> {
1935
1950
 
1936
1951
  async function handleExtAppListResourceTemplates(req: Request): Promise<Response> {
1937
1952
  const body = await readJson(req);
1953
+ if (body === null) return malformedJsonResponse();
1938
1954
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1939
1955
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1940
1956
 
@@ -1948,6 +1964,7 @@ async function handleExtAppListResourceTemplates(req: Request): Promise<Response
1948
1964
 
1949
1965
  async function handleExtAppListPrompts(req: Request): Promise<Response> {
1950
1966
  const body = await readJson(req);
1967
+ if (body === null) return malformedJsonResponse();
1951
1968
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1952
1969
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1953
1970
 
@@ -1961,6 +1978,7 @@ async function handleExtAppListPrompts(req: Request): Promise<Response> {
1961
1978
 
1962
1979
  async function handleExtAppModelContext(req: Request): Promise<Response> {
1963
1980
  const body = await readJson(req);
1981
+ if (body === null) return malformedJsonResponse();
1964
1982
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
1965
1983
  if (!nodeId) return responseJson({ ok: false, error: 'Missing nodeId.' }, 400);
1966
1984
 
@@ -2033,6 +2051,7 @@ function currentWorkbenchUrl(): string | null {
2033
2051
 
2034
2052
  async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response> {
2035
2053
  const body = await readJson(req);
2054
+ if (body === null) return malformedJsonResponse();
2036
2055
  const format = body.format === 'jpeg' || body.format === 'webp' || body.format === 'png'
2037
2056
  ? body.format
2038
2057
  : 'png';
@@ -2063,6 +2082,7 @@ async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response>
2063
2082
 
2064
2083
  async function handleWorkbenchOpen(req: Request): Promise<Response> {
2065
2084
  const body = await readJson(req);
2085
+ if (body === null) return malformedJsonResponse();
2066
2086
  const pathLike = typeof body.path === 'string' ? body.path : '';
2067
2087
  const safePath = resolveWorkspaceMarkdownPath(pathLike);
2068
2088
  if (!safePath) return responseText('Invalid path', 400);
@@ -2074,6 +2094,7 @@ async function handleWorkbenchOpen(req: Request): Promise<Response> {
2074
2094
 
2075
2095
  async function handleWorkbenchIntent(req: Request): Promise<Response> {
2076
2096
  const body = await readJson(req);
2097
+ if (body === null) return malformedJsonResponse();
2077
2098
  const rawType = typeof body.type === 'string' ? body.type.trim() : '';
2078
2099
  if (!rawType) return responseText('Missing intent type', 400);
2079
2100
  if (!ALLOWED_WORKBENCH_INTENTS.has(rawType as PrimaryWorkbenchIntent['type'])) {
@@ -2234,6 +2255,7 @@ function handleWorkbenchEvents(req: Request): Response {
2234
2255
 
2235
2256
  async function handleSave(req: Request): Promise<Response> {
2236
2257
  const body = await readJson(req);
2258
+ if (body === null) return malformedJsonResponse();
2237
2259
  const pathLike = typeof body.path === 'string' ? body.path : '';
2238
2260
  const safePath = resolveWorkspaceMarkdownPath(pathLike);
2239
2261
  if (!safePath) return responseText('Invalid path', 400);
@@ -2250,6 +2272,7 @@ async function handleSave(req: Request): Promise<Response> {
2250
2272
 
2251
2273
  async function handleRender(req: Request): Promise<Response> {
2252
2274
  const body = await readJson(req);
2275
+ if (body === null) return malformedJsonResponse();
2253
2276
  const markdown = typeof body.markdown === 'string' ? body.markdown : '';
2254
2277
  const html =
2255
2278
  (marked.parse(normalizeMarkdownExternalUrls(markdown), {
@@ -2271,6 +2294,7 @@ function buildSelectionContextPreamble(contextNodeIds: string[]): string {
2271
2294
 
2272
2295
  async function handleCanvasPrompt(req: Request): Promise<Response> {
2273
2296
  const body = await readJson(req);
2297
+ if (body === null) return malformedJsonResponse();
2274
2298
  const text = typeof body.text === 'string' ? body.text.trim() : '';
2275
2299
  if (!text) return responseText('Missing prompt text', 400);
2276
2300
 
@@ -2604,6 +2628,7 @@ function normalizeActivityReactions(input: Record<string, unknown>): {
2604
2628
  // Report primitive A: ingest a harness-forwarded agent activity; the board auto-reacts.
2605
2629
  async function handleAxActivityIngest(req: Request): Promise<Response> {
2606
2630
  const body = await readJson(req);
2631
+ if (body === null) return malformedJsonResponse();
2607
2632
  if (!isAxActivityKind(body.kind)) {
2608
2633
  return responseJson({ ok: false, error: "activity requires a valid 'kind': one of tool-start, tool-result, failure, error, session-start, session-end, command, note." }, 400);
2609
2634
  }
@@ -2650,6 +2675,7 @@ function handleGetAxSurfaceSnapshot(): Response {
2650
2675
  // safe presentation query params like the current theme.
2651
2676
  async function handleOpenExternalSurface(req: Request): Promise<Response> {
2652
2677
  const body = await readJson(req);
2678
+ if (body === null) return malformedJsonResponse();
2653
2679
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
2654
2680
  if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
2655
2681
  const node = canvasState.getNode(nodeId);
@@ -2670,6 +2696,7 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
2670
2696
 
2671
2697
  async function handleAxInteraction(req: Request): Promise<Response> {
2672
2698
  const body = await readJson(req);
2699
+ if (body === null) return malformedJsonResponse();
2673
2700
  const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
2674
2701
  for (const e of events) {
2675
2702
  broadcastWorkbenchEvent(e.event, {
@@ -2711,6 +2738,7 @@ function handleAxPolicyGet(): Response {
2711
2738
 
2712
2739
  async function handleAxStatePatch(req: Request): Promise<Response> {
2713
2740
  const body = await readJson(req);
2741
+ if (body === null) return malformedJsonResponse();
2714
2742
  if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
2715
2743
  return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
2716
2744
  }
@@ -5,7 +5,7 @@
5
5
  * agent is ABOUT to make (create / move / connect / remove / edit) so the canvas
6
6
  * can paint a faint placeholder before the real mutation lands. Like a
7
7
  * multiplayer cursor, it auto-expires, is count-capped, and never enters
8
- * `canvas_get_layout`, `state.json`, or snapshots.
8
+ * `canvas_query` (`layout` action), `state.json`, or snapshots.
9
9
  *
10
10
  * This module is import-shared by the server (IntentRegistry + the intent ops)
11
11
  * and the client (intent-store + IntentLayer); it must stay free of any