pmx-canvas 0.3.2 → 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.
@@ -17,6 +17,8 @@ export declare function removePidFile(path: string): void;
17
17
  export interface HealthStatus {
18
18
  responsive: boolean;
19
19
  workspace: string | null;
20
+ /** The serving process's own pid, self-reported by /health (0.3.3+ servers). */
21
+ pid: number | null;
20
22
  }
21
23
  export declare function readHealthStatus(url: string): Promise<HealthStatus>;
22
24
  export declare function isHealthy(url: string): Promise<boolean>;
@@ -65,6 +67,21 @@ export declare function startDaemonMode(options: DaemonPaths & {
65
67
  waitMs: number;
66
68
  entry: string;
67
69
  }): Promise<void>;
70
+ /**
71
+ * Resolve the pid story `serve status` reports. The AUTHORITATIVE pid is the
72
+ * serving process's self-reported /health pid: the pid file can go stale when
73
+ * something other than `serve --daemon` (re)spawned the server on this port —
74
+ * e.g. a host adapter respawn (0.3.2 report Finding P). Reporting the dead
75
+ * file pid as `pid` while `running` was true made agents read a contradiction
76
+ * (`running: true, pidRunning: false`); report the real listener instead and
77
+ * mark the file explicitly stale. Pre-0.3.3 servers don't self-report a pid —
78
+ * for those, fall back to the pid-file view unchanged.
79
+ */
80
+ export declare function resolveDaemonPidView(pidFilePid: number | null, pidFilePidRunning: boolean, health: HealthStatus): {
81
+ pid: number | null;
82
+ pidRunning: boolean;
83
+ pidFileStale: boolean;
84
+ };
68
85
  export declare function showServeStatus(options: DaemonPaths & {
69
86
  entry: string;
70
87
  }): Promise<void>;
@@ -38,6 +38,19 @@ export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMo
38
38
  export declare function sendExtAppBootstrapState(bridge: ExtAppBridgeNotifications, toolInput: Record<string, unknown>, toolResult: CallToolResult | undefined): Promise<void>;
39
39
  export declare function resolveExtAppSandbox(value: unknown): string;
40
40
  export declare function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string;
41
+ /**
42
+ * Boot beacon injected into EVERY ext-app document (not gated on AX): posts one
43
+ * parent message the moment the iframe's scripts execute. This is the liveness
44
+ * signal the WebKit never-booted watchdog keys on — it distinguishes an app
45
+ * that is alive but boots via the 1200ms bootstrap fallback (e.g. a non-SDK
46
+ * widget that never sends ui/notifications/initialized) from a dead window
47
+ * whose scripts never ran. Without it, the watchdog treated every
48
+ * fallback-booted app as never-booted and remounted it up to the attempt cap —
49
+ * a reboot/flicker loop for exactly the apps the fallback exists to support
50
+ * (0.3.2 pre-release review). The token scopes the message to this component;
51
+ * the host also checks event.source against the live iframe.
52
+ */
53
+ export declare function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string;
41
54
  export declare function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string;
