pmx-canvas 0.3.2 → 0.3.4

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 (70) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/Readme.md +2 -1
  3. package/dist/canvas/index.js +85 -83
  4. package/dist/json-render/index.js +47 -47
  5. package/dist/types/cli/agent.d.ts +13 -8
  6. package/dist/types/cli/commands/apps.d.ts +1 -0
  7. package/dist/types/cli/commands/ax.d.ts +1 -0
  8. package/dist/types/cli/commands/copilot.d.ts +1 -0
  9. package/dist/types/cli/commands/edges.d.ts +1 -0
  10. package/dist/types/cli/commands/groups.d.ts +1 -0
  11. package/dist/types/cli/commands/history.d.ts +1 -0
  12. package/dist/types/cli/commands/nodes.d.ts +1 -0
  13. package/dist/types/cli/commands/pins.d.ts +1 -0
  14. package/dist/types/cli/commands/query.d.ts +1 -0
  15. package/dist/types/cli/commands/view.d.ts +1 -0
  16. package/dist/types/cli/commands/webview.d.ts +1 -0
  17. package/dist/types/cli/daemon.d.ts +17 -0
  18. package/dist/types/cli/shared.d.ts +73 -0
  19. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
  20. package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -0
  21. package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
  22. package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
  23. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
  24. package/dist/types/server/canvas-operations.d.ts +2 -0
  25. package/dist/types/server/diagram-presets.d.ts +8 -0
  26. package/dist/types/server/operations/composites.d.ts +0 -9
  27. package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
  28. package/docs/RELEASE.md +11 -0
  29. package/docs/bun-webview-integration.md +5 -0
  30. package/docs/environment.md +48 -0
  31. package/docs/node-types.md +5 -0
  32. package/docs/plans/plan-009-tech-debt-backlog.md +27 -9
  33. package/docs/screenshot.png +0 -0
  34. package/package.json +8 -5
  35. package/skills/pmx-canvas/SKILL.md +11 -8
  36. package/skills/pmx-canvas/evals/evals.json +5 -5
  37. package/skills/pmx-canvas/references/full-reference.md +8 -1
  38. package/src/cli/agent.ts +22 -3512
  39. package/src/cli/commands/apps.ts +165 -0
  40. package/src/cli/commands/ax.ts +604 -0
  41. package/src/cli/commands/copilot.ts +43 -0
  42. package/src/cli/commands/edges.ts +73 -0
  43. package/src/cli/commands/groups.ts +81 -0
  44. package/src/cli/commands/history.ts +220 -0
  45. package/src/cli/commands/nodes.ts +954 -0
  46. package/src/cli/commands/pins.ts +42 -0
  47. package/src/cli/commands/query.ts +223 -0
  48. package/src/cli/commands/view.ts +186 -0
  49. package/src/cli/commands/webview.ts +227 -0
  50. package/src/cli/daemon.ts +39 -7
  51. package/src/cli/shared.ts +860 -0
  52. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
  53. package/src/client/nodes/ExtAppFrame.tsx +166 -52
  54. package/src/client/nodes/HtmlNode.tsx +8 -51
  55. package/src/client/nodes/McpAppNode.tsx +12 -47
  56. package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
  57. package/src/json-render/renderer/index.tsx +3 -2
  58. package/src/mcp/server.ts +0 -47
  59. package/src/server/bundled-skills.ts +5 -1
  60. package/src/server/canvas-operations.ts +8 -0
  61. package/src/server/diagram-presets.ts +15 -1
  62. package/src/server/html-surface.ts +11 -6
  63. package/src/server/operations/composites.ts +1 -13
  64. package/src/server/operations/invoker.ts +10 -3
  65. package/src/server/operations/mcp.ts +51 -1
  66. package/src/server/operations/ops/app.ts +5 -0
  67. package/src/server/server.ts +61 -3
  68. package/src/shared/ax-surface-protocol.ts +22 -0
  69. package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
  70. package/src/client/canvas/ContextPinHud.tsx +0 -17
