pmx-canvas 0.3.1 → 0.3.2

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.
@@ -10,4 +10,12 @@
10
10
  * - Idempotent operations where possible
11
11
  * - --yes for destructive actions, --dry-run for preview
12
12
  */
13
- export declare function runAgentCli(args: string[]): Promise<void>;
13
+ /**
14
+ * Extract the global `--port <n>` / `--server-url <url>` flags (any position,
15
+ * `=` or space-separated value) and set the invocation's target override.
16
+ * Returns the remaining args for command dispatch. Invalid values are a loud
17
+ * `die` — never a silent fallback to the default port. `--server-url` wins
18
+ * over `--port` when both are given.
19
+ */
20
+ export declare function extractGlobalTargetFlags(args: string[]): string[];
21
+ export declare function runAgentCli(rawArgs: string[]): Promise<void>;
@@ -19,7 +19,15 @@ interface ExtAppHostDimensionsTarget {
19
19
  * everywhere we can test (Chrome / Codex / Playwright).
20
20
  */
21
21
  export declare function isWebKitOnlyHost(userAgent: string): boolean;
22
- export declare function nextWebkitRepaintSlot(): number;
22
+ export declare const WEBKIT_REMOUNT_SETTLE_MS = 1000;
23
+ export interface WebkitRemountTask {
24
+ /** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
25
+ remount: () => boolean;
26
+ /** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
27
+ awaitBoot: () => Promise<void>;
28
+ }
29
+ export declare function extAppRecoveryLog(nodeId: string, event: string): void;
30
+ export declare function enqueueWebkitRemount(task: WebkitRemountTask): void;
23
31
  export declare function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean;
24
32
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
25
33
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
@@ -81,6 +81,14 @@ export declare function setPrimaryWorkbenchAutoOpenEnabled(enabled: boolean): vo
81
81
  export declare function isPrimaryWorkbenchAutoOpenEnabled(): boolean;
82
82
  export declare function hasWorkbenchSubscribers(): boolean;
83
83
  export declare function setPrimaryWorkbenchCanvasPromptHandler(handler: PrimaryWorkbenchCanvasPromptHandler | null): void;
84
+ /**
85
+ * Containment check for bundle-asset serving. Separator-agnostic: on Windows,
86
+ * `resolve()` returns backslash paths, so comparing against a `${dir}/` template
87
+ * rejected every asset — /canvas/index.js 404'd and the SPA never booted (the
88
+ * 0.3.1 Windows report). Normalizing both sides keeps the check exact on POSIX
89
+ * and correct on win32, and testable with win32-shaped fixtures on any host.
90
+ */
91
+ export declare function isCanvasBundlePath(distPath: string, bundleDir: string): boolean;
84
92
  export declare function buildMacBrowserOpenScript(appName: string, url: string): string;
85
93
  export declare function openUrlInExternalBrowser(url: string): boolean;
86
94
  /**
package/docs/cli.md CHANGED
@@ -2,7 +2,16 @@
2
2
 
3
3
  The CLI is the shell-native way to run and control PMX Canvas. It targets
4
4
  `http://localhost:4313` by default — override with `PMX_CANVAS_URL` or
5
- `PMX_CANVAS_PORT` when the server runs elsewhere.
5
+ `PMX_CANVAS_PORT` when the server runs elsewhere, or per invocation with the
6
+ global `--port <n>` / `--server-url <url>` flags (any position; `--server-url`
7
+ wins over `--port`, and both win over the environment variables). An invalid
8
+ value for either flag is a hard error — the CLI never falls back silently to
9
+ the default port.
10
+
11
+ ```bash
12
+ pmx-canvas node list --port 4750 # target a non-default daemon
13
+ pmx-canvas --server-url http://127.0.0.1:4750 status # same, by URL
14
+ ```
6
15
 
7
16
  ## Server lifecycle
8
17
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -24,7 +24,8 @@ Humans curate agent context by pinning nodes; agents read that curation through
24
24
  `workspace` must equal the intended absolute workspace root. A healthy listener on port 4313
25
25
  may belong to another project.
26
26
  3. **Read before write.** Search with `canvas_query { action: "search", query }` before creating
27
- nodes. Read the full layout only when necessary.
27
+ nodes. Read the full layout only when necessary. The MCP parameter is `query` — passing the
28
+ HTTP API's `q` is silently ignored and returns zero results.
28
29
  4. **Snapshot before destructive changes.** Use `canvas_snapshot` (deprecated standalone; folds into
29
30
  the `canvas_snapshot` composite's `save` action in v0.4) before clear, restore, or a major
30
31
  reorganization.
@@ -33,7 +34,8 @@ Humans curate agent context by pinning nodes; agents read that curation through
33
34
  or edit, then pass the returned `intent.id` as `intentId` on the mutation so the ghost settles
34
35
  into the result. Use it as much as possible to make your next move and your work visible: the
35
36
  human watches intent form and can veto mid-thought. Skip it only for trivial in-place tweaks or
36
- high-frequency batch churn.
37
+ high-frequency batch churn. The default TTL (~8s) expires between agent turns: signal with
38
+ `ttlMs: 30000` and settle by passing `intentId` on the mutation in the same or next call.
37
39
  6. **Mutate through current composites.** Prefer the 15 composite MCP tools below.
38
40
  7. **Arrange and validate.** After batch changes, use `canvas_view { action: "arrange" }` when
39
41
  appropriate and always finish with `canvas_query { action: "validate" }`.
@@ -215,13 +217,14 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
215
217
  loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
216
218
  compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
217
219
  `sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
218
- live after the panel hydrates). The canvas forces a one-time post-boot repaint remount under
219
- WebKit, which reliably repaints a **single** present-at-load
220
- ext-app but a board with **several** ext-apps present at WebKit panel-load can still black out
221
- (the simultaneous cold-hydration burst overwhelms the WebKit compositor). Recovery is
222
- deterministic: **expand-then-close** any black tile (forces a fresh mount in the fullscreen
223
- overlay, which always paints), or open the workbench in a normal browser (Chrome). Do not
224
- diagnose a healthy app session as a broken node; the durable fix is upstream in the host panel.
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.
225
228
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
226
229
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
227
230
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
@@ -510,6 +510,13 @@ surfaces): `canvas_batch`, `canvas_pin_nodes`, `canvas_screenshot`, `canvas_ax_i
510
510
  `canvas_diff` — deprecated pending a `canvas_snapshot` composite in v0.4; the name collides with
511
511
  the current save-snapshot tool, so the composite cannot land additively).
512
512
 
513
+ `canvas_batch` supports exactly these ops: `node.add`, `node.update`, `node.remove`, `graph.add`,
514
+ `edge.add`, `edge.remove`, `group.create`, `group.add`, `group.remove`, `pin.set`/`pin.add`/
515
+ `pin.remove`, `snapshot.save`, and `arrange`. Anything else fails with "Unsupported canvas_batch
516
+ operation" — batch is non-atomic, so earlier ops stay applied and the response carries
517
+ `failedIndex`. `canvas_screenshot` requires an active automation WebView: call
518
+ `canvas_webview { action: "start" }` first.
519
+
513
520
  > **Removed in v0.3.0 → use the composite instead**: `canvas_open_mcp_app` / `canvas_add_diagram` /
514
521
  > `canvas_build_web_artifact` → **`canvas_app`**; `canvas_webview_start` / `canvas_webview_status` /
515
522
  > `canvas_webview_stop` / `canvas_resize` / `canvas_evaluate` → **`canvas_webview`**;
package/src/cli/agent.ts CHANGED
@@ -89,13 +89,77 @@ interface CanvasSchemaResponse {
89
89
  };
90
90
  }
91
91
 
92
+ // Per-invocation target override from the global --server-url / --port flags.
93
+ // Before these existed, `--port 4750` on any agent command was SILENTLY ignored
94
+ // and the command hit the default 4313 daemon — which once pointed a test
95
+ // automation WebView at a live production board.
96
+ let cliTargetOverride: string | null = null;
97
+
92
98
  function getBaseUrl(): string {
99
+ if (cliTargetOverride) return cliTargetOverride;
93
100
  const envUrl = process.env.PMX_CANVAS_URL;
94
101
  if (envUrl) return envUrl.replace(/\/$/, '');
95
102
  const port = process.env.PMX_CANVAS_PORT || DEFAULT_PORT;
96
103
  return `http://localhost:${port}`;
97
104
  }
98
105
 
106
+ /**
107
+ * Extract the global `--port <n>` / `--server-url <url>` flags (any position,
108
+ * `=` or space-separated value) and set the invocation's target override.
109
+ * Returns the remaining args for command dispatch. Invalid values are a loud
110
+ * `die` — never a silent fallback to the default port. `--server-url` wins
111
+ * over `--port` when both are given.
112
+ */
113
+ export function extractGlobalTargetFlags(args: string[]): string[] {
114
+ cliTargetOverride = null;
115
+ let portOverride: number | null = null;
116
+ let urlOverride: string | null = null;
117
+ const rest: string[] = [];
118
+
119
+ for (let i = 0; i < args.length; i++) {
120
+ const arg = args[i];
121
+ let name: 'port' | 'server-url' | null = null;
122
+ let value: string | undefined;
123
+ if (arg === '--port' || arg === '--server-url') {
124
+ name = arg.slice(2) as 'port' | 'server-url';
125
+ value = args[i + 1];
126
+ i += 1;
127
+ } else if (arg.startsWith('--port=')) {
128
+ name = 'port';
129
+ value = arg.slice('--port='.length);
130
+ } else if (arg.startsWith('--server-url=')) {
131
+ name = 'server-url';
132
+ value = arg.slice('--server-url='.length);
133
+ } else {
134
+ rest.push(arg);
135
+ continue;
136
+ }
137
+
138
+ if (name === 'port') {
139
+ const port = Number(value);
140
+ if (!value || !Number.isInteger(port) || port <= 0 || port > 65535) {
141
+ die(
142
+ `--port requires a port number, got ${value === undefined ? 'no value' : JSON.stringify(value)}.`,
143
+ 'Example: --port 4313. Without the flag, PMX_CANVAS_PORT / PMX_CANVAS_URL pick the target.',
144
+ );
145
+ }
146
+ portOverride = port;
147
+ } else {
148
+ if (!value || !/^https?:\/\//.test(value)) {
149
+ die(
150
+ `--server-url requires an http(s) URL, got ${value === undefined ? 'no value' : JSON.stringify(value)}.`,
151
+ 'Example: --server-url http://localhost:4313. Without the flag, PMX_CANVAS_URL picks the target.',
152
+ );
153
+ }
154
+ urlOverride = value.replace(/\/$/, '');
155
+ }
156
+ }
157
+
158
+ if (urlOverride) cliTargetOverride = urlOverride;
159
+ else if (portOverride) cliTargetOverride = `http://localhost:${portOverride}`;
160
+ return rest;
161
+ }
162
+
99
163
  function die(message: string, hint?: string): never {
100
164
  const out: Record<string, string> = { error: message };
101
165
  if (hint) out.hint = hint;
@@ -3535,6 +3599,8 @@ Analysis:
3535
3599
 
3536
3600
  Global flags:
3537
3601
  --help, -h Show help for any command
3602
+ --port <n> Target daemon port for this invocation (overrides PMX_CANVAS_PORT)
3603
+ --server-url <url> Target server URL for this invocation (overrides PMX_CANVAS_URL; wins over --port)
3538
3604
 
3539
3605
  Environment:
3540
3606
  PMX_CANVAS_URL Server URL (default: http://localhost:4313)
@@ -3592,7 +3658,8 @@ async function readStdin(): Promise<string> {
3592
3658
 
3593
3659
  // ── Router ───────────────────────────────────────────────────
3594
3660
 
3595
- export async function runAgentCli(args: string[]): Promise<void> {
3661
+ export async function runAgentCli(rawArgs: string[]): Promise<void> {
3662
+ const args = extractGlobalTargetFlags(rawArgs);
3596
3663
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
3597
3664
  showTopLevelHelp();
3598
3665
  return;
package/src/cli/index.ts CHANGED
@@ -120,6 +120,25 @@ function stripOption(argv: string[], name: string): string[] {
120
120
  return stripped;
121
121
  }
122
122
 
123
+ // Global target flags (--port / --server-url) may precede the subcommand, e.g.
124
+ // `pmx-canvas --port 4750 node list`. Skip them (and their values) when picking
125
+ // the subcommand to dispatch, so the agent CLI — which strips these flags via
126
+ // extractGlobalTargetFlags and validates them loudly — is reached instead of the
127
+ // server-startup fallback. Only these two flags are skipped; other leading flags
128
+ // (--mcp, --demo, …) still route exactly as before.
129
+ function commandAfterGlobalTargetFlags(argv: string[]): string {
130
+ for (let index = 0; index < argv.length; index++) {
131
+ const arg = argv[index];
132
+ if (arg === '--port' || arg === '--server-url') {
133
+ if (index + 1 < argv.length && !argv[index + 1].startsWith('-')) index++;
134
+ continue;
135
+ }
136
+ if (arg.startsWith('--port=') || arg.startsWith('--server-url=')) continue;
137
+ return arg;
138
+ }
139
+ return '';
140
+ }
141
+
123
142
  function runMcpServerProcess(): Promise<void> {
124
143
  return new Promise((resolvePromise, rejectPromise) => {
125
144
  const child = spawn(process.execPath, ['run', mcpServerEntry], {
@@ -181,7 +200,8 @@ if (firstArg === 'serve') {
181
200
  args.shift();
182
201
  }
183
202
 
184
- if (AGENT_COMMANDS.has(firstArg) && firstArg !== 'serve') {
203
+ const routedCommand = commandAfterGlobalTargetFlags(args);
204
+ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
185
205
  await runAgentCli(args);
186
206
  } else if (args.includes('--mcp')) {
187
207
  // MCP server mode: stdio transport, auto-starts canvas on first tool call
@@ -59,24 +59,50 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
59
59
  return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
60
60
  }
61
61
 
62
- // Finding F (0.2.5): SERIALIZED WebKit repaint slots. The black tile is a cold-
63
- // hydration BURST problem — a single ext-app repaints fine into an idle panel (like
64
- // a live-created node, or expand+close), but several compositing at once overwhelm
65
- // WebKit and all stay black. We give each mounting ext-app an increasing slot so its
66
- // one-time repaint remount fires into a progressively-quieter panel (one app at a
67
- // time), turning the burst back into a sequence of single repaints. The shared
68
- // counter resets after the burst so a later board load starts from slot 0.
69
- let webkitRepaintSlot = 0;
70
- let webkitRepaintSlotResetTimer: ReturnType<typeof setTimeout> | null = null;
71
- export function nextWebkitRepaintSlot(): number {
72
- const slot = webkitRepaintSlot++;
73
- if (typeof window !== 'undefined') {
74
- if (webkitRepaintSlotResetTimer) clearTimeout(webkitRepaintSlotResetTimer);
75
- webkitRepaintSlotResetTimer = setTimeout(() => {
76
- webkitRepaintSlot = 0;
77
- }, 3000);
78
- }
79
- return slot;
62
+ // Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
63
+ // The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
64
+ // into an idle panel (like a live-created node, or expand+close), but several
65
+ // compositing at once overwhelm WebKit and all stay black. The 0.2.5 fixed-stagger
66
+ // slots (450ms apart) were NOT boot-aware: each recovery remount reboots the app
67
+ // (~1-2s for Excalidraw), so N staggered remounts overlapped into a fresh burst and
68
+ // the per-node one-shot flag was spent exactly the multi-app reload blackout the
69
+ // 0.3.1 report shows. This queue runs remounts strictly one at a time: each entry
70
+ // performs its remount, then waits for that app's GENUINE initialized handshake
71
+ // (or a bounded timeout for an app that never boots) plus a settle delay before the
72
+ // next entry fires — so every remount lands in an idle panel, which is the
73
+ // empirically always-successful recovery (what expand+close does manually).
74
+ // Settle covers the scene DRAW: the app's initialized handshake fires before the
75
+ // replayed tool result arrives and the scene is painted, so the queue waits for the
76
+ // settled signal (bootstrap chain incl. tool-result send complete) plus this pause.
77
+ export const WEBKIT_REMOUNT_SETTLE_MS = 1000;
78
+ const WEBKIT_BOOT_TIMEOUT_MS = 7000;
79
+
80
+ export interface WebkitRemountTask {
81
+ /** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
82
+ remount: () => boolean;
83
+ /** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
84
+ awaitBoot: () => Promise<void>;
85
+ }
86
+
87
+ // Bounded recovery trail readable via `webview evaluate` / devtools
88
+ // (window.__PMX_EXTAPP_LOG). The WebKit compositor dropout is otherwise
89
+ // unobservable from page JS — this is the only diagnostic surface.
90
+ export function extAppRecoveryLog(nodeId: string, event: string): void {
91
+ if (typeof window === 'undefined') return;
92
+ const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
93
+ host.__PMX_EXTAPP_LOG ??= [];
94
+ if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
95
+ }
96
+
97
+ let webkitRemountChain: Promise<void> = Promise.resolve();
98
+ export function enqueueWebkitRemount(task: WebkitRemountTask): void {
99
+ webkitRemountChain = webkitRemountChain
100
+ .then(async () => {
101
+ if (!task.remount()) return;
102
+ await task.awaitBoot();
103
+ await new Promise((resolve) => setTimeout(resolve, WEBKIT_REMOUNT_SETTLE_MS));
104
+ })
105
+ .catch(() => {});
80
106
  }
81
107
 
82
108
  export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
@@ -231,7 +257,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
231
257
  const bridgeReadyRef = useRef(false);
232
258
  const themeUnsubRef = useRef<(() => void) | null>(null);
233
259
  const webkitRepaintDoneRef = useRef(false);
234
- const webkitRepaintTimerRef = useRef<number | null>(null);
260
+ const webkitRemountAttemptsRef = useRef(0);
261
+ // Genuine boot signal: set ONLY when the app completes the ui/initialize
262
+ // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
263
+ // which flips status via notifications that resolve even into a dead iframe.
264
+ const appInitializedRef = useRef(false);
265
+ const bootWaitersRef = useRef<Array<() => void>>([]);
266
+ const remountQueuedRef = useRef(false);
267
+ const unmountedRef = useRef(false);
235
268
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
236
269
  const [error, setError] = useState<string | null>(null);
237
270
  const [retryKey, setRetryKey] = useState(0);
@@ -308,23 +341,55 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
308
341
  return () => window.removeEventListener('message', onAxMessage);
309
342
  }, [axEnabled, axToken, nodeId]);
310
343
 
311
- // Finding F (0.2.4/0.2.5): in a WebKit host panel (e.g. the GitHub Copilot app's
312
- // WKWebView, and Bun's headless WebKit WebView) the doubly-nested ext-app iframe
313
- // (workbench iframe → mcp-app.html iframe) can come up as a black tile for nodes
314
- // present at panel-load. The mcp-app shell loads blank, then the app boots over the
315
- // bridge and draws its content AFTER load; under a cold-hydration burst WebKit does
316
- // not composite that late draw, so the layer stays black (clean in Blink, and clean
317
- // for a node created live into an already-idle panel). A parent-side transform/src
318
- // nudge does NOT repair a black layer — only a full remount (new iframe element +
319
- // bridge re-init, what expand+close does) does, and only when it lands in an idle
320
- // moment. So: once the app has booted `ready` for empty apps, `done` for restored
321
- // apps that must replay saved tool output — under WebKit only, force ONE remount,
322
- // SERIALIZED per node (an increasing slot delay) so concurrent ext-apps
323
- // don't re-form a single compositing burst (the 0.2.4 fixed-250ms-on-mount remount
324
- // fired mid-burst before the app had even booted, and all siblings re-raced —
325
- // insufficient). This reliably repaints a SINGLE present-at-load ext-app; a board
326
- // with several ext-apps present at WebKit panel-load can still black out (a host
327
- // compositor limit expand+close or Chrome remains the fallback; see SKILL.md).
344
+ // Enqueue one serialized remount attempt for this node (Finding F recovery).
345
+ // Attempts are capped so a persistently-failing app degrades to the manual
346
+ // fallback (expand+close / Retry) instead of remount-looping forever.
347
+ const WEBKIT_MAX_REMOUNT_ATTEMPTS = 3;
348
+ const scheduleWebkitRemount = (reason: string): void => {
349
+ if (webkitRemountAttemptsRef.current >= WEBKIT_MAX_REMOUNT_ATTEMPTS) {
350
+ extAppRecoveryLog(nodeId, `remount-cap-hit (${reason})`);
351
+ return;
352
+ }
353
+ if (remountQueuedRef.current) return; // an attempt is already queued and has not run yet
354
+ webkitRemountAttemptsRef.current += 1;
355
+ remountQueuedRef.current = true;
356
+ extAppRecoveryLog(nodeId, `remount-queued #${webkitRemountAttemptsRef.current} (${reason})`);
357
+ enqueueWebkitRemount({
358
+ remount: () => {
359
+ remountQueuedRef.current = false;
360
+ if (unmountedRef.current || expandedNodeId.value === nodeId) {
361
+ extAppRecoveryLog(nodeId, 'remount-skipped');
362
+ return false;
363
+ }
364
+ extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
365
+ setRetryKey((k) => k + 1);
366
+ return true;
367
+ },
368
+ awaitBoot: () =>
369
+ new Promise<void>((resolve) => {
370
+ const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
371
+ function finish() {
372
+ window.clearTimeout(timer);
373
+ resolve();
374
+ }
375
+ bootWaitersRef.current.push(finish);
376
+ }),
377
+ });
378
+ };
379
+
380
+ // Finding F (0.2.4/0.2.5, reworked 0.3.1): in a WebKit host panel (e.g. the GitHub
381
+ // Copilot app's WKWebView, and Bun's headless WebKit WebView) the doubly-nested
382
+ // ext-app iframe (workbench iframe → mcp-app.html iframe) can come up as a black
383
+ // tile for nodes present at panel-load. The mcp-app shell loads blank, then the app
384
+ // boots over the bridge and draws its content AFTER load; under a cold-hydration
385
+ // burst WebKit does not composite that late draw, so the layer stays black (clean
386
+ // in Blink, and clean for a node created live into an already-idle panel). A
387
+ // parent-side transform/src nudge does NOT repair a black layer — only a full
388
+ // remount (new iframe element + bridge re-init, what expand+close does) does, and
389
+ // only when it lands in an idle moment. So: once the app has booted — `ready` for
390
+ // empty apps, `done` for restored apps that must replay saved tool output — under
391
+ // WebKit only, enqueue ONE recovery remount through the boot-aware queue above, so
392
+ // concurrent ext-apps remount strictly one at a time instead of re-bursting.
328
393
  // Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
329
394
  useEffect(() => {
330
395
  if (expanded || webkitRepaintDoneRef.current) return;
@@ -332,19 +397,33 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
332
397
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
333
398
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
334
399
  webkitRepaintDoneRef.current = true;
335
- // Serialize: each ext-app repaints into a progressively-quieter panel so the
336
- // cold-hydration burst becomes a sequence of single (always-successful) repaints.
337
- // The timer is held in a ref and cleared only on UNMOUNT (separate effect), NOT in
338
- // this effect's cleanup — otherwise a later status change (ready→done) would cancel
339
- // the one-shot remount before it fires. The gate above runs once per node.
340
- const delayMs = 250 + nextWebkitRepaintSlot() * 450;
341
- webkitRepaintTimerRef.current = window.setTimeout(() => setRetryKey((k) => k + 1), delayMs);
400
+ scheduleWebkitRemount('post-boot-repaint');
342
401
  // eslint-disable-next-line react-hooks/exhaustive-deps
343
402
  }, [status, hasReplayToolResult]);
344
403
 
404
+ // Never-booted watchdog (0.3.1): an iframe whose scripts never ran in the burst
405
+ // 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).
409
+ const WEBKIT_BOOT_WATCHDOG_MS = 6000;
410
+ useEffect(() => {
411
+ if (expanded) return;
412
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
413
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
414
+ if (!iframeDocument.ready) return;
415
+ const timer = window.setTimeout(() => {
416
+ if (appInitializedRef.current || unmountedRef.current) return;
417
+ scheduleWebkitRemount('boot-watchdog');
418
+ }, WEBKIT_BOOT_WATCHDOG_MS);
419
+ return () => window.clearTimeout(timer);
420
+ // eslint-disable-next-line react-hooks/exhaustive-deps
421
+ }, [frameKey, iframeDocument.ready, expanded]);
422
+
345
423
  useEffect(
346
424
  () => () => {
347
- if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
425
+ unmountedRef.current = true;
426
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
348
427
  },
349
428
  [],
350
429
  );
@@ -415,6 +494,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
415
494
  lastSentToolResultRef.current = undefined;
416
495
  toolResultSendingRef.current = null;
417
496
  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.
499
+ appInitializedRef.current = false;
418
500
 
419
501
  const clearFallbackTimer = (): void => {
420
502
  if (!fallbackTimer) return;
@@ -611,12 +693,23 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
611
693
  if (disposed) return;
612
694
  clearFallbackTimer();
613
695
  bridgeReadyRef.current = true;
696
+ appInitializedRef.current = true;
697
+ extAppRecoveryLog(nodeId, 'initialized');
614
698
  setStatus('ready');
615
699
  setError(null);
616
700
  void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
617
701
  .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
618
702
  .then(() => flushToolResult(bridge))
619
- .then(() => nudgeHostContextAfterLayout())
703
+ .then(() => {
704
+ // Settled: handshake + bootstrap replay delivered — the app draws its
705
+ // scene right after this. Release the remount queue (which then adds
706
+ // its own settle pause covering the draw) only from this genuine path;
707
+ // the bootstrap fallback never releases it (a dead iframe must run the
708
+ // queue's bounded timeout instead of green-lighting the next remount).
709
+ extAppRecoveryLog(nodeId, 'settled');
710
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
711
+ nudgeHostContextAfterLayout();
712
+ })
620
713
  .catch((err) => {
621
714
  const msg = err instanceof Error ? err.message : String(err);
622
715
  setError(`Bridge bootstrap failed: ${msg}`);
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { basename, resolve } from 'node:path';
3
3
  import { recomputeCodeGraph } from './code-graph.js';
4
4
  import {
5
5
  canvasState,
@@ -727,7 +727,7 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
727
727
  const rawPath =
728
728
  typeof input.data?.path === 'string' && input.data.path.length > 0 ? input.data.path : (input.content ?? '');
729
729
  const resolved = resolve(rawPath);
730
- const fileName = resolved.split('/').pop() ?? rawPath;
730
+ const fileName = basename(resolved) || rawPath;
731
731
  const data: Record<string, unknown> = {
732
732
  ...(input.data ?? {}),
733
733
  path: resolved,
@@ -767,7 +767,7 @@ function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown>
767
767
 
768
768
  if (!isDataUri && !isUrl && src) {
769
769
  const resolved = resolve(src);
770
- const fileName = resolved.split('/').pop() ?? src;
770
+ const fileName = basename(resolved) || src;
771
771
  const { mimeType } = validateLocalImageFile(resolved);
772
772
  return {
773
773
  ...(input.data ?? {}),
@@ -51,6 +51,7 @@ const SUPPORTED_BATCH_OPS = new Set([
51
51
  'node.remove',
52
52
  'graph.add',
53
53
  'edge.add',
54
+ 'edge.remove',
54
55
  'group.create',
55
56
  'group.add',
56
57
  'group.remove',
@@ -204,7 +205,7 @@ function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
204
205
  return { ok: true, arranged: body.arranged, layout: body.layout };
205
206
  }
206
207
 
207
- // edge.add / group.remove: wire shape already matches the legacy push verbatim.
208
+ // edge.add / edge.remove / group.remove: wire shape matches the push verbatim.
208
209
  return body;
209
210
  }
210
211
 
@@ -356,7 +357,7 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
356
357
  mcp: {
357
358
  toolName: 'canvas_batch',
358
359
  description:
359
- 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
360
+ 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, edge.remove, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
360
361
  formatResult: (result, input) => {
361
362
  const envelope = result as BatchEnvelope;
362
363
  const payload = wantsFullBatch(input) ? envelope : compactBatchResult(envelope);
@@ -1021,13 +1021,26 @@ function resolveCanvasBundleDir(): string {
1021
1021
  return fallback;
1022
1022
  }
1023
1023
 
1024
+ /**
1025
+ * Containment check for bundle-asset serving. Separator-agnostic: on Windows,
1026
+ * `resolve()` returns backslash paths, so comparing against a `${dir}/` template
1027
+ * rejected every asset — /canvas/index.js 404'd and the SPA never booted (the
1028
+ * 0.3.1 Windows report). Normalizing both sides keeps the check exact on POSIX
1029
+ * and correct on win32, and testable with win32-shaped fixtures on any host.
1030
+ */
1031
+ export function isCanvasBundlePath(distPath: string, bundleDir: string): boolean {
1032
+ const normalize = (value: string) => value.replaceAll('\\', '/');
1033
+ const dir = normalize(bundleDir).replace(/\/+$/, '');
1034
+ return normalize(distPath).startsWith(`${dir}/`);
1035
+ }
1036
+
1024
1037
  function serveCanvasStatic(pathname: string): Response | null {
1025
1038
  const fileName = pathname.slice('/canvas/'.length);
1026
1039
  if (!fileName || fileName.includes('..') || fileName.startsWith('/')) return null;
1027
1040
 
1028
1041
  const bundleDir = resolveCanvasBundleDir();
1029
1042
  const distPath = resolve(bundleDir, fileName);
1030
- if (!distPath.startsWith(`${bundleDir}/`)) return null;
1043
+ if (!isCanvasBundlePath(distPath, bundleDir)) return null;
1031
1044
  if (existsSync(distPath)) {
1032
1045
  const ext = extname(fileName);
1033
1046
  return new Response(readFileSync(distPath), {