42
55
  export declare function resolveExtAppContainerDimensions(target: ExtAppHostDimensionsTarget | null | undefined, fallback: {
43
56
  width: number;
package/docs/RELEASE.md CHANGED
@@ -124,6 +124,17 @@ If this is the first release from your machine, run `bunx npm login`
124
124
  once so Bun can reuse your npm credentials. CI does not need this —
125
125
  it uses `NPM_TOKEN` directly.
126
126
 
127
+ ## Sync consumer skill mirrors
128
+
129
+ Every release since 0.3.0 has been retested against **stale** skill mirrors:
130
+ consumer workspaces (e.g. `personalclaude`) carry copies of the pmx-canvas
131
+ skill under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
132
+ repeatedly had to sync them mid-cycle before results were trustworthy. After
133
+ publishing, update the installed package in the consumer workspace and re-sync
134
+ its four mirrors from
135
+ `<global npm root>/pmx-canvas/skills/pmx-canvas/` so the first test pass runs
136
+ against the released skill, not the previous version's guidance.
137
+
127
138
  ## GitHub release
128
139
 
129
140
  After `npm publish` succeeds:
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -217,14 +217,17 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
217
217
  loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
218
218
  compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
219
219
  `sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
220
- live after the panel hydrates). Under WebKit the canvas auto-recovers: after boot, each
221
- present-at-load ext-app is remounted through a **serialized, boot-aware queue** (one app at a
222
- time, each waiting for the previous app's handshake + scene replay), with a watchdog retry for
223
- iframes that never boot so multi-app boards converge to painted tiles within a few seconds of
224
- panel load. In rare cases a tile can still end up black (compositing success is not observable
225
- from page JS, so retries are bounded); recovery is deterministic: **expand-then-close** the
226
- black tile (forces a fresh mount in the fullscreen overlay, which always paints), or open the
227
- workbench in a normal browser (Chrome). Do not diagnose a healthy app session as a broken node.
220
+ live after the panel hydrates). Under WebKit the canvas ATTEMPTS auto-recovery: after boot,
221
+ each present-at-load ext-app is remounted through a serialized, boot-aware queue (one app at a
222
+ time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
223
+ hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed the 0.3.2
224
+ Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
225
+ success is not observable from page JS, so retries are bounded). Keep the cautionary behavior:
226
+ recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
227
+ the fullscreen overlay, which always paints), or open the workbench in a normal browser
228
+ (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
229
+ fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
230
+ what the recovery queue actually did in that panel.
228
231
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
229
232
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
230
233
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
package/src/cli/daemon.ts CHANGED
@@ -88,20 +88,26 @@ export function removePidFile(path: string): void {
88
88
  export interface HealthStatus {
89
89
  responsive: boolean;
90
90
  workspace: string | null;
91
+ /** The serving process's own pid, self-reported by /health (0.3.3+ servers). */
92
+ pid: number | null;
91
93
  }
92
94
 
93
95
  export async function readHealthStatus(url: string): Promise<HealthStatus> {
94
96
  try {
95
97
  const response = await fetch(url);
96
- if (!response.ok) return { responsive: false, workspace: null };
98
+ if (!response.ok) return { responsive: false, workspace: null, pid: null };
97
99
  const payload = (await response.json().catch(() => null)) as unknown;
98
100
  const workspace =
99
101
  payload && typeof payload === 'object' && 'workspace' in payload && typeof payload.workspace === 'string'
100
102
  ? payload.workspace
101
103
  : null;
102
- return { responsive: true, workspace };
104
+ const pid =
105
+ payload && typeof payload === 'object' && 'pid' in payload && typeof payload.pid === 'number'
106
+ ? payload.pid
107
+ : null;
108
+ return { responsive: true, workspace, pid };
103
109
  } catch {
104
- return { responsive: false, workspace: null };
110
+ return { responsive: false, workspace: null, pid: null };
105
111
  }
106
112
  }
107
113
 
@@ -335,17 +341,41 @@ export async function startDaemonMode(
335
341
  process.exit(0);
336
342
  }
337
343
 
344
+ /**
345
+ * Resolve the pid story `serve status` reports. The AUTHORITATIVE pid is the
346
+ * serving process's self-reported /health pid: the pid file can go stale when
347
+ * something other than `serve --daemon` (re)spawned the server on this port —
348
+ * e.g. a host adapter respawn (0.3.2 report Finding P). Reporting the dead
349
+ * file pid as `pid` while `running` was true made agents read a contradiction
350
+ * (`running: true, pidRunning: false`); report the real listener instead and
351
+ * mark the file explicitly stale. Pre-0.3.3 servers don't self-report a pid —
352
+ * for those, fall back to the pid-file view unchanged.
353
+ */
354
+ export function resolveDaemonPidView(
355
+ pidFilePid: number | null,
356
+ pidFilePidRunning: boolean,
357
+ health: HealthStatus,
358
+ ): { pid: number | null; pidRunning: boolean; pidFileStale: boolean } {
359
+ const authoritative = health.responsive && health.pid !== null;
360
+ return {
361
+ pid: authoritative ? health.pid : pidFilePid,
362
+ pidRunning: authoritative ? true : pidFilePidRunning,
363
+ pidFileStale: pidFilePid !== null && !pidFilePidRunning && (!health.responsive || health.pid !== pidFilePid),
364
+ };
365
+ }
366
+
338
367
  export async function showServeStatus(options: DaemonPaths & { entry: string }): Promise<void> {
339
368
  const healthUrl = `http://localhost:${options.port}/health`;
340
369
  const url = `http://localhost:${options.port}/workbench`;
341
- const pid = readPidFile(options.pidFile);
342
- const pidRunning = pid ? isOwnDaemonProcess(pid, options.entry) : false;
370
+ const pidFilePid = readPidFile(options.pidFile);
371
+ const pidFilePidRunning = pidFilePid ? isOwnDaemonProcess(pidFilePid, options.entry) : false;
343
372
  const health = await readHealthStatus(healthUrl);
344
373
  const responsive = health.responsive;
345
- const running = responsive || pidRunning;
374
+ const running = responsive || pidFilePidRunning;
375
+ const { pid, pidRunning, pidFileStale } = resolveDaemonPidView(pidFilePid, pidFilePidRunning, health);
346
376
  // Clean up a stale pid file, but never a concurrent starter's fresh empty
347
377
  // spawn lock (that would let a racing `serve --daemon` double-spawn).
348
- if (!running && existsSync(options.pidFile) && !pidRunning && !isFreshEmptyLock(options.pidFile)) {
378
+ if (!running && existsSync(options.pidFile) && !pidFilePidRunning && !isFreshEmptyLock(options.pidFile)) {
349
379
  removePidFile(options.pidFile);
350
380
  }
351
381
 
@@ -357,6 +387,8 @@ export async function showServeStatus(options: DaemonPaths & { entry: string }):
357
387
  workspace: health.workspace,
358
388
  pid,
359
389
  pidRunning,
390
+ pidFilePid,
391
+ pidFileStale,
360
392
  url,
361
393
  healthUrl,
362
394
  logFile: options.logFile,
@@ -92,6 +92,33 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
92
92
  const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
93
93
  host.__PMX_EXTAPP_LOG ??= [];
94
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);
95
122
  }
96
123
 
97
124
  let webkitRemountChain: Promise<void> = Promise.resolve();
@@ -204,6 +231,24 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
204
231
  </script>`;
205
232
  }
206
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
+
207
252
  export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
208
253
  if (!axBridgeScript) return html;
209
254
  const headMatch = /<head\b[^>]*>/i.exec(html);
@@ -262,8 +307,15 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
262
307
  // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
263
308
  // which flips status via notifications that resolve even into a dead iframe.
264
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);
265
314
  const bootWaitersRef = useRef<Array<() => void>>([]);
266
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);
267
319
  const unmountedRef = useRef(false);
268
320
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
269
321
  const [error, setError] = useState<string | null>(null);
@@ -298,7 +350,26 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
298
350
  const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
299
351
  const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
300
352
  const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
301
- 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]);
302
373
 
303
374
  useEffect(() => {
304
375
  if (!axEnabled) return;
@@ -361,6 +432,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
361
432
  extAppRecoveryLog(nodeId, 'remount-skipped');
362
433
  return false;
363
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';
364
441
  extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
365
442
  setRetryKey((k) => k + 1);
366
443
  return true;
@@ -401,11 +478,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
401
478
  // eslint-disable-next-line react-hooks/exhaustive-deps
402
479
  }, [status, hasReplayToolResult]);
403
480
 
404
- // Never-booted watchdog (0.3.1): an iframe whose scripts never ran in the burst
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
405
509
  // shows no error — the bootstrap fallback flips status via notifications that
406
- // resolve into a dead window, so the tile just stays black. If the CURRENT frame
407
- // has not completed the genuine initialize handshake within the watchdog window,
408
- // retry it through the same serialized queue (bounded by the shared attempt cap).
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).
409
517
  const WEBKIT_BOOT_WATCHDOG_MS = 6000;
410
518
  useEffect(() => {
411
519
  if (expanded) return;
@@ -413,7 +521,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
413
521
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
414
522
  if (!iframeDocument.ready) return;
415
523
  const timer = window.setTimeout(() => {
416
- if (appInitializedRef.current || unmountedRef.current) return;
524
+ if (appInitializedRef.current || appScriptsRanRef.current || unmountedRef.current) return;
417
525
  scheduleWebkitRemount('boot-watchdog');
418
526
  }, WEBKIT_BOOT_WATCHDOG_MS);
419
527
  return () => window.clearTimeout(timer);
@@ -494,9 +602,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
494
602
  lastSentToolResultRef.current = undefined;
495
603
  toolResultSendingRef.current = null;
496
604
  bridgeReadyRef.current = false;
497
- // New frame = new boot attempt: the genuine-initialized signal belongs to the
498
- // CURRENT iframe. The queue's awaitBoot waits on this frame's handshake.
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.
499
608
  appInitializedRef.current = false;
609
+ appScriptsRanRef.current = false;
500
610
 
501
611
  const clearFallbackTimer = (): void => {
502
612
  if (!fallbackTimer) return;
@@ -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);
@@ -1120,6 +1120,44 @@ function handleFrameDocument(pathname: string): Response {
1120
1120
  });
1121
1121
  }
1122
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
+
1123
1161
  // ── Node surfaces ("Open as site") ─────────────────────────────
1124
1162
  //
1125
1163
  // One stable, node-addressable URL — /api/canvas/surface/:nodeId — that serves
@@ -2843,7 +2881,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2843
2881
  const url = new URL(req.url);
2844
2882
 
2845
2883
  if (url.pathname === '/health') {
2846
- 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 });
2847
2888
  }
2848
2889
 
2849
2890
  if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
@@ -2854,10 +2895,25 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2854
2895
  return handleArtifactView(url);
2855
2896
  }
2856
2897
 
2857
- 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')) {
2858
2904
  return handleJsonRenderView(url);
2859
2905
  }
2860
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
+
2861
2917
  if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
2862
2918
  return handleCreateFrameDocument(req);
2863
2919
  }
@@ -2866,7 +2922,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2866
2922
  return handleFrameDocument(url.pathname);
2867
2923
  }
2868
2924
 
2869
- if (url.pathname.startsWith('/api/canvas/surface/') && req.method === 'GET') {
2925
+ if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) {
2870
2926
  return handleNodeSurface(url.pathname, url);
2871
2927
  }
2872
2928