pmx-canvas 0.2.6 → 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.
@@ -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.6",
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,15 +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 caveat (wrong-workspace split).** An MCP server (`pmx-canvas --mcp`) holds its own
62
- in-memory canvas and, if its preferred port is already taken by a *different* workspace's daemon,
63
- binds the next free port adopting **its own launch `cwd`** as the workspace so its writes land on
64
- a daemon the browser panel never renders (it self-reports this fallback on stderr). Before trusting
65
- MCP-written state, confirm the MCP server's workspace matches the panel's (`/health` on both ports)
66
- and that no stray higher-numbered listener exists. The durable fix is to launch the MCP server with
67
- `cwd=<project>` or `PMX_CANVAS_PORT=<panel-port>`. The CLI's query/mutation commands are a thin HTTP
68
- client and never start a server of their own (only `serve` / `--mcp` spawn a process), so prefer
69
- those CLI commands for watcher/automation loops until the launch config is pinned.
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).
70
72
 
71
73
  ## Choose the Smallest Useful Node Type
72
74
 
@@ -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: never reuse a listener that belongs to
161
- > another workspace.** A healthy, responsive server on the default port `4313` can be serving a
162
- > *different project's* canvas (a leftover daemon, or another repo's session). Mutating it would
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. **After any version upgrade**, run a behavior canary (e.g. a batch `node.add` with no `type`
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,10 +392,12 @@ Agents tend to pack boards too tightly. Give nodes room to breathe — readabili
386
392
 
387
393
  ### Colors (Semantic)
388
394
 
389
- A `color` parameter is honored only for **group** nodes (frame accent) and **graph** nodes
390
- (series/accent color). It is **not** a parameter for `markdown` / `status` / `context` nodes — a
391
- top-level `color` on those is silently ignored over both HTTP and the CLI (report Finding H). Their
392
- meaning comes from the node **type** and value instead: a `status` node colors its indicator from its
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
393
401
  **phase** (`idle`/`running`/`planning`/`thinking`/`drafting`/`tooling`/`review`/`waiting-approval`/
394
402
  `waiting` — e.g. `review` → green, `running` → accent; an unrecognized phase renders gray), and a
395
403
  `trace` node from its `status` field (`success` → green, `failed` → red, `running` → accent). To
@@ -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 />}
@@ -1,5 +1,6 @@
1
1
  import { realpathSync } from 'node:fs';
2
- import { resolve } from 'node:path';
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
- const workspaceRoot = resolve(process.cwd());
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 same-workspace server to attach to. Allow a port fallback so a daemon
536
- // already holding the preferred port (e.g. one serving a *different*
537
- // workspace) doesn't crash this MCP/SDK session with EADDRINUSE start our
538
- // own canvas on a free port instead, and explain how to share one if intended.
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(`http://127.0.0.1:${port}`);
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 ` +
@@ -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
- const workspaceRoot = options.workspaceRoot ?? process.cwd();
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;