pmx-canvas 0.2.6 → 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 (59) hide show
  1. package/CHANGELOG.md +156 -1
  2. package/Readme.md +13 -11
  3. package/dist/canvas/index.js +18 -18
  4. package/dist/json-render/index.css +1 -1
  5. package/dist/types/mcp/canvas-access.d.ts +23 -7
  6. package/dist/types/server/ax-state-manager.d.ts +3 -2
  7. package/dist/types/server/bundled-skills.d.ts +3 -3
  8. package/dist/types/server/canvas-state.d.ts +2 -0
  9. package/dist/types/server/ext-app-lookup.d.ts +1 -3
  10. package/dist/types/server/operations/composites.d.ts +29 -23
  11. package/dist/types/server/operations/http.d.ts +2 -1
  12. package/dist/types/shared/ax-intent.d.ts +1 -1
  13. package/docs/RELEASE.md +23 -8
  14. package/docs/ax-host-adapter-contract.md +7 -7
  15. package/docs/ax-state-contract.md +7 -6
  16. package/docs/bun-webview-integration.md +5 -5
  17. package/docs/cli.md +1 -1
  18. package/docs/http-api.md +7 -3
  19. package/docs/mcp.md +121 -100
  20. package/docs/node-types.md +45 -35
  21. package/docs/plans/plan-006-mcp-tool-consolidation.md +3 -1
  22. package/docs/plans/plan-008-registry-finish.md +2 -0
  23. package/docs/plans/plan-009-tech-debt-backlog.md +51 -0
  24. package/docs/screenshot.png +0 -0
  25. package/docs/tech-debt-assessment-2026-06.md +1 -1
  26. package/docs/tech-debt-assessment-2026-07.md +135 -0
  27. package/package.json +1 -1
  28. package/skills/data-analysis/SKILL.md +3 -3
  29. package/skills/frontend-design/SKILL.md +3 -2
  30. package/skills/json-render-mcp/SKILL.md +4 -3
  31. package/skills/playwright-cli/SKILL.md +1 -1
  32. package/skills/pmx-canvas/SKILL.md +20 -15
  33. package/skills/pmx-canvas/evals/evals.json +2 -2
  34. package/skills/pmx-canvas/references/ax-html-control-surface.md +3 -1
  35. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +2 -2
  36. package/skills/pmx-canvas/references/full-reference.md +65 -62
  37. package/skills/pmx-canvas/references/html-primitives.md +3 -3
  38. package/skills/tufte-viz/SKILL.md +5 -4
  39. package/skills/web-artifacts-builder/SKILL.md +4 -4
  40. package/src/client/App.tsx +2 -1
  41. package/src/mcp/canvas-access.ts +115 -107
  42. package/src/mcp/server.ts +74 -201
  43. package/src/server/ax-state-manager.ts +3 -2
  44. package/src/server/bundled-skills.ts +3 -3
  45. package/src/server/canvas-schema.ts +46 -29
  46. package/src/server/canvas-state.ts +5 -0
  47. package/src/server/code-graph.ts +1 -1
  48. package/src/server/ext-app-lookup.ts +1 -8
  49. package/src/server/index.ts +1 -1
  50. package/src/server/intent-registry.ts +1 -1
  51. package/src/server/operations/composites.ts +39 -39
  52. package/src/server/operations/http.ts +3 -2
  53. package/src/server/operations/mcp.ts +7 -6
  54. package/src/server/operations/ops/app.ts +4 -4
  55. package/src/server/operations/ops/nodes.ts +5 -5
  56. package/src/server/operations/ops/query.ts +19 -8
  57. package/src/server/operations/ops/snapshots.ts +6 -6
  58. package/src/server/server.ts +40 -4
  59. 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
  },
@@ -422,7 +422,10 @@ async function withCanvasAutomationWebViewTimeout<T>(task: Promise<T>, action: s
422
422
  reject(
423
423
  new Error(
424
424
  `Timed out after ${getCanvasAutomationWebViewTimeoutMs()}ms while ${action}. ` +
425
- 'Bun.WebView may be unavailable in this environment.',
425
+ 'The Bun.WebView backend may be slow to launch or unavailable in this environment ' +
426
+ '(the chrome backend is known-flaky on some hosts). On macOS prefer the webkit ' +
427
+ 'backend (start --backend webkit), or raise PMX_CANVAS_WEBVIEW_TIMEOUT_MS for a ' +
428
+ 'slow-but-available backend.',
426
429
  ),
427
430
  );
428
431
  }, getCanvasAutomationWebViewTimeoutMs());
