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.
- package/CHANGELOG.md +23 -0
- package/dist/canvas/index.js +59 -57
- package/dist/types/cli/daemon.d.ts +17 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -0
- package/docs/RELEASE.md +11 -0
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +11 -8
- package/src/cli/daemon.ts +39 -7
- package/src/client/nodes/ExtAppFrame.tsx +118 -8
- package/src/server/bundled-skills.ts +5 -1
- package/src/server/server.ts +59 -3
|
@@ -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:
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.3.
|
|
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-
|
|
221
|
-
present-at-load ext-app is remounted through a
|
|
222
|
-
time,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
from page JS, so retries are bounded)
|
|
226
|
-
black tile (forces a fresh mount in
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
342
|
-
const
|
|
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 ||
|
|
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) && !
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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:
|
|
498
|
-
//
|
|
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(
|
|
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);
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|