pmx-canvas 0.2.5 → 0.2.7
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 +87 -0
- package/dist/canvas/index.js +65 -65
- package/dist/types/client/nodes/ExtAppFrame.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +23 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +20 -5
- package/skills/pmx-canvas/references/full-reference.md +24 -7
- package/src/client/App.tsx +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +60 -15
- package/src/mcp/canvas-access.ts +111 -8
- package/src/server/server.ts +10 -2
|
@@ -3,6 +3,7 @@ import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
|
3
3
|
import type { CanvasNodeState } from '../types';
|
|
4
4
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
5
5
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
6
|
+
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
6
7
|
interface ExtAppHostDimensionsTarget {
|
|
7
8
|
clientWidth?: number;
|
|
8
9
|
clientHeight?: number;
|
|
@@ -18,6 +19,8 @@ interface ExtAppHostDimensionsTarget {
|
|
|
18
19
|
* everywhere we can test (Chrome / Codex / Playwright).
|
|
19
20
|
*/
|
|
20
21
|
export declare function isWebKitOnlyHost(userAgent: string): boolean;
|
|
22
|
+
export declare function nextWebkitRepaintSlot(): number;
|
|
23
|
+
export declare function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean;
|
|
21
24
|
export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
|
|
22
25
|
export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
|
|
23
26
|
nextMode: DisplayMode;
|
|
@@ -65,5 +65,28 @@ export interface CanvasAccess {
|
|
|
65
65
|
screenshotAutomationWebView(options?: AutomationScreenshotOptions): Promise<Uint8Array>;
|
|
66
66
|
}
|
|
67
67
|
export declare function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasAccess>;
|
|
68
|
+
/**
|
|
69
|
+
* Finding I (0.2.6): decide whether to ATTACH to the daemon already holding the
|
|
70
|
+
* preferred port instead of spawning a split daemon on a fallback port. True only
|
|
71
|
+
* when the split is not opted in AND the preferred port is held by a healthy canvas
|
|
72
|
+
* daemon that reports a workspace (i.e. a real different-workspace daemon, the
|
|
73
|
+
* "wrong-workspace split" trap — not a free port or a non-canvas occupant). Pure +
|
|
74
|
+
* exported for deterministic testing.
|
|
75
|
+
*/
|
|
76
|
+
export declare function shouldAttachToExistingDaemon(occupant: {
|
|
77
|
+
ok?: boolean;
|
|
78
|
+
workspace?: unknown;
|
|
79
|
+
} | null, allowSplit: boolean): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Finding I (0.2.6, first-binder gap): true when the launch cwd looks like a
|
|
82
|
+
* host/agent config dir rather than a project root — the home dir itself, or a
|
|
83
|
+
* dot-prefixed DIRECT child of home (e.g. `~/.copilot`, `~/.codex`, `~/.claude`,
|
|
84
|
+
* `~/.config`). POSITIVE-signal ONLY — never "absence of project markers", because
|
|
85
|
+
* the MCP/SDK test harness runs from bare `mkdtemp` temp dirs (under `os.tmpdir()`,
|
|
86
|
+
* never under home) that a marker-absence heuristic would misflag. Both sides are
|
|
87
|
+
* canonicalized (realpath) so a symlinked home matches. Pure + exported for tests;
|
|
88
|
+
* FS-safe (defaults to false on any error).
|
|
89
|
+
*/
|
|
90
|
+
export declare function looksLikeIncidentalCwd(cwd: string): boolean;
|
|
68
91
|
export declare function createCanvasAccess(): Promise<CanvasAccess>;
|
|
69
92
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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",
|
|
@@ -58,6 +58,17 @@ Both surfaces report `workspace`. It must match the intended workspace root.
|
|
|
58
58
|
- Target that port and re-check `/health`.
|
|
59
59
|
- `PMX_CANVAS_PORT` is the agent CLI target; the server's startup port is controlled by `--port`
|
|
60
60
|
or `PMX_WEB_CANVAS_PORT`.
|
|
61
|
+
- **MCP transport workspace resolution.** An MCP server (`pmx-canvas --mcp`) holds its own in-memory
|
|
62
|
+
canvas. To avoid the old "wrong-workspace split" (a `--mcp` launched from an incidental dir, e.g.
|
|
63
|
+
`~/.copilot`, silently binding a fallback port the panel never renders): when the preferred port is
|
|
64
|
+
held by a healthy daemon serving a *different* workspace, the MCP server now **attaches** to it
|
|
65
|
+
(inherits its workspace) so writes are visible where the panel renders; and if it launched from an
|
|
66
|
+
incidental host/agent config dir on a *free* port, it still binds but emits a loud stderr warning
|
|
67
|
+
instead of silently adopting that cwd. To pin the intended workspace deterministically set
|
|
68
|
+
**`PMX_CANVAS_WORKSPACE_ROOT=<abs project root>`** (recommended for host adapters); for a genuinely
|
|
69
|
+
separate canvas set `PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1` or a distinct `PMX_CANVAS_PORT`. The CLI's
|
|
70
|
+
query/mutation commands are a thin HTTP client and never start a server of their own (only `serve` /
|
|
71
|
+
`--mcp` spawn a process).
|
|
61
72
|
|
|
62
73
|
## Choose the Smallest Useful Node Type
|
|
63
74
|
|
|
@@ -197,11 +208,15 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
197
208
|
openable.
|
|
198
209
|
- A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
|
|
199
210
|
loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
|
|
200
|
-
paint race on the nested iframe, not a broken node (the session is healthy and
|
|
201
|
-
is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
|
|
202
|
-
panel hydrates). The canvas
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
|
|
212
|
+
`sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
|
|
213
|
+
live after the panel hydrates). The canvas forces a one-time post-boot repaint remount under
|
|
214
|
+
WebKit, which reliably repaints a **single** present-at-load
|
|
215
|
+
ext-app — but a board with **several** ext-apps present at WebKit panel-load can still black out
|
|
216
|
+
(the simultaneous cold-hydration burst overwhelms the WebKit compositor). Recovery is
|
|
217
|
+
deterministic: **expand-then-close** any black tile (forces a fresh mount in the fullscreen
|
|
218
|
+
overlay, which always paints), or open the workbench in a normal browser (Chrome). Do not
|
|
219
|
+
diagnose a healthy app session as a broken node; the durable fix is upstream in the host panel.
|
|
205
220
|
- Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
|
|
206
221
|
reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
|
|
207
222
|
Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
|
|
@@ -157,10 +157,10 @@ file, sends SIGTERM, and cleans up on exit.
|
|
|
157
157
|
|
|
158
158
|
### Verify workspace identity BEFORE mutating (required)
|
|
159
159
|
|
|
160
|
-
> **"Start once, reuse always" has one hard exception
|
|
161
|
-
> another workspace.** A healthy, responsive server on the default port
|
|
162
|
-
> *different project's* canvas (a leftover daemon, or another repo's
|
|
163
|
-
> corrupt the wrong board. Always preflight:
|
|
160
|
+
> **"Start once, reuse always" has one hard exception for direct CLI/HTTP work: never mutate a
|
|
161
|
+
> listener that belongs to another workspace.** A healthy, responsive server on the default port
|
|
162
|
+
> `4313` can be serving a *different project's* canvas (a leftover daemon, or another repo's
|
|
163
|
+
> session). Mutating it directly would corrupt the wrong board. Always preflight:
|
|
164
164
|
|
|
165
165
|
1. **Read `GET /health`** (or `pmx-canvas serve status`). Both return a top-level
|
|
166
166
|
`workspace` field. **`workspace` MUST equal your intended workspace root**
|
|
@@ -172,7 +172,13 @@ file, sends SIGTERM, and cleans up on exit.
|
|
|
172
172
|
`pmx-canvas serve --daemon --no-open --port=<free-port>` — and target that port. (`PMX_CANVAS_PORT`
|
|
173
173
|
alone may still attach to an existing `4313` listener; prefer an explicit `--port`.) Then
|
|
174
174
|
**re-read `/health`** to confirm the workspace now matches.
|
|
175
|
-
4. **
|
|
175
|
+
4. **MCP transport exception:** `pmx-canvas --mcp` launched from an incidental host dir may attach to
|
|
176
|
+
the healthy daemon already on the preferred port when no explicit workspace root is set, so writes
|
|
177
|
+
land in the visible workbench instead of a hidden fallback workspace. Host adapters should set
|
|
178
|
+
`PMX_CANVAS_WORKSPACE_ROOT=<abs project root>` for deterministic targeting; set
|
|
179
|
+
`PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1` or a distinct `PMX_CANVAS_PORT` only when a separate canvas is
|
|
180
|
+
intentional.
|
|
181
|
+
5. **After any version upgrade**, run a behavior canary (e.g. a batch `node.add` with no `type`
|
|
176
182
|
must return `400`) to confirm the listener is the version you expect, not a stale old daemon.
|
|
177
183
|
|
|
178
184
|
## Browser Workflows
|
|
@@ -386,7 +392,18 @@ Agents tend to pack boards too tightly. Give nodes room to breathe — readabili
|
|
|
386
392
|
|
|
387
393
|
### Colors (Semantic)
|
|
388
394
|
|
|
389
|
-
|
|
395
|
+
A `color` parameter is honored as a **renderer** color only for **group** nodes (frame accent) and
|
|
396
|
+
**graph** nodes (series/accent color). It is **not** a renderer parameter for `markdown` / `status` /
|
|
397
|
+
`context` nodes: a top-level `color` on those is dropped on both HTTP and the CLI, and while an
|
|
398
|
+
arbitrary `data.color` you POST under `data` persists like any other `data.*` metadata, it is **not**
|
|
399
|
+
read as a render color for basic node types (report Finding H — renderer-semantic, not raw-storage,
|
|
400
|
+
contract). Their meaning comes from the node **type** and value instead: a `status` node colors its indicator from its
|
|
401
|
+
**phase** (`idle`/`running`/`planning`/`thinking`/`drafting`/`tooling`/`review`/`waiting-approval`/
|
|
402
|
+
`waiting` — e.g. `review` → green, `running` → accent; an unrecognized phase renders gray), and a
|
|
403
|
+
`trace` node from its `status` field (`success` → green, `failed` → red, `running` → accent). To
|
|
404
|
+
color an arbitrary region, group the nodes and set the group's `color`.
|
|
405
|
+
|
|
406
|
+
When you do set a color (group/graph), use this palette consistently to convey meaning:
|
|
390
407
|
- **Green** (`#22c55e`) — success, done, healthy
|
|
391
408
|
- **Yellow** (`#eab308`) — in progress, warning, attention needed
|
|
392
409
|
- **Red** (`#ef4444`) — error, blocked, failing
|
|
@@ -523,7 +540,7 @@ retrying the generic add.
|
|
|
523
540
|
- `content`: markdown text for most types. For `file`, pass the **file path** (e.g. `"src/auth/login.ts"`) —
|
|
524
541
|
the server auto-loads + watches it. For `image`, pass a file path, URL, or data URI.
|
|
525
542
|
- `path`: compatibility alias for image paths only; prefer `content` for new image calls
|
|
526
|
-
- `x`, `y`: position (prefer omitting for auto-layout); `width`, `height`: dimensions (sensible defaults); `
|
|
543
|
+
- `x`, `y`: position (prefer omitting for auto-layout); `width`, `height`: dimensions (sensible defaults); `metadata`: arbitrary JSON. (`color` applies to **group**/**graph** nodes only — it is ignored on basic node types; see Colors (Semantic).)
|
|
527
544
|
- Returns: `{ id: "<node-id>" }` — capture this ID for edges and groups
|
|
528
545
|
|
|
529
546
|
**`canvas_node { action: "update", id, … }`** — Update an existing node in place (preferred over
|
package/src/client/App.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
walkGraph,
|
|
39
39
|
} from './state/canvas-store';
|
|
40
40
|
import { connectSSE } from './state/sse-bridge';
|
|
41
|
+
import { intents } from './state/intent-store';
|
|
41
42
|
import { saveCanvasTheme } from './state/intent-bridge';
|
|
42
43
|
import {
|
|
43
44
|
IconArrange,
|
|
@@ -556,7 +557,7 @@ export function App() {
|
|
|
556
557
|
annotationMode={annotationTool !== null}
|
|
557
558
|
annotationTool={annotationTool}
|
|
558
559
|
/>
|
|
559
|
-
{hasInitialLayout && allNodes.filter((n) => !n.dockPosition).length === 0 && (
|
|
560
|
+
{hasInitialLayout && allNodes.filter((n) => !n.dockPosition).length === 0 && intents.value.size === 0 && (
|
|
560
561
|
<WelcomeCard onOpenPalette={() => setPaletteOpen(true)} />
|
|
561
562
|
)}
|
|
562
563
|
{selectedNodeIds.value.size > 0 && <SelectionBar />}
|
|
@@ -23,6 +23,7 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
23
23
|
|
|
24
24
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
25
25
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
26
|
+
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
26
27
|
|
|
27
28
|
interface ExtAppHostDimensionsTarget {
|
|
28
29
|
clientWidth?: number;
|
|
@@ -58,6 +59,28 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
|
|
|
58
59
|
return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
|
|
59
60
|
}
|
|
60
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(() => { webkitRepaintSlot = 0; }, 3000);
|
|
76
|
+
}
|
|
77
|
+
return slot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
|
|
81
|
+
return hasReplayToolResult ? status === 'done' : status === 'ready' || status === 'done';
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
62
85
|
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
63
86
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
@@ -208,7 +231,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
208
231
|
const bridgeReadyRef = useRef(false);
|
|
209
232
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
210
233
|
const webkitRepaintDoneRef = useRef(false);
|
|
211
|
-
const
|
|
234
|
+
const webkitRepaintTimerRef = useRef<number | null>(null);
|
|
235
|
+
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
212
236
|
const [error, setError] = useState<string | null>(null);
|
|
213
237
|
const [retryKey, setRetryKey] = useState(0);
|
|
214
238
|
|
|
@@ -231,6 +255,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
231
255
|
const maxHeight = node.size.height;
|
|
232
256
|
const nodeId = node.id;
|
|
233
257
|
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
258
|
+
const hasReplayToolResult = toolResult != null;
|
|
234
259
|
const iframeSandbox = resolveExtAppSandbox(null);
|
|
235
260
|
// Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
|
|
236
261
|
// inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
|
|
@@ -277,26 +302,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
277
302
|
return () => window.removeEventListener('message', onAxMessage);
|
|
278
303
|
}, [axEnabled, axToken, nodeId]);
|
|
279
304
|
|
|
280
|
-
// Finding F (0.2.4): in a WebKit host panel (e.g. the GitHub Copilot app's
|
|
281
|
-
// WKWebView
|
|
282
|
-
// iframe
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
305
|
+
// Finding F (0.2.4/0.2.5): in a WebKit host panel (e.g. the GitHub Copilot app's
|
|
306
|
+
// WKWebView, and Bun's headless WebKit WebView) the doubly-nested ext-app iframe
|
|
307
|
+
// (workbench iframe → mcp-app.html iframe) can come up as a black tile for nodes
|
|
308
|
+
// present at panel-load. The mcp-app shell loads blank, then the app boots over the
|
|
309
|
+
// bridge and draws its content AFTER load; under a cold-hydration burst WebKit does
|
|
310
|
+
// not composite that late draw, so the layer stays black (clean in Blink, and clean
|
|
311
|
+
// for a node created live into an already-idle panel). A parent-side transform/src
|
|
312
|
+
// nudge does NOT repair a black layer — only a full remount (new iframe element +
|
|
313
|
+
// bridge re-init, what expand+close does) does, and only when it lands in an idle
|
|
314
|
+
// moment. So: once the app has booted — `ready` for empty apps, `done` for restored
|
|
315
|
+
// apps that must replay saved tool output — under WebKit only, force ONE remount,
|
|
316
|
+
// SERIALIZED per node (an increasing slot delay) so concurrent ext-apps
|
|
317
|
+
// don't re-form a single compositing burst (the 0.2.4 fixed-250ms-on-mount remount
|
|
318
|
+
// fired mid-burst before the app had even booted, and all siblings re-raced —
|
|
319
|
+
// insufficient). This reliably repaints a SINGLE present-at-load ext-app; a board
|
|
320
|
+
// with several ext-apps present at WebKit panel-load can still black out (a host
|
|
321
|
+
// compositor limit — expand+close or Chrome remains the fallback; see SKILL.md).
|
|
322
|
+
// Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
|
|
290
323
|
useEffect(() => {
|
|
291
324
|
if (expanded || webkitRepaintDoneRef.current) return;
|
|
325
|
+
if (!shouldScheduleWebKitRepaint(status, hasReplayToolResult)) return;
|
|
292
326
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
293
327
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
294
328
|
webkitRepaintDoneRef.current = true;
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
329
|
+
// Serialize: each ext-app repaints into a progressively-quieter panel so the
|
|
330
|
+
// cold-hydration burst becomes a sequence of single (always-successful) repaints.
|
|
331
|
+
// The timer is held in a ref and cleared only on UNMOUNT (separate effect), NOT in
|
|
332
|
+
// this effect's cleanup — otherwise a later status change (ready→done) would cancel
|
|
333
|
+
// the one-shot remount before it fires. The gate above runs once per node.
|
|
334
|
+
const delayMs = 250 + nextWebkitRepaintSlot() * 450;
|
|
335
|
+
webkitRepaintTimerRef.current = window.setTimeout(() => setRetryKey((k) => k + 1), delayMs);
|
|
299
336
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
|
+
}, [status, hasReplayToolResult]);
|
|
338
|
+
|
|
339
|
+
useEffect(() => () => {
|
|
340
|
+
if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
|
|
300
341
|
}, []);
|
|
301
342
|
|
|
302
343
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
@@ -829,6 +870,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
829
870
|
{...iframeDocument.attributes}
|
|
830
871
|
sandbox={iframeSandbox}
|
|
831
872
|
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
873
|
+
// NB: do NOT add the `.mcp-app-frame` GPU-layer class (translateZ(0)) here —
|
|
874
|
+
// it creates a stacking context that breaks the AX emit→ack round-trip in the
|
|
875
|
+
// expanded ext-app overlay (#55 e2e); the post-boot WebKit repaint remount
|
|
876
|
+
// below recovers a single present-at-load ext-app without it (Finding F).
|
|
832
877
|
style={{
|
|
833
878
|
flex: 1,
|
|
834
879
|
width: '100%',
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
4
|
import {
|
|
4
5
|
createCanvas,
|
|
5
6
|
canvasState,
|
|
@@ -526,24 +527,126 @@ export async function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasA
|
|
|
526
527
|
return remoteBaseUrl ? new RemoteCanvasAccess(remoteBaseUrl) : access;
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Finding I (0.2.6): decide whether to ATTACH to the daemon already holding the
|
|
532
|
+
* preferred port instead of spawning a split daemon on a fallback port. True only
|
|
533
|
+
* when the split is not opted in AND the preferred port is held by a healthy canvas
|
|
534
|
+
* daemon that reports a workspace (i.e. a real different-workspace daemon, the
|
|
535
|
+
* "wrong-workspace split" trap — not a free port or a non-canvas occupant). Pure +
|
|
536
|
+
* exported for deterministic testing.
|
|
537
|
+
*/
|
|
538
|
+
export function shouldAttachToExistingDaemon(
|
|
539
|
+
occupant: { ok?: boolean; workspace?: unknown } | null,
|
|
540
|
+
allowSplit: boolean,
|
|
541
|
+
): boolean {
|
|
542
|
+
return !allowSplit && occupant?.ok === true && typeof occupant.workspace === 'string' && occupant.workspace.length > 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Finding I (0.2.6, first-binder gap): true when the launch cwd looks like a
|
|
547
|
+
* host/agent config dir rather than a project root — the home dir itself, or a
|
|
548
|
+
* dot-prefixed DIRECT child of home (e.g. `~/.copilot`, `~/.codex`, `~/.claude`,
|
|
549
|
+
* `~/.config`). POSITIVE-signal ONLY — never "absence of project markers", because
|
|
550
|
+
* the MCP/SDK test harness runs from bare `mkdtemp` temp dirs (under `os.tmpdir()`,
|
|
551
|
+
* never under home) that a marker-absence heuristic would misflag. Both sides are
|
|
552
|
+
* canonicalized (realpath) so a symlinked home matches. Pure + exported for tests;
|
|
553
|
+
* FS-safe (defaults to false on any error).
|
|
554
|
+
*/
|
|
555
|
+
export function looksLikeIncidentalCwd(cwd: string): boolean {
|
|
556
|
+
let home: string;
|
|
557
|
+
try {
|
|
558
|
+
home = canonicalWorkspacePath(homedir());
|
|
559
|
+
} catch {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
if (!home || home === '/') return false;
|
|
563
|
+
const canonical = canonicalWorkspacePath(cwd);
|
|
564
|
+
if (canonical === home) return true;
|
|
565
|
+
// A dot-prefixed direct child of home: ~/.copilot, ~/.codex, ~/.claude, ~/.config …
|
|
566
|
+
return dirname(canonical) === home && basename(canonical).startsWith('.');
|
|
567
|
+
}
|
|
568
|
+
|
|
529
569
|
export async function createCanvasAccess(): Promise<CanvasAccess> {
|
|
530
|
-
|
|
570
|
+
// PMX_CANVAS_WORKSPACE_ROOT (Finding I escape hatch): an explicit project root the
|
|
571
|
+
// host can pass so the MCP server keys off it instead of an incidental launch cwd
|
|
572
|
+
// (e.g. ~/.copilot). When set, it overrides process.cwd() for the whole acquisition
|
|
573
|
+
// and suppresses the incidental-cwd guard below (the operator stated intent).
|
|
574
|
+
const override = process.env.PMX_CANVAS_WORKSPACE_ROOT?.trim();
|
|
575
|
+
const explicitRoot = Boolean(override);
|
|
576
|
+
const workspaceRoot = explicitRoot ? resolve(override as string) : resolve(process.cwd());
|
|
531
577
|
const port = targetPort();
|
|
532
578
|
const remoteBaseUrl = await findExistingCanvasServer(workspaceRoot, port);
|
|
533
579
|
if (remoteBaseUrl) return new RemoteCanvasAccess(remoteBaseUrl);
|
|
534
580
|
|
|
535
|
-
// No
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
581
|
+
// No SAME-workspace server to attach to. The preferred port may still be held by
|
|
582
|
+
// a healthy canvas daemon serving a DIFFERENT workspace. The old behavior silently
|
|
583
|
+
// started our own canvas on a FALLBACK port adopting this process's launch cwd as
|
|
584
|
+
// the workspace — but the open workbench panel renders the PREFERRED port and never
|
|
585
|
+
// shows that fallback, so MCP writes land on a phantom workspace nobody sees (report
|
|
586
|
+
// Finding I, the "wrong-workspace daemon split"; the launch cwd is often incidental,
|
|
587
|
+
// e.g. the host spawns `--mcp` from ~/.copilot). Default to the safer behavior:
|
|
588
|
+
// ATTACH to the existing preferred-port daemon (inherit its workspace) so writes are
|
|
589
|
+
// visible where the human is looking. Opt back into a separate canvas with
|
|
590
|
+
// PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1 or by pinning a distinct PMX_CANVAS_PORT.
|
|
591
|
+
// An explicit PMX_CANVAS_WORKSPACE_ROOT is an operator statement of intent and WINS:
|
|
592
|
+
// skip the heuristic attach so the pinned root is honored (it binds its own daemon —
|
|
593
|
+
// on a fallback port if the preferred port is foreign-held) rather than silently
|
|
594
|
+
// inheriting the foreign daemon's workspace. So the pin is genuinely deterministic.
|
|
595
|
+
const occupantBaseUrl = `http://127.0.0.1:${port}`;
|
|
596
|
+
const allowSplit = process.env.PMX_CANVAS_ALLOW_WORKSPACE_SPLIT === '1';
|
|
597
|
+
if (!explicitRoot) {
|
|
598
|
+
const occupant = await readHealth(occupantBaseUrl);
|
|
599
|
+
if (occupant && shouldAttachToExistingDaemon(occupant, allowSplit)) {
|
|
600
|
+
// stderr only — stdout is the MCP stdio JSON-RPC channel.
|
|
601
|
+
process.stderr.write(
|
|
602
|
+
`[pmx-canvas] port ${port} is serving a different workspace (${occupant.workspace}); this ` +
|
|
603
|
+
`MCP server launched from ${workspaceRoot}. Attaching to that canvas so writes are visible ` +
|
|
604
|
+
`in the open workbench instead of splitting to a hidden fallback port. For a SEPARATE canvas, ` +
|
|
605
|
+
`set PMX_CANVAS_PORT to a free port or PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1.\n`,
|
|
606
|
+
);
|
|
607
|
+
return new RemoteCanvasAccess(occupantBaseUrl);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// First-binder gap (Finding I): the attach branch above only fires when the
|
|
612
|
+
// preferred port is HELD. If it is FREE (or a non-canvas occupant) AND this process
|
|
613
|
+
// launched from an incidental host/agent config dir (e.g. ~/.copilot), binding the
|
|
614
|
+
// preferred port here would adopt that incidental cwd as the workspace — a canvas the
|
|
615
|
+
// human's project panel would never render. Don't silently do that.
|
|
616
|
+
if (!allowSplit && !explicitRoot && looksLikeIncidentalCwd(workspaceRoot)) {
|
|
617
|
+
// Race-tolerant: a real daemon may have appeared on the preferred port since the
|
|
618
|
+
// first probe. Attach to ANY healthy canvas now there (inherit its workspace)
|
|
619
|
+
// rather than inventing a phantom workspace under the incidental launch dir.
|
|
620
|
+
const racedOccupant = await readHealth(occupantBaseUrl);
|
|
621
|
+
if (racedOccupant?.ok === true) {
|
|
622
|
+
process.stderr.write(
|
|
623
|
+
`[pmx-canvas] launch cwd ${workspaceRoot} looks like a host config dir; attaching to the ` +
|
|
624
|
+
`canvas now on port ${port}.\n`,
|
|
625
|
+
);
|
|
626
|
+
return new RemoteCanvasAccess(occupantBaseUrl);
|
|
627
|
+
}
|
|
628
|
+
// Still free: bind it anyway (the agent always gets a working canvas) but warn
|
|
629
|
+
// loudly so a wrong-workspace canvas is diagnosed, not silent. stderr only.
|
|
630
|
+
process.stderr.write(
|
|
631
|
+
`[pmx-canvas] launch cwd ${workspaceRoot} looks like a host/agent config dir, not a project ` +
|
|
632
|
+
`root. This canvas will persist under it and may not be visible in a workbench opened for ` +
|
|
633
|
+
`your project. Set PMX_CANVAS_WORKSPACE_ROOT=/abs/project to target it, PMX_CANVAS_URL to ` +
|
|
634
|
+
`attach to a running daemon, or PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1 / PMX_CANVAS_PORT=<free ` +
|
|
635
|
+
`port> for a deliberate separate canvas.\n`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Either the split is opted in, the root is explicit, the cwd is a real project, or
|
|
640
|
+
// the preferred port is genuinely free / not a canvas daemon. Allow a port fallback
|
|
641
|
+
// so a non-canvas occupant doesn't crash this session with EADDRINUSE — start our
|
|
642
|
+
// own canvas and explain how to share one.
|
|
539
643
|
const canvas = createCanvas({ port });
|
|
540
644
|
await canvas.start({ open: true, allowPortFallback: true });
|
|
541
645
|
const boundPort = canvas.port;
|
|
542
646
|
if (boundPort !== port) {
|
|
543
|
-
const occupant = await readHealth(
|
|
647
|
+
const occupant = await readHealth(occupantBaseUrl);
|
|
544
648
|
const occupantWorkspace =
|
|
545
649
|
typeof occupant?.workspace === 'string' ? ` (serving ${occupant.workspace})` : '';
|
|
546
|
-
// stderr only — stdout is the MCP stdio JSON-RPC channel.
|
|
547
650
|
process.stderr.write(
|
|
548
651
|
`[pmx-canvas] preferred port ${port} was in use${occupantWorkspace}; ` +
|
|
549
652
|
`started this canvas on port ${boundPort} instead. To share one canvas, run the daemon ` +
|
package/src/server/server.ts
CHANGED
|
@@ -422,7 +422,10 @@ async function withCanvasAutomationWebViewTimeout<T>(task: Promise<T>, action: s
|
|
|
422
422
|
reject(
|
|
423
423
|
new Error(
|
|
424
424
|
`Timed out after ${getCanvasAutomationWebViewTimeoutMs()}ms while ${action}. ` +
|
|
425
|
-
'Bun.WebView may be unavailable in this environment
|
|
425
|
+
'The Bun.WebView backend may be slow to launch or unavailable in this environment ' +
|
|
426
|
+
'(the chrome backend is known-flaky on some hosts). On macOS prefer the webkit ' +
|
|
427
|
+
'backend (start --backend webkit), or raise PMX_CANVAS_WEBVIEW_TIMEOUT_MS for a ' +
|
|
428
|
+
'slow-but-available backend.',
|
|
426
429
|
),
|
|
427
430
|
);
|
|
428
431
|
}, getCanvasAutomationWebViewTimeoutMs());
|
|
@@ -3482,7 +3485,12 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3482
3485
|
return typeof server.port === 'number' ? loopbackBaseUrl(server.port) : null;
|
|
3483
3486
|
}
|
|
3484
3487
|
|
|
3485
|
-
|
|
3488
|
+
// An explicit `options.workspaceRoot` wins. Otherwise honor PMX_CANVAS_WORKSPACE_ROOT
|
|
3489
|
+
// (Finding I escape hatch) before falling back to the launch cwd, so a host that
|
|
3490
|
+
// spawns from an incidental dir (e.g. ~/.copilot) can still pin the real project root
|
|
3491
|
+
// for the daemon it binds — not just for the MCP same-workspace lookup.
|
|
3492
|
+
const envWorkspaceRoot = process.env.PMX_CANVAS_WORKSPACE_ROOT?.trim();
|
|
3493
|
+
const workspaceRoot = options.workspaceRoot ?? (envWorkspaceRoot ? resolve(envWorkspaceRoot) : process.cwd());
|
|
3486
3494
|
activeWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot);
|
|
3487
3495
|
if (options.autoOpenBrowser !== undefined) {
|
|
3488
3496
|
primaryWorkbenchAutoOpenEnabled = options.autoOpenBrowser;
|