package/src/mcp/server.ts CHANGED
@@ -175,53 +175,6 @@ function encodeBase64(bytes: Uint8Array): string {
175
175
  return Buffer.from(bytes).toString('base64');
176
176
  }
177
177
 
178
- function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): boolean {
179
- return input.full === true || input.verbose === true || input.includeData === true;
180
- }
181
-
182
- function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
183
- if (!node) return null;
184
- const serialized = serializeCanvasNode(node);
185
- return {
186
- id: serialized.id,
187
- type: serialized.type,
188
- kind: serialized.kind,
189
- title: serialized.title,
190
- content: serialized.content,
191
- position: serialized.position,
192
- size: serialized.size,
193
- pinned: serialized.pinned,
194
- collapsed: serialized.collapsed,
195
- dockPosition: serialized.dockPosition,
196
- provenance: serialized.provenance,
197
- };
198
- }
199
-
200
- function compactLayoutPayload(
201
- layout: Awaited<ReturnType<CanvasAccess['getLayout']>>,
202
- pinnedIds: string[],
203
- ): Record<string, unknown> {
204
- return {
205
- summary: buildSummaryFromLayout(layout, pinnedIds),
206
- viewport: layout.viewport,
207
- annotations: (layout.annotations ?? []).map((annotation) =>
208
- summarizeCanvasAnnotationForContext(annotation, layout.nodes),
209
- ),
210
- nodes: layout.nodes
211
- .map((node) => compactNodePayload(node))
212
- .filter((node): node is Record<string, unknown> => node !== null),
213
- edges: layout.edges.map((edge) => ({
214
- id: edge.id,
215
- from: edge.from,
216
- to: edge.to,
217
- type: edge.type,
218
- ...(edge.label ? { label: edge.label } : {}),
219
- ...(edge.style ? { style: edge.style } : {}),
220
- ...(edge.animated !== undefined ? { animated: edge.animated } : {}),
221
- })),
222
- };
223
- }
224
-
225
178
  function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
