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.
- package/CHANGELOG.md +35 -0
- package/dist/canvas/index.js +65 -65
- package/dist/types/cli/agent.d.ts +9 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +9 -1
- package/dist/types/server/server.d.ts +8 -0
- package/docs/cli.md +10 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +12 -9
- package/skills/pmx-canvas/references/full-reference.md +7 -0
- package/src/cli/agent.ts +68 -1
- package/src/cli/index.ts +21 -1
- package/src/client/nodes/ExtAppFrame.tsx +138 -45
- package/src/server/canvas-operations.ts +3 -3
- package/src/server/operations/ops/batch.ts +3 -2
- package/src/server/server.ts +14 -1
|
@@ -10,4 +10,12 @@
|
|
|
10
10
|
* - Idempotent operations where possible
|
|
11
11
|
* - --yes for destructive actions, --dry-run for preview
|
|
12
12
|
*/
|
|
13
|
-
|
|
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
|
|
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
|
|
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.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).
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
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
|
-
|
|
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):
|
|
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
|
+
}
|
|
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
|
|
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
|
-
//
|
|
312
|
-
//
|
|
313
|
-
// (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
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), {
|