pmx-canvas 0.3.1 → 0.3.3

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.
@@ -59,24 +59,77 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
59
59
  return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
60
60
  }
61
61
 
62
- // Finding F (0.2.5): SERIALIZED WebKit repaint slots. The black tile is a cold-
63
- // hydration BURST problem — a single ext-app repaints fine into an idle panel (like
64
- // a live-created node, or expand+close), but several compositing at once overwhelm
65
- // WebKit and all stay black. We give each mounting ext-app an increasing slot so its
66
- // one-time repaint remount fires into a progressively-quieter panel (one app at a
67
- // time), turning the burst back into a sequence of single repaints. The shared
68
- // counter resets after the burst so a later board load starts from slot 0.
69
- let webkitRepaintSlot = 0;
70
- let webkitRepaintSlotResetTimer: ReturnType<typeof setTimeout> | null = null;
71
- export function nextWebkitRepaintSlot(): number {
72
- const slot = webkitRepaintSlot++;
73
- if (typeof window !== 'undefined') {
74
- if (webkitRepaintSlotResetTimer) clearTimeout(webkitRepaintSlotResetTimer);
75
- webkitRepaintSlotResetTimer = setTimeout(() => {
76
- webkitRepaintSlot = 0;
77
- }, 3000);
78
- }
79
- return slot;
62
+ // Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
63
+ // The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
64
+ // into an idle panel (like a live-created node, or expand+close), but several
65
+ // compositing at once overwhelm WebKit and all stay black. The 0.2.5 fixed-stagger
66
+ // slots (450ms apart) were NOT boot-aware: each recovery remount reboots the app
67
+ // (~1-2s for Excalidraw), so N staggered remounts overlapped into a fresh burst and
68
+ // the per-node one-shot flag was spent exactly the multi-app reload blackout the
69
+ // 0.3.1 report shows. This queue runs remounts strictly one at a time: each entry
70
+ // performs its remount, then waits for that app's GENUINE initialized handshake
71
+ // (or a bounded timeout for an app that never boots) plus a settle delay before the
72
+ // next entry fires — so every remount lands in an idle panel, which is the
73
+ // empirically always-successful recovery (what expand+close does manually).
74
+ // Settle covers the scene DRAW: the app's initialized handshake fires before the
75
+ // replayed tool result arrives and the scene is painted, so the queue waits for the
76
+ // settled signal (bootstrap chain incl. tool-result send complete) plus this pause.
77
+ export const WEBKIT_REMOUNT_SETTLE_MS = 1000;
78
+ const WEBKIT_BOOT_TIMEOUT_MS = 7000;
79
+
80
+ export interface WebkitRemountTask {
81
+ /** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
82
+ remount: () => boolean;
83
+ /** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
84
+ awaitBoot: () => Promise<void>;
85
+ }
86
+
87
+ // Bounded recovery trail readable via `webview evaluate` / devtools
88
+ // (window.__PMX_EXTAPP_LOG). The WebKit compositor dropout is otherwise
89
+ // unobservable from page JS — this is the only diagnostic surface.
90
+ export function extAppRecoveryLog(nodeId: string, event: string): void {
91
+ if (typeof window === 'undefined') return;
92
+ const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
93
+ host.__PMX_EXTAPP_LOG ??= [];
94
+ if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
95
+ queueExtAppRecoveryReport({ t: Date.now(), nodeId, event });
96
+ }
97
+
98
+ // Mirror the recovery trail to the daemon (batched, fire-and-forget) so a host
99
+ // panel without devtools access — the Copilot WKWebView, exactly where the
100
+ // black tiles happen — still yields an actionable trace: agents read it back
101
+ // via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
102
+ let extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
103
+ let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
104
+ function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
105
+ extAppReportQueue.push({
106
+ ...entry,
107
+ ...(typeof document !== 'undefined' ? { visibility: document.visibilityState } : {}),
108
+ });
109
+ if (extAppReportTimer) return;
110
+ extAppReportTimer = setTimeout(() => {
111
+ extAppReportTimer = null;
112
+ const entries = extAppReportQueue.splice(0);
113
+ if (entries.length === 0) return;
114
+ void fetch('/api/canvas/debug/ext-app-recovery', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ entries }),
118
+ }).catch(() => {
119
+ // Diagnostics only — never let reporting failures affect recovery.
120
+ });
121
+ }, 1500);
122
+ }
123
+
124
+ let webkitRemountChain: Promise<void> = Promise.resolve();
125
+ export function enqueueWebkitRemount(task: WebkitRemountTask): void {
126
+ webkitRemountChain = webkitRemountChain
127
+ .then(async () => {
128
+ if (!task.remount()) return;
129
+ await task.awaitBoot();
130
+ await new Promise((resolve) => setTimeout(resolve, WEBKIT_REMOUNT_SETTLE_MS));
131
+ })
132
+ .catch(() => {});
80
133
  }
81
134
 
82
135
  export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
@@ -178,6 +231,24 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
178
231
  </script>`;
179
232
  }
180
233
 
234
+ /**
235
+ * Boot beacon injected into EVERY ext-app document (not gated on AX): posts one
236
+ * parent message the moment the iframe's scripts execute. This is the liveness
237
+ * signal the WebKit never-booted watchdog keys on — it distinguishes an app
238
+ * that is alive but boots via the 1200ms bootstrap fallback (e.g. a non-SDK
239
+ * widget that never sends ui/notifications/initialized) from a dead window
240
+ * whose scripts never ran. Without it, the watchdog treated every
241
+ * fallback-booted app as never-booted and remounted it up to the attempt cap —
242
+ * a reboot/flicker loop for exactly the apps the fallback exists to support
243
+ * (0.3.2 pre-release review). The token scopes the message to this component;
244
+ * the host also checks event.source against the live iframe.
245
+ */
246
+ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
247
+ return `<script data-pmx-canvas-boot-beacon>
248
+ window.parent.postMessage({ source: 'pmx-canvas-ext-app-alive', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
249
+ </script>`;
250
+ }
251
+
181
252
  export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
182
253
  if (!axBridgeScript) return html;
183
254
  const headMatch = /<head\b[^>]*>/i.exec(html);
@@ -231,7 +302,21 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
231
302
  const bridgeReadyRef = useRef(false);
232
303
  const themeUnsubRef = useRef<(() => void) | null>(null);
233
304
  const webkitRepaintDoneRef = useRef(false);
234
- const webkitRepaintTimerRef = useRef<number | null>(null);
305
+ const webkitRemountAttemptsRef = useRef(0);
306
+ // Genuine boot signal: set ONLY when the app completes the ui/initialize
307
+ // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
308
+ // which flips status via notifications that resolve even into a dead iframe.
309
+ const appInitializedRef = useRef(false);
310
+ // Liveness signal from the injected boot beacon: the CURRENT frame's scripts
311
+ // executed. Proves "alive" for apps that boot via the fallback path without
312
+ // ever sending initialized; a dead window never beacons.
313
+ const appScriptsRanRef = useRef(false);
314
+ const bootWaitersRef = useRef<Array<() => void>>([]);
315
+ const remountQueuedRef = useRef(false);
316
+ // Recovery ran while the document was hidden (compositing suppressed) — re-arm
317
+ // one fresh remount round when the panel becomes visible (Finding N).
318
+ const bootedWhileHiddenRef = useRef(false);
319
+ const unmountedRef = useRef(false);
235
320
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
236
321
  const [error, setError] = useState<string | null>(null);
237
322
  const [retryKey, setRetryKey] = useState(0);
@@ -265,7 +350,26 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
265
350
  const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
266
351
  const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
267
352
  const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
268
- const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
353
+ // The boot beacon precedes the AX bridge so it runs first at parse time; it
354
+ // shares the per-component token (used purely to authenticate the message).
355
+ const bootBeaconScript =
356
+ typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
357
+ const iframeDocument = useIframeDocument(
358
+ injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
359
+ iframeSandbox,
360
+ );
361
+
362
+ useEffect(() => {
363
+ function onBootBeacon(event: MessageEvent) {
364
+ if (event.source !== iframeRef.current?.contentWindow) return;
365
+ const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
366
+ if (!data || data.source !== 'pmx-canvas-ext-app-alive' || data.token !== axToken || data.nodeId !== nodeId)
367
+ return;
368
+ appScriptsRanRef.current = true;
369
+ }
370
+ window.addEventListener('message', onBootBeacon);
371
+ return () => window.removeEventListener('message', onBootBeacon);
372
+ }, [axToken, nodeId]);
269
373
 
270
374
  useEffect(() => {
271
375
  if (!axEnabled) return;
@@ -308,23 +412,61 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
308
412
  return () => window.removeEventListener('message', onAxMessage);
309
413
  }, [axEnabled, axToken, nodeId]);
310
414
 
311
- // Finding F (0.2.4/0.2.5): in a WebKit host panel (e.g. the GitHub Copilot app's
312
- // WKWebView, and Bun's headless WebKit WebView) the doubly-nested ext-app iframe
313
- // (workbench iframe → mcp-app.html iframe) can come up as a black tile for nodes
314
- // present at panel-load. The mcp-app shell loads blank, then the app boots over the
315
- // bridge and draws its content AFTER load; under a cold-hydration burst WebKit does
316
- // not composite that late draw, so the layer stays black (clean in Blink, and clean
317
- // for a node created live into an already-idle panel). A parent-side transform/src
318
- // nudge does NOT repair a black layer — only a full remount (new iframe element +
319
- // bridge re-init, what expand+close does) does, and only when it lands in an idle
320
- // moment. So: once the app has booted `ready` for empty apps, `done` for restored
321
- // apps that must replay saved tool output — under WebKit only, force ONE remount,
322
- // SERIALIZED per node (an increasing slot delay) so concurrent ext-apps
323
- // don't re-form a single compositing burst (the 0.2.4 fixed-250ms-on-mount remount
324
- // fired mid-burst before the app had even booted, and all siblings re-raced —
325
- // insufficient). This reliably repaints a SINGLE present-at-load ext-app; a board
326
- // with several ext-apps present at WebKit panel-load can still black out (a host
327
- // compositor limit expand+close or Chrome remains the fallback; see SKILL.md).
415
+ // Enqueue one serialized remount attempt for this node (Finding F recovery).
416
+ // Attempts are capped so a persistently-failing app degrades to the manual
417
+ // fallback (expand+close / Retry) instead of remount-looping forever.
418
+ const WEBKIT_MAX_REMOUNT_ATTEMPTS = 3;
419
+ const scheduleWebkitRemount = (reason: string): void => {
420
+ if (webkitRemountAttemptsRef.current >= WEBKIT_MAX_REMOUNT_ATTEMPTS) {
421
+ extAppRecoveryLog(nodeId, `remount-cap-hit (${reason})`);
422
+ return;
423
+ }
424
+ if (remountQueuedRef.current) return; // an attempt is already queued and has not run yet
425
+ webkitRemountAttemptsRef.current += 1;
426
+ remountQueuedRef.current = true;
427
+ extAppRecoveryLog(nodeId, `remount-queued #${webkitRemountAttemptsRef.current} (${reason})`);
428
+ enqueueWebkitRemount({
429
+ remount: () => {
430
+ remountQueuedRef.current = false;
431
+ if (unmountedRef.current || expandedNodeId.value === nodeId) {
432
+ extAppRecoveryLog(nodeId, 'remount-skipped');
433
+ return false;
434
+ }
435
+ // Stamp visibility at RUN time, not schedule time: the serialized queue
436
+ // can hold this slot for many seconds, and what decides whether the
437
+ // repaint can composite is the document's visibility when the remount
438
+ // actually executes (the Finding N re-arm keys on this flag). Covers
439
+ // every scheduling path (post-boot repaint, boot watchdog, re-arm).
440
+ bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
441
+ extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
442
+ setRetryKey((k) => k + 1);
443
+ return true;
444
+ },
445
+ awaitBoot: () =>
446
+ new Promise<void>((resolve) => {
447
+ const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
448
+ function finish() {
449
+ window.clearTimeout(timer);
450
+ resolve();
451
+ }
452
+ bootWaitersRef.current.push(finish);
453
+ }),
454
+ });
455
+ };
456
+
457
+ // Finding F (0.2.4/0.2.5, reworked 0.3.1): in a WebKit host panel (e.g. the GitHub
458
+ // Copilot app's WKWebView, and Bun's headless WebKit WebView) the doubly-nested
459
+ // ext-app iframe (workbench iframe → mcp-app.html iframe) can come up as a black
460
+ // tile for nodes present at panel-load. The mcp-app shell loads blank, then the app
461
+ // boots over the bridge and draws its content AFTER load; under a cold-hydration
462
+ // burst WebKit does not composite that late draw, so the layer stays black (clean
463
+ // in Blink, and clean for a node created live into an already-idle panel). A
464
+ // parent-side transform/src nudge does NOT repair a black layer — only a full
465
+ // remount (new iframe element + bridge re-init, what expand+close does) does, and
466
+ // only when it lands in an idle moment. So: once the app has booted — `ready` for
467
+ // empty apps, `done` for restored apps that must replay saved tool output — under
468
+ // WebKit only, enqueue ONE recovery remount through the boot-aware queue above, so
469
+ // concurrent ext-apps remount strictly one at a time instead of re-bursting.
328
470
  // Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
329
471
  useEffect(() => {
330
472
  if (expanded || webkitRepaintDoneRef.current) return;
@@ -332,19 +474,64 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
332
474
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
333
475
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
334
476
  webkitRepaintDoneRef.current = true;
335
- // Serialize: each ext-app repaints into a progressively-quieter panel so the
336
- // cold-hydration burst becomes a sequence of single (always-successful) repaints.
337
- // The timer is held in a ref and cleared only on UNMOUNT (separate effect), NOT in
338
- // this effect's cleanup — otherwise a later status change (ready→done) would cancel
339
- // the one-shot remount before it fires. The gate above runs once per node.
340
- const delayMs = 250 + nextWebkitRepaintSlot() * 450;
341
- webkitRepaintTimerRef.current = window.setTimeout(() => setRetryKey((k) => k + 1), delayMs);
477
+ scheduleWebkitRemount('post-boot-repaint');
342
478
  // eslint-disable-next-line react-hooks/exhaustive-deps
343
479
  }, [status, hasReplayToolResult]);
344
480
 
481
+ // Finding N (0.3.2 report): the Copilot panel can load the canvas while the
482
+ // WKWebView document is HIDDEN (panel backgrounded/occluded). WebKit suppresses
483
+ // compositing for hidden documents, so the whole serialized recovery above can
484
+ // run to completion without ever producing a visible paint — and nothing
485
+ // retried once the panel was shown, which is why tiles stayed black until a
486
+ // manual expand+close (necessarily performed while visible). When the document
487
+ // becomes visible again, re-arm ONE fresh serialized remount round for frames
488
+ // whose recovery ran while hidden.
489
+ useEffect(() => {
490
+ if (expanded) return;
491
+ if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
492
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
493
+ function onVisibilityChange() {
494
+ if (document.visibilityState !== 'visible') return;
495
+ if (!bootedWhileHiddenRef.current || unmountedRef.current) return;
496
+ bootedWhileHiddenRef.current = false;
497
+ // Fresh visibility = fresh recovery budget: the hidden-run attempts were
498
+ // spent without a chance of compositing.
499
+ webkitRemountAttemptsRef.current = 0;
500
+ extAppRecoveryLog(nodeId, 'visible-again-rearm');
501
+ scheduleWebkitRemount('visible-after-hidden-boot');
502
+ }
503
+ document.addEventListener('visibilitychange', onVisibilityChange);
504
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
505
+ // eslint-disable-next-line react-hooks/exhaustive-deps
506
+ }, [expanded, nodeId]);
507
+
508
+ // Never-booted watchdog (0.3.2): an iframe whose scripts never ran in the burst
509
+ // shows no error — the bootstrap fallback flips status via notifications that
510
+ // resolve into a dead window, so the tile just stays black. "Alive" is either
511
+ // boot signal from the CURRENT frame: the genuine initialize handshake, or the
512
+ // injected boot beacon (which covers apps that boot via the fallback without
513
+ // ever sending initialized — remounting those was a flicker loop, and checking
514
+ // bridgeReadyRef instead would be wrong: the fallback sets it blindly, dead
515
+ // window or not). Only a frame with NEITHER signal is retried through the
516
+ // serialized queue (bounded by the shared attempt cap).
517
+ const WEBKIT_BOOT_WATCHDOG_MS = 6000;
518
+ useEffect(() => {
519
+ if (expanded) return;
520
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
521
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
522
+ if (!iframeDocument.ready) return;
523
+ const timer = window.setTimeout(() => {
524
+ if (appInitializedRef.current || appScriptsRanRef.current || unmountedRef.current) return;
525
+ scheduleWebkitRemount('boot-watchdog');
526
+ }, WEBKIT_BOOT_WATCHDOG_MS);
527
+ return () => window.clearTimeout(timer);
528
+ // eslint-disable-next-line react-hooks/exhaustive-deps
529
+ }, [frameKey, iframeDocument.ready, expanded]);
530
+
345
531
  useEffect(
346
532
  () => () => {
347
- if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
533
+ unmountedRef.current = true;
534
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
348
535
  },
349
536
  [],
350
537
  );
@@ -415,6 +602,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
415
602
  lastSentToolResultRef.current = undefined;
416
603
  toolResultSendingRef.current = null;
417
604
  bridgeReadyRef.current = false;
605
+ // New frame = new boot attempt: both boot signals belong to the CURRENT
606
+ // iframe (the beacon listener also pins event.source to the live element).
607
+ // The queue's awaitBoot waits on this frame's handshake.
608
+ appInitializedRef.current = false;
609
+ appScriptsRanRef.current = false;
418
610
 
419
611
  const clearFallbackTimer = (): void => {
420
612
  if (!fallbackTimer) return;
@@ -611,12 +803,23 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
611
803
  if (disposed) return;
612
804
  clearFallbackTimer();
613
805
  bridgeReadyRef.current = true;
806
+ appInitializedRef.current = true;
807
+ extAppRecoveryLog(nodeId, 'initialized');
614
808
  setStatus('ready');
615
809
  setError(null);
616
810
  void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
617
811
  .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
618
812
  .then(() => flushToolResult(bridge))
619
- .then(() => nudgeHostContextAfterLayout())
813
+ .then(() => {
814
+ // Settled: handshake + bootstrap replay delivered — the app draws its
815
+ // scene right after this. Release the remount queue (which then adds
816
+ // its own settle pause covering the draw) only from this genuine path;
817
+ // the bootstrap fallback never releases it (a dead iframe must run the
818
+ // queue's bounded timeout instead of green-lighting the next remount).
819
+ extAppRecoveryLog(nodeId, 'settled');
820
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
821
+ nudgeHostContextAfterLayout();
822
+ })
620
823
  .catch((err) => {
621
824
  const msg = err instanceof Error ? err.message : String(err);
622
825
  setError(`Bridge bootstrap failed: ${msg}`);
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { basename, resolve } from 'node:path';
3
3
  import { recomputeCodeGraph } from './code-graph.js';
4
4
  import {
5
5
  canvasState,
@@ -727,7 +727,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
727
727
  const rawPath =
728
728
  typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
729
729
  const resolved = resolve(rawPath);
730
- const fileName = resolved.split('/').pop() ?? rawPath;
730
+ const fileName = basename(resolved) || rawPath;
731
731
  const data: Record<string, unknown> = {
732
732
  ...(input.data ?? {}),
733
733
  path: resolved,
@@ -767,7 +767,7 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
767
767
 
768
768
  if (!isDataUri && !isUrl && src) {
769
769
  const resolved = resolve(src);
770
- const fileName = resolved.split('/').pop() ?? src;
770
+ const fileName = basename(resolved) || src;
771
771
  const { mimeType } = validateLocalImageFile(resolved);
772
772
  return {
773
773
  ...(input.data ?? {}),
@@ -51,6 +51,7 @@ const SUPPORTED_BATCH_OPS = new Set([
51
51
  'node.remove',
52
52
  'graph.add',
53
53
  'edge.add',
54
+ 'edge.remove',
54
55
  'group.create',
55
56
  'group.add',
56
57
  'group.remove',
@@ -204,7 +205,7 @@ function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
204
205
  return { ok: true, arranged: body.arranged, layout: body.layout };
205
206
  }
206
207
 
207
- // edge.add / group.remove: wire shape already matches the legacy push verbatim.
208
+ // edge.add / edge.remove / group.remove: wire shape matches the push verbatim.
208
209
  return body;
209
210
  }
210
211
 
@@ -356,7 +357,7 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
356
357
  mcp: {
357
358
  toolName: 'canvas_batch',
358
359
  description:
359
- 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
360
+ 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, edge.remove, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
360
361
  formatResult: (result, input) => {
361
362
  const envelope = result as BatchEnvelope;
362
363
  const payload = wantsFullBatch(input) ? envelope : compactBatchResult(envelope);
@@ -1021,13 +1021,26 @@ function resolveCanvasBundleDir(): string {
1021
1021
  return fallback;
1022
1022
  }
1023
1023
 
1024
+ /**
1025
+ * Containment check for bundle-asset serving. Separator-agnostic: on Windows,
1026
+ * `resolve()` returns backslash paths, so comparing against a `${dir}/` template
1027
+ * rejected every asset — /canvas/index.js 404'd and the SPA never booted (the
1028
+ * 0.3.1 Windows report). Normalizing both sides keeps the check exact on POSIX
1029
+ * and correct on win32, and testable with win32-shaped fixtures on any host.
1030
+ */
1031
+ export function isCanvasBundlePath(distPath: string, bundleDir: string): boolean {
1032
+ const normalize = (value: string) => value.replaceAll('\\', '/');
1033
+ const dir = normalize(bundleDir).replace(/\/+$/, '');
1034
+ return normalize(distPath).startsWith(`${dir}/`);
1035
+ }
1036
+
1024
1037
  function serveCanvasStatic(pathname: string): Response | null {
1025
1038
  const fileName = pathname.slice('/canvas/'.length);
1026
1039
  if (!fileName || fileName.includes('..') || fileName.startsWith('/')) return null;
1027
1040
 
1028
1041
  const bundleDir = resolveCanvasBundleDir();
1029
1042
  const distPath = resolve(bundleDir, fileName);
1030
- if (!distPath.startsWith(`${bundleDir}/`)) return null;
1043
+ if (!isCanvasBundlePath(distPath, bundleDir)) return null;
1031
1044
  if (existsSync(distPath)) {
1032
1045
  const ext = extname(fileName);
1033
1046
  return new Response(readFileSync(distPath), {
@@ -1107,6 +1120,44 @@ function handleFrameDocument(pathname: string): Response {
1107
1120
  });
1108
1121
  }
1109
1122
 
1123
+ // ── Ext-app recovery diagnostics (0.3.2 report Finding N) ──────
1124
+ //
1125
+ // The WebKit black-tile recovery trail is otherwise trapped inside host panels
1126
+ // without devtools (the Copilot WKWebView). Clients mirror their bounded
1127
+ // recovery log here; agents/testers read it back over HTTP. In-memory only,
1128
+ // diagnostics-grade: no persistence, no snapshots.
1129
+ const EXT_APP_RECOVERY_LOG_MAX = 500;
1130
+ const EXT_APP_RECOVERY_FIELD_MAX = 300;
1131
+ const extAppRecoveryEntries: Array<Record<string, unknown>> = [];
1132
+
1133
+ /**
1134
+ * Clamp a reported entry to the known trail shape ({ t, nodeId, event,
1135
+ * visibility }) with bounded string lengths. The count trim below caps the
1136
+ * number of entries; this caps bytes per entry, so the in-memory log stays
1137
+ * diagnostics-sized no matter what a client posts.
1138
+ */
1139
+ function clampExtAppRecoveryEntry(entry: Record<string, unknown>): Record<string, unknown> {
1140
+ const clamped: Record<string, unknown> = {};
1141
+ if (typeof entry.t === 'number' && Number.isFinite(entry.t)) clamped.t = entry.t;
1142
+ for (const key of ['nodeId', 'event', 'visibility'] as const) {
1143
+ const value = entry[key];
1144
+ if (typeof value === 'string') clamped[key] = value.slice(0, EXT_APP_RECOVERY_FIELD_MAX);
1145
+ }
1146
+ return clamped;
1147
+ }
1148
+
1149
+ function handleExtAppRecoveryReport(entries: unknown): Response {
1150
+ if (!Array.isArray(entries)) return responseJson({ ok: false, error: 'entries must be an array' }, 400);
1151
+ for (const entry of entries.slice(0, 100)) {
1152
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
1153
+ extAppRecoveryEntries.push(clampExtAppRecoveryEntry(entry as Record<string, unknown>));
1154
+ }
1155
+ if (extAppRecoveryEntries.length > EXT_APP_RECOVERY_LOG_MAX) {
1156
+ extAppRecoveryEntries.splice(0, extAppRecoveryEntries.length - EXT_APP_RECOVERY_LOG_MAX);
1157
+ }
1158
+ return responseJson({ ok: true, total: extAppRecoveryEntries.length });
1159
+ }
1160
+
1110
1161
  // ── Node surfaces ("Open as site") ─────────────────────────────
1111
1162
  //
1112
1163
  // One stable, node-addressable URL — /api/canvas/surface/:nodeId — that serves
@@ -2830,7 +2881,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2830
2881
  const url = new URL(req.url);
2831
2882
 
2832
2883
  if (url.pathname === '/health') {
2833
- return responseJson({ ok: true, workspace: activeWorkspaceRoot });
2884
+ // `pid` lets `serve status` report the ACTUAL serving process even
2885
+ // when the pid file is stale (e.g. an adapter respawned the server
2886
+ // outside `serve --daemon` — 0.3.2 report Finding P).
2887
+ return responseJson({ ok: true, workspace: activeWorkspaceRoot, pid: process.pid });
2834
2888
  }
2835
2889
 
2836
2890
  if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
@@ -2841,10 +2895,25 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2841
2895
  return handleArtifactView(url);
2842
2896
  }
2843
2897
 
2844
- if (url.pathname === '/api/canvas/json-render/view' && req.method === 'GET') {
2898
+ // Standalone surfaces answer HEAD like GET (status + headers, no body):
2899
+ // tooling probes these advertised URLs, and a HEAD 404 against a
2900
+ // working GET reads as a broken surface (0.3.2 report Finding O).
2901
+ // Bun.serve strips the body and keeps the true Content-Length on HEAD,
2902
+ // so the GET handler's response is returned as-is.
2903
+ if (url.pathname === '/api/canvas/json-render/view' && (req.method === 'GET' || req.method === 'HEAD')) {
2845
2904
  return handleJsonRenderView(url);
2846
2905
  }
2847
2906
 
2907
+ if (url.pathname === '/api/canvas/debug/ext-app-recovery' && req.method === 'POST') {
2908
+ const body = await readJson(req);
2909
+ if (body === null) return malformedJsonResponse();
2910
+ return handleExtAppRecoveryReport(body.entries);
2911
+ }
2912
+
2913
+ if (url.pathname === '/api/canvas/debug/ext-app-recovery' && req.method === 'GET') {
2914
+ return responseJson({ ok: true, entries: extAppRecoveryEntries });
2915
+ }
2916
+
2848
2917
  if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
2849
2918
  return handleCreateFrameDocument(req);
2850
2919
  }
@@ -2853,7 +2922,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2853
2922
  return handleFrameDocument(url.pathname);
2854
2923
  }
2855
2924
 
2856
- if (url.pathname.startsWith('/api/canvas/surface/') && req.method === 'GET') {
2925
+ if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) {
2857
2926
  return handleNodeSurface(url.pathname, url);
2858
2927
  }
2859
2928