226
179
  return {
227
180
  ...serializeCanvasLayoutForAgent(layout),
@@ -65,7 +65,11 @@ export function findBundledSkillsRoot(): string | null {
65
65
  return null;
66
66
  }
67
67
 
68
- function parseFrontmatterDescription(markdown: string): string {
68
+ function parseFrontmatterDescription(rawMarkdown: string): string {
69
+ // Normalize CRLF first: a git checkout with autocrlf (the Windows default)
70
+ // puts a trailing \r on every line, which `(.*)$` cannot match (JS `.` and
71
+ // `$` both exclude \r), silently emptying every description.
72
+ const markdown = rawMarkdown.replace(/\r\n/g, '\n');
69
73
  // YAML frontmatter lives between two `---` fences at the very top.
70
74
  if (!markdown.startsWith('---')) return '';
71
75
  const end = markdown.indexOf('\n---', 3);
@@ -856,6 +856,14 @@ export function scheduleCodeGraphRecompute(onComplete?: () => void): void {
856
856
  }, 300);
857
857
  }
858
858
 
859
+ /** Clear a pending recompute so the timer cannot outlive its server (start/stop cycles, tests). */
860
+ export function cancelCodeGraphRecompute(): void {
861
+ if (codeGraphTimer) {
862
+ clearTimeout(codeGraphTimer);
863
+ codeGraphTimer = null;
864
+ }
865
+ }
866
+
859
867
  /**
860
868
  * Resolve an html-node `html` field that may be a path to a local .html/.htm file.
861
869
  *
@@ -41,6 +41,20 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
41
41
  url: EXCALIDRAW_MCP_URL,
42
42
  };
43
43
 
44
+ /**
45
+ * The diagram preset targets the hosted Excalidraw MCP server. Tests set
46
+ * PMX_CANVAS_EXCALIDRAW_MCP_COMMAND (a stdio command line, e.g.
47
+ * "bun run tests/fixtures/mcp-app-fixture.ts") so the unit suite never does a
48
+ * real network round-trip — the hosted dependency made diagram tests time out
49
+ * under parallel load (plan-009 M8).
50
+ */
51
+ export function resolveExcalidrawTransport(): ExternalMcpTransportConfig {
52
+ const override = process.env.PMX_CANVAS_EXCALIDRAW_MCP_COMMAND?.trim();
53
+ if (!override) return EXCALIDRAW_MCP_TRANSPORT;
54
+ const [command = override, ...args] = override.split(/\s+/);
55
+ return { type: 'stdio', command, args };
56
+ }
57
+
44
58
  export interface DiagramPresetOpenInput {
45
59
  elements: unknown;
46
60
  nodeId?: string;
@@ -379,7 +393,7 @@ export function ensureExcalidrawCheckpointId(
379
393
  export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): ExcalidrawOpenMcpAppInput {
380
394
  const elements = normalizeExcalidrawElementsForToolInput(input.elements);
381
395
  const out: ExcalidrawOpenMcpAppInput = {
382
- transport: EXCALIDRAW_MCP_TRANSPORT,
396
+ transport: resolveExcalidrawTransport(),
383
397
  toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
384
398
  serverName: EXCALIDRAW_SERVER_NAME,
385
399
  toolArguments: { elements },
@@ -15,6 +15,11 @@
15
15
  * postMessage required.
16
16
  */
17
17
 
18
+ import {
19
+ AX_SURFACE_ACK_SOURCE,
20
+ AX_SURFACE_EMIT_SOURCE,
21
+ HTML_SURFACE_PUSH_SOURCE,
22
+ } from '../shared/ax-surface-protocol.js';
18
23
  import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
19
24
 
20
25
  export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
@@ -54,7 +59,7 @@ function buildThemeBridge(themeToken: string): string {
54
59
  const PMX_CANVAS_THEME_TOKEN = ${token};
55
60
  window.addEventListener('message', (event) => {
56
61
  const message = event.data;
57
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
62
+ if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
58
63
  if (typeof message.theme !== 'string') return;
59
64
  document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
60
65
  document.documentElement.setAttribute('data-theme', message.theme);
@@ -74,12 +79,12 @@ function buildPresentationEscapeBridge(exitToken: string): string {
74
79
  const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
75
80
  document.addEventListener('keydown', (event) => {
76
81
  if (event.key === 'Escape') {
77
- window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
82
+ window.parent.postMessage({ source: '${HTML_SURFACE_PUSH_SOURCE}', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
78
83
  }
79
84
  }, true);
80
85
  window.addEventListener('message', (event) => {
81
86
  const message = event.data;
82
- if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
87
+ if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
83
88
  if (typeof message.key !== 'string') return;
84
89
  if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
85
90
  window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
@@ -131,7 +136,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
131
136
  seq += 1;
132
137
  const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
133
138
  window.parent.postMessage({
134
- source: 'pmx-canvas-ax',
139
+ source: '${AX_SURFACE_EMIT_SOURCE}',
135
140
  token: PMX_AX_TOKEN,
136
141
  nodeId: PMX_AX_NODE_ID,
137
142
  correlationId: correlationId,
@@ -152,7 +157,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
152
157
  };
153
158
  window.addEventListener('message', function (event) {
154
159
  const m = event.data;
155
- if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
160
+ if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
156
161
  const result = m.result || { ok: false };
157
162
  const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
158
163
  if (resolver) { pending.delete(m.correlationId); resolver(result); }
@@ -182,7 +187,7 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
182
187
  window.PMX_AX.state = ${snapshotJson};
183
188
  window.addEventListener('message', function (event) {
184
189
  const m = event.data;
185
- if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
190
+ if (!m || m.source !== '${HTML_SURFACE_PUSH_SOURCE}' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
186
191
  window.PMX_AX.state = m.state;
187
192
  try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
188
193
  });
@@ -94,12 +94,6 @@ export interface CompositeToolDefinition {
94
94
  * raise a loud error instead of silently no-op'ing.
95
95
  */
96
96
  resolveOp?: (input: { kind: string; action: string }) => string | undefined;
97
- /**
98
- * Two-discriminator extension: human-readable `(kind, action)` for a member op
99
- * (the inverse of `resolveOp`), used to build that op's deprecation note. The
100
- * `kind` field-collision is resolved here (see `gateFieldRemap`).
101
- */
102
- describeOp?: (opName: string) => { kind: string; action: string } | undefined;
103
97
  /**
104
98
  * Field-name remap applied to the composite's advertised schema and undone at
105
99
  * dispatch. Resolves a collision between a discriminator name and a member-op
@@ -131,7 +125,7 @@ function resolveGateOp(input: { kind: string; action: string }): string | undefi
131
125
  return undefined;
132
126
  }
133
127
 
134
- /** The 9 gate ops, in (kind, action) order — single source for memberOps + describeOp. */
128
+ /** The 9 gate ops, in (kind, action) order — single source for memberOps. */
135
129
  const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS.flatMap((kind) =>
136
130
  GATE_ACTIONS.map((action) => {
137
131
  const op = resolveGateOp({ kind, action });
@@ -143,11 +137,6 @@ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS
143
137
  }),
144
138
  );
145
139
 
146
- function describeGateOp(opName: string): { kind: string; action: string } | undefined {
147
- const match = GATE_OPS.find((entry) => entry.op === opName);
148
- return match ? { kind: match.kind, action: match.action } : undefined;
149
- }
150
-
151
140
  export const compositeToolDefinitions: CompositeToolDefinition[] = [
152
141
  {
153
142
  toolName: 'canvas_node',
@@ -289,7 +278,6 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
289
278
  },
290
279
  memberOps: GATE_OPS.map((entry) => entry.op),
291
280
  resolveOp: resolveGateOp,
292
- describeOp: describeGateOp,
293
281
  // ax.approval.request defines its own optional `action` field (a
294
282
  // machine-readable identifier the gate guards) — it collides with the gate
295
283
  // lifecycle `action` discriminator. Namespace it to `approvalAction` in the
@@ -68,19 +68,26 @@ export class HttpOperationInvoker implements OperationInvoker {
68
68
  const response = await fetch(url, init);
69
69
  const text = await response.text();
70
70
  let parsed: unknown = null;
71
+ let parsedAsJson = false;
71
72
  if (text.length > 0) {
72
73
  try {
73
74
  parsed = JSON.parse(text) as unknown;
75
+ parsedAsJson = true;
74
76
  } catch {
75
77
  parsed = { error: text };
76
78
  }
77
79
  }
78
80
  if (!response.ok) {
79
- if (route.errorBodyAsResult) return parsed;
81
+ // errorBodyAsResult carries the route's STRUCTURED partial-failure
82
+ // envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
83
+ // a result. A non-JSON error body (an HTML 502 from a proxy, a foreign
84
+ // service) is not that envelope — fall through to the throw path so the
85
+ // caller still fails loudly with a nonzero exit.
86
+ if (route.errorBodyAsResult && parsedAsJson) return parsed;
80
87
  const message =
81
- parsed !== null && typeof parsed === 'object' && 'error' in parsed
88
+ parsedAsJson && parsed !== null && typeof parsed === 'object' && 'error' in parsed
82
89
  ? String((parsed as { error?: unknown }).error)
83
- : `HTTP ${response.status}`;
90
+ : `HTTP ${response.status}${text.length > 0 ? `: ${text.slice(0, 200)}` : ''}`;
84
91
  throw new OperationError(message, toOperationErrorStatus(response.status));
85
92
  }
86
93
  return parsed;
@@ -11,7 +11,7 @@ import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
11
11
  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
- import { OperationError, type OperationMcpToolHost } from './types.js';
14
+ import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
15
15
  import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
16
16
 
17
17
  export interface OperationToolHost extends OperationMcpToolHost {
@@ -176,6 +176,43 @@ function stripCompositeDiscriminators(
176
176
  return out;
177
177
  }
178
178
 
179
+ /**
180
+ * Per-op cache of the MCP arg names its extraShape marks REQUIRED (a schema
181
+ * that rejects `undefined`). These are the standalone tools' old contracts,
182
+ * which buildCompositeShape flattens to all-optional — the composite handler
183
+ * re-enforces them at dispatch so a missing required field fails loudly and
184
+ * names the field, instead of failing deep in the handler or silently
185
+ * no-oping (the `q`-vs-`query` zero-results trap from the 0.3.1 test report).
186
+ */
187
+ const requiredFieldsCache = new WeakMap<object, string[]>();
188
+
189
+ // Advertised input aliases that satisfy a required extraShape field: the op's
190
+ // handler accepts either name, so the pre-dispatch check must too. (The only
191
+ // such collision across all 15 composites — edges.ts advertises `edge_id` as a
192
+ // legacy alias for `id` and its handler reads `edge_id ?? id`.)
193
+ const REQUIRED_FIELD_ALIASES: Record<string, Record<string, readonly string[]>> = {
194
+ 'edge.remove': { id: ['edge_id'] },
195
+ };
196
+
197
+ function requiredFieldSatisfied(opName: string, field: string, rest: Record<string, unknown>): boolean {
198
+ if (rest[field] !== undefined) return true;
199
+ const aliases = REQUIRED_FIELD_ALIASES[opName]?.[field] ?? [];
200
+ return aliases.some((alias) => rest[alias] !== undefined);
201
+ }
202
+
203
+ function requiredMcpFields(op: Operation): string[] {
204
+ const tool = op.mcp;
205
+ if (!tool?.extraShape) return [];
206
+ let keys = requiredFieldsCache.get(tool);
207
+ if (!keys) {
208
+ keys = Object.entries(tool.extraShape)
209
+ .filter(([, schema]) => !(schema as ZodTypeAny).safeParse(undefined).success)
210
+ .map(([key]) => key);
211
+ requiredFieldsCache.set(tool, keys);
212
+ }
213
+ return keys;
214
+ }
215
+
179
216
  /**
180
217
  * Register composite (action-discriminated) MCP tools (plan-006). Each action
181
218
  * dispatches to a registered operation, reusing that op's `mcp.buildInput` and
@@ -188,6 +225,12 @@ export function registerCompositeTools(
188
225
  definitions: CompositeToolDefinition[] = compositeToolDefinitions,
189
226
  ): void {
190
227
  for (const def of definitions) {
228
+ // Public composite name for each op field (inverse of fieldRemap), so
229
+ // required-field errors name the argument the agent actually sends.
230
+ const publicFieldName = new Map<string, string>();
231
+ for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
232
+ publicFieldName.set(opField, publicKey);
233
+ }
191
234
  server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
192
235
  try {
193
236
  const host = await getHost();
@@ -197,6 +240,13 @@ export function registerCompositeTools(
197
240
  // and undo any field remap; the rest is the op's raw MCP args — the same
198
241
  // value the standalone tool would receive.
199
242
  const rest = stripCompositeDiscriminators(def, input);
243
+ const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
244
+ if (missing.length > 0) {
245
+ const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
246
+ throw new OperationError(
247
+ `${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
248
+ );
249
+ }
200
250
  const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
201
251
  const result = await host.invoker().invoke(opName, opInput);
202
252
  if (op.mcp?.formatResult) {
@@ -405,6 +405,11 @@ const webArtifactOperation = defineOperation<z.infer<typeof webArtifactSchema>,
405
405
  http: {
406
406
  method: 'POST',
407
407
  path: '/api/canvas/web-artifact',
408
+ // Build failures throw OperationError, served as { ok:false, error }. The
409
+ // CLI prints that failure envelope on STDOUT and exits 1
410
+ // (cli-node.test.ts:2502-2548 pins it), so the HTTP invoker must return
411
+ // the non-2xx body instead of throwing — mirrors canvas.batch/webview.start.
412
+ errorBodyAsResult: true,
408
413
  },
409
414
  mcp: {
410
415
  toolName: 'canvas_build_web_artifact',
@@ -61,6 +61,7 @@ import { resolveNodeAxCapabilities } from './ax-interaction.js';
61
61
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
62
62
  import { validateLocalImageFile } from './image-source.js';
63
63
  import {
64
+ cancelCodeGraphRecompute,
64
65
  primeCanvasRuntimeBackends,
65
66
  setCanvasLayoutUpdateEmitter,
66
67
  syncCanvasRuntimeBackends,
@@ -1120,6 +1121,44 @@ function handleFrameDocument(pathname: string): Response {
1120
1121
  });
1121
1122
  }
1122
1123
 
1124
+ // ── Ext-app recovery diagnostics (0.3.2 report Finding N) ──────
1125
+ //
1126
+ // The WebKit black-tile recovery trail is otherwise trapped inside host panels
1127
+ // without devtools (the Copilot WKWebView). Clients mirror their bounded
1128
+ // recovery log here; agents/testers read it back over HTTP. In-memory only,
1129
+ // diagnostics-grade: no persistence, no snapshots.
1130
+ const EXT_APP_RECOVERY_LOG_MAX = 500;
1131
+ const EXT_APP_RECOVERY_FIELD_MAX = 300;
1132
+ const extAppRecoveryEntries: Array<Record<string, unknown>> = [];
1133
+
1134
+ /**
1135
+ * Clamp a reported entry to the known trail shape ({ t, nodeId, event,
1136
+ * visibility }) with bounded string lengths. The count trim below caps the
1137
+ * number of entries; this caps bytes per entry, so the in-memory log stays
1138
+ * diagnostics-sized no matter what a client posts.
1139
+ */
1140
+ function clampExtAppRecoveryEntry(entry: Record<string, unknown>): Record<string, unknown> {
1141
+ const clamped: Record<string, unknown> = {};
1142
+ if (typeof entry.t === 'number' && Number.isFinite(entry.t)) clamped.t = entry.t;
1143
+ for (const key of ['nodeId', 'event', 'visibility'] as const) {
1144
+ const value = entry[key];
1145
+ if (typeof value === 'string') clamped[key] = value.slice(0, EXT_APP_RECOVERY_FIELD_MAX);
1146
+ }
1147
+ return clamped;
1148
+ }
1149
+
1150
+ function handleExtAppRecoveryReport(entries: unknown): Response {
1151
+ if (!Array.isArray(entries)) return responseJson({ ok: false, error: 'entries must be an array' }, 400);
1152
+ for (const entry of entries.slice(0, 100)) {
1153
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
1154
+ extAppRecoveryEntries.push(clampExtAppRecoveryEntry(entry as Record<string, unknown>));
1155
+ }
1156
+ if (extAppRecoveryEntries.length > EXT_APP_RECOVERY_LOG_MAX) {
1157
+ extAppRecoveryEntries.splice(0, extAppRecoveryEntries.length - EXT_APP_RECOVERY_LOG_MAX);
1158
+ }
1159
+ return responseJson({ ok: true, total: extAppRecoveryEntries.length });
1160
+ }
1161
+
1123
1162
  // ── Node surfaces ("Open as site") ─────────────────────────────
1124
1163
  //
1125
1164
  // One stable, node-addressable URL — /api/canvas/surface/:nodeId — that serves
@@ -2843,7 +2882,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2843
2882
  const url = new URL(req.url);
2844
2883
 
2845
2884
  if (url.pathname === '/health') {
2846
- return responseJson({ ok: true, workspace: activeWorkspaceRoot });
2885
+ // `pid` lets `serve status` report the ACTUAL serving process even
2886
+ // when the pid file is stale (e.g. an adapter respawned the server
2887
+ // outside `serve --daemon` — 0.3.2 report Finding P).
2888
+ return responseJson({ ok: true, workspace: activeWorkspaceRoot, pid: process.pid });
2847
2889
  }
2848
2890
 
2849
2891
  if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
@@ -2854,10 +2896,25 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2854
2896
  return handleArtifactView(url);
2855
2897
  }
2856
2898
 
2857
- if (url.pathname === '/api/canvas/json-render/view' && req.method === 'GET') {
2899
+ // Standalone surfaces answer HEAD like GET (status + headers, no body):
2900
+ // tooling probes these advertised URLs, and a HEAD 404 against a
2901
+ // working GET reads as a broken surface (0.3.2 report Finding O).
2902
+ // Bun.serve strips the body and keeps the true Content-Length on HEAD,
2903
+ // so the GET handler's response is returned as-is.
2904
+ if (url.pathname === '/api/canvas/json-render/view' && (req.method === 'GET' || req.method === 'HEAD')) {
2858
2905
  return handleJsonRenderView(url);
2859
2906
  }
2860
2907
 
2908
+ if (url.pathname === '/api/canvas/debug/ext-app-recovery' && req.method === 'POST') {
2909
+ const body = await readJson(req);
2910
+ if (body === null) return malformedJsonResponse();
2911
+ return handleExtAppRecoveryReport(body.entries);
2912
+ }
2913
+
2914
+ if (url.pathname === '/api/canvas/debug/ext-app-recovery' && req.method === 'GET') {
2915
+ return responseJson({ ok: true, entries: extAppRecoveryEntries });
2916
+ }
2917
+
2861
2918
  if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
2862
2919
  return handleCreateFrameDocument(req);
2863
2920
  }
@@ -2866,7 +2923,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2866
2923
  return handleFrameDocument(url.pathname);
2867
2924
  }
2868
2925
 
2869
- if (url.pathname.startsWith('/api/canvas/surface/') && req.method === 'GET') {
2926
+ if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) {
2870
2927
  return handleNodeSurface(url.pathname, url);
2871
2928
  }
2872
2929
 
@@ -2959,6 +3016,7 @@ export function stopCanvasServer(): void {
2959
3016
  intentRegistry.reset();
2960
3017
  canvasState.close();
2961
3018
  closeAllMcpAppSessions();
3019
+ cancelCodeGraphRecompute();
2962
3020
  setCanvasLayoutUpdateEmitter(null);
2963
3021
  void closeCanvasAutomationWebViewInternal().catch((error) => {
2964
3022
  logWorkbenchWarning('stopCanvasServer closeCanvasAutomationWebViewInternal', error);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wire-protocol source tags for sandboxed-surface ↔ canvas postMessage traffic
3
+ * (plan-009 M2): one module owns the strings that used to be spread across the
4
+ * client node components, the server-side surface script generators, the
5
+ * ext-app script builders, and the json-render viewer. These are the trust
6
+ * boundary between opaque-origin iframes and the canvas — a typo'd tag fails
7
+ * SILENTLY (the listener just ignores the message), so single-siting them is a
8
+ * correctness measure, not cosmetics. The values are a persisted wire contract
9
+ * with already-generated surface documents: never change them.
10
+ */
11
+
12
+ /** A sandboxed surface emitting an AX interaction to the parent canvas. */
13
+ export const AX_SURFACE_EMIT_SOURCE = 'pmx-canvas-ax';
14
+
15
+ /** The parent canvas acking an AX interaction back to the emitting surface (#55). */
16
+ export const AX_SURFACE_ACK_SOURCE = 'pmx-canvas-ax-ack';
17
+
18
+ /** Parent→surface pushes (theme updates, live AX state) into html/viewer iframes. */
19
+ export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
20
+
21
+ /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
22
+ export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
@@ -1 +0,0 @@
1
- export declare function ContextPinHud(): import("preact/src").JSX.Element | null;
@@ -1,17 +0,0 @@
1
- import { clearContextPins, contextPinnedNodeIds } from '../state/canvas-store';
2
-
3
- export function ContextPinHud() {
4
- const count = contextPinnedNodeIds.value.size;
5
- if (count === 0) return null;
6
-
7
- return (
8
- <div class="context-pin-hud">
9
- <span class="context-pin-hud-label">
10
- {'\u2726'} {count} in context
11
- </span>
12
- <button type="button" class="context-pin-hud-clear" onClick={clearContextPins} title="Clear all context pins">
13
- ×
14
- </button>
15
- </div>
16
- );
17
- }