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.
- package/CHANGELOG.md +58 -0
- package/dist/canvas/index.js +69 -67
- package/dist/types/cli/agent.d.ts +9 -1
- package/dist/types/cli/daemon.d.ts +17 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +22 -1
- package/dist/types/server/server.d.ts +8 -0
- package/docs/RELEASE.md +11 -0
- package/docs/cli.md +10 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +15 -9
- package/skills/pmx-canvas/references/full-reference.md +7 -0
- package/src/cli/agent.ts +68 -1
- package/src/cli/daemon.ts +39 -7
- package/src/cli/index.ts +21 -1
- package/src/client/nodes/ExtAppFrame.tsx +249 -46
- package/src/server/bundled-skills.ts +5 -1
- package/src/server/canvas-operations.ts +3 -3
- package/src/server/operations/ops/batch.ts +3 -2
- package/src/server/server.ts +73 -4
|
@@ -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):
|
|
63
|
-
// hydration BURST problem — a single ext-app repaints fine
|
|
64
|
-
// a live-created node, or expand+close), but several
|
|
65
|
-
// WebKit and all stay black.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
312
|
-
//
|
|
313
|
-
// (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|