@@ -917,7 +920,8 @@ function rotatePrimaryWorkbenchSessionIfNeeded(): void {
917
920
  primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
918
921
  }
919
922
 
920
- 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> {
921
925
  let text = '';
922
926
  try {
923
927
  text = await req.text();
@@ -934,10 +938,14 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
934
938
  return value as Record<string, unknown>;
935
939
  } catch (error) {
936
940
  logWorkbenchWarning('readJson', error);
937
- return {};
941
+ return null;
938
942
  }
939
943
  }
940
944
 
945
+ function malformedJsonResponse(): Response {
946
+ return responseJson({ ok: false, error: 'Malformed JSON body.' }, 400);
947
+ }
948
+
941
949
  function htmlEscape(value: string): string {
942
950
  return value
943
951
  .replaceAll('&', '&amp;')
@@ -1225,6 +1233,7 @@ function addFrameDocument(html: string, sandbox: string): string {
1225
1233
 
1226
1234
  async function handleCreateFrameDocument(req: Request): Promise<Response> {
1227
1235
  const body = await readJson(req);
1236
+ if (body === null) return malformedJsonResponse();
1228
1237
  const html = body.html;
1229
1238
  if (typeof html !== 'string' || !html) {
1230
1239
  return responseJson({ ok: false, error: 'Frame document requires non-empty html.' }, 400);
@@ -1367,6 +1376,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1367
1376
 
1368
1377
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1369
1378
  const body = await readJson(req);
1379
+ if (body === null) return malformedJsonResponse();
1370
1380
  const updates = Array.isArray(body.updates) ? body.updates : [];
1371
1381
  const result = body.recordHistory === false
1372
1382
  ? (() => {
@@ -1385,6 +1395,7 @@ async function handleCanvasUpdate(req: Request): Promise<Response> {
1385
1395
 
1386
1396
  async function handleCanvasViewport(req: Request): Promise<Response> {
1387
1397
  const body = await readJson(req);
1398
+ if (body === null) return malformedJsonResponse();
1388
1399
  const next = {
1389
1400
  x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
1390
1401
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
@@ -1435,6 +1446,7 @@ function parseAnnotationPoints(value: unknown): CanvasAnnotation['points'] {
1435
1446
 
1436
1447
  async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
1437
1448
  const body = await readJson(req);
1449
+ if (body === null) return malformedJsonResponse();
1438
1450
  const type = body.type === 'text' ? 'text' : 'freehand';
1439
1451
  const points = parseAnnotationPoints(body.points);
1440
1452
  if (points.length < (type === 'text' ? 1 : 2)) {
@@ -1521,6 +1533,7 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
1521
1533
  }
1522
1534
 
1523
1535
  const body = await readJson(req);
1536
+ if (body === null) return malformedJsonResponse();
1524
1537
  const rawUrl = typeof body.url === 'string' ? body.url : undefined;
1525
1538
  let url: string | undefined;
1526
1539
  if (rawUrl && rawUrl.trim().length > 0) {
@@ -1541,6 +1554,7 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
1541
1554
 
1542
1555
  async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
1543
1556
  const body = await readJson(req);
1557
+ if (body === null) return malformedJsonResponse();
1544
1558
  const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
1545
1559
  const next = canvasState.setTheme(theme);
1546
1560
  broadcastWorkbenchEvent('theme-changed', {
@@ -1820,6 +1834,7 @@ function handleRead(pathLike: string): Response {
1820
1834
 
1821
1835
  async function handleExtAppCallTool(req: Request): Promise<Response> {
1822
1836
  const body = await readJson(req);
1837
+ if (body === null) return malformedJsonResponse();
1823
1838
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1824
1839
  const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
1825
1840
  if (!sessionId || !toolName) {
@@ -1890,6 +1905,7 @@ async function handleExtAppCallTool(req: Request): Promise<Response> {
1890
1905
 
1891
1906
  async function handleExtAppReadResource(req: Request): Promise<Response> {
1892
1907
  const body = await readJson(req);
1908
+ if (body === null) return malformedJsonResponse();
1893
1909
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1894
1910
  const uri = typeof body.uri === 'string' ? body.uri.trim() : '';
1895
1911
  if (!sessionId || !uri) {
@@ -1906,6 +1922,7 @@ async function handleExtAppReadResource(req: Request): Promise<Response> {
1906
1922
 
1907
1923
  async function handleExtAppListTools(req: Request): Promise<Response> {
1908
1924
  const body = await readJson(req);
1925
+ if (body === null) return malformedJsonResponse();
1909
1926
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1910
1927
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1911
1928
 
@@ -1919,6 +1936,7 @@ async function handleExtAppListTools(req: Request): Promise<Response> {
1919
1936
 
1920
1937
  async function handleExtAppListResources(req: Request): Promise<Response> {
1921
1938
  const body = await readJson(req);
1939
+ if (body === null) return malformedJsonResponse();
1922
1940
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1923
1941
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1924
1942
 
@@ -1932,6 +1950,7 @@ async function handleExtAppListResources(req: Request): Promise<Response> {
1932
1950
 
1933
1951
  async function handleExtAppListResourceTemplates(req: Request): Promise<Response> {
1934
1952
  const body = await readJson(req);
1953
+ if (body === null) return malformedJsonResponse();
1935
1954
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1936
1955
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1937
1956
 
@@ -1945,6 +1964,7 @@ async function handleExtAppListResourceTemplates(req: Request): Promise<Response
1945
1964
 
1946
1965
  async function handleExtAppListPrompts(req: Request): Promise<Response> {
1947
1966
  const body = await readJson(req);
1967
+ if (body === null) return malformedJsonResponse();
1948
1968
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
1949
1969
  if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
1950
1970
 
@@ -1958,6 +1978,7 @@ async function handleExtAppListPrompts(req: Request): Promise<Response> {
1958
1978
 
1959
1979
  async function handleExtAppModelContext(req: Request): Promise<Response> {
1960
1980
  const body = await readJson(req);
1981
+ if (body === null) return malformedJsonResponse();
1961
1982
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
1962
1983
  if (!nodeId) return responseJson({ ok: false, error: 'Missing nodeId.' }, 400);
1963
1984
 
@@ -2030,6 +2051,7 @@ function currentWorkbenchUrl(): string | null {
2030
2051
 
2031
2052
  async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response> {
2032
2053
  const body = await readJson(req);
2054
+ if (body === null) return malformedJsonResponse();
2033
2055
  const format = body.format === 'jpeg' || body.format === 'webp' || body.format === 'png'
2034
2056
  ? body.format
2035
2057
  : 'png';
@@ -2060,6 +2082,7 @@ async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response>
2060
2082
 
2061
2083
  async function handleWorkbenchOpen(req: Request): Promise<Response> {
2062
2084
  const body = await readJson(req);
2085
+ if (body === null) return malformedJsonResponse();
2063
2086
  const pathLike = typeof body.path === 'string' ? body.path : '';
2064
2087
  const safePath = resolveWorkspaceMarkdownPath(pathLike);
2065
2088
  if (!safePath) return responseText('Invalid path', 400);
@@ -2071,6 +2094,7 @@ async function handleWorkbenchOpen(req: Request): Promise<Response> {
2071
2094
 
2072
2095
  async function handleWorkbenchIntent(req: Request): Promise<Response> {
2073
2096
  const body = await readJson(req);
2097
+ if (body === null) return malformedJsonResponse();
2074
2098
  const rawType = typeof body.type === 'string' ? body.type.trim() : '';
2075
2099
  if (!rawType) return responseText('Missing intent type', 400);
2076
2100
  if (!ALLOWED_WORKBENCH_INTENTS.has(rawType as PrimaryWorkbenchIntent['type'])) {
@@ -2231,6 +2255,7 @@ function handleWorkbenchEvents(req: Request): Response {
2231
2255
 
2232
2256
  async function handleSave(req: Request): Promise<Response> {
2233
2257
  const body = await readJson(req);
2258
+ if (body === null) return malformedJsonResponse();
2234
2259
  const pathLike = typeof body.path === 'string' ? body.path : '';
2235
2260
  const safePath = resolveWorkspaceMarkdownPath(pathLike);
2236
2261
  if (!safePath) return responseText('Invalid path', 400);
@@ -2247,6 +2272,7 @@ async function handleSave(req: Request): Promise<Response> {
2247
2272
 
2248
2273
  async function handleRender(req: Request): Promise<Response> {
2249
2274
  const body = await readJson(req);
2275
+ if (body === null) return malformedJsonResponse();
2250
2276
  const markdown = typeof body.markdown === 'string' ? body.markdown : '';
2251
2277
  const html =
2252
2278
  (marked.parse(normalizeMarkdownExternalUrls(markdown), {
@@ -2268,6 +2294,7 @@ function buildSelectionContextPreamble(contextNodeIds: string[]): string {
2268
2294
 
2269
2295
  async function handleCanvasPrompt(req: Request): Promise<Response> {
2270
2296
  const body = await readJson(req);
2297
+ if (body === null) return malformedJsonResponse();
2271
2298
  const text = typeof body.text === 'string' ? body.text.trim() : '';
2272
2299
  if (!text) return responseText('Missing prompt text', 400);
2273
2300
 
@@ -2601,6 +2628,7 @@ function normalizeActivityReactions(input: Record<string, unknown>): {
2601
2628
  // Report primitive A: ingest a harness-forwarded agent activity; the board auto-reacts.
2602
2629
  async function handleAxActivityIngest(req: Request): Promise<Response> {
2603
2630
  const body = await readJson(req);
2631
+ if (body === null) return malformedJsonResponse();
2604
2632
  if (!isAxActivityKind(body.kind)) {
2605
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);
2606
2634
  }
@@ -2647,6 +2675,7 @@ function handleGetAxSurfaceSnapshot(): Response {
2647
2675
  // safe presentation query params like the current theme.
2648
2676
  async function handleOpenExternalSurface(req: Request): Promise<Response> {
2649
2677
  const body = await readJson(req);
2678
+ if (body === null) return malformedJsonResponse();
2650
2679
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
2651
2680
  if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
2652
2681
  const node = canvasState.getNode(nodeId);
@@ -2667,6 +2696,7 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
2667
2696
 
2668
2697
  async function handleAxInteraction(req: Request): Promise<Response> {
2669
2698
  const body = await readJson(req);
2699
+ if (body === null) return malformedJsonResponse();
2670
2700
  const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
2671
2701
  for (const e of events) {
2672
2702
  broadcastWorkbenchEvent(e.event, {
@@ -2708,6 +2738,7 @@ function handleAxPolicyGet(): Response {
2708
2738
 
2709
2739
  async function handleAxStatePatch(req: Request): Promise<Response> {
2710
2740
  const body = await readJson(req);
2741
+ if (body === null) return malformedJsonResponse();
2711
2742
  if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
2712
2743
  return responseJson({ ok: false, error: 'PATCH /api/canvas/ax currently requires a focus object.' }, 400);
2713
2744
  }
@@ -3482,7 +3513,12 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3482
3513
  return typeof server.port === 'number' ? loopbackBaseUrl(server.port) : null;
3483
3514
  }
3484
3515
 
3485
- const workspaceRoot = options.workspaceRoot ?? process.cwd();
3516
+ // An explicit `options.workspaceRoot` wins. Otherwise honor PMX_CANVAS_WORKSPACE_ROOT
3517
+ // (Finding I escape hatch) before falling back to the launch cwd, so a host that
3518
+ // spawns from an incidental dir (e.g. ~/.copilot) can still pin the real project root
3519
+ // for the daemon it binds — not just for the MCP same-workspace lookup.
3520
+ const envWorkspaceRoot = process.env.PMX_CANVAS_WORKSPACE_ROOT?.trim();
3521
+ const workspaceRoot = options.workspaceRoot ?? (envWorkspaceRoot ? resolve(envWorkspaceRoot) : process.cwd());
3486
3522
  activeWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot);
3487
3523
  if (options.autoOpenBrowser !== undefined) {
3488
3524
  primaryWorkbenchAutoOpenEnabled = options.autoOpenBrowser;
@@ -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