pmx-canvas 0.2.6 → 0.3.0
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 +156 -1
- package/Readme.md +13 -11
- package/dist/canvas/index.js +18 -18
- package/dist/json-render/index.css +1 -1
- package/dist/types/mcp/canvas-access.d.ts +23 -7
- package/dist/types/server/ax-state-manager.d.ts +3 -2
- package/dist/types/server/bundled-skills.d.ts +3 -3
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/ext-app-lookup.d.ts +1 -3
- package/dist/types/server/operations/composites.d.ts +29 -23
- package/dist/types/server/operations/http.d.ts +2 -1
- package/dist/types/shared/ax-intent.d.ts +1 -1
- package/docs/RELEASE.md +23 -8
- package/docs/ax-host-adapter-contract.md +7 -7
- package/docs/ax-state-contract.md +7 -6
- package/docs/bun-webview-integration.md +5 -5
- package/docs/cli.md +1 -1
- package/docs/http-api.md +7 -3
- package/docs/mcp.md +121 -100
- package/docs/node-types.md +45 -35
- package/docs/plans/plan-006-mcp-tool-consolidation.md +3 -1
- package/docs/plans/plan-008-registry-finish.md +2 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +51 -0
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +1 -1
- package/docs/tech-debt-assessment-2026-07.md +135 -0
- package/package.json +1 -1
- package/skills/data-analysis/SKILL.md +3 -3
- package/skills/frontend-design/SKILL.md +3 -2
- package/skills/json-render-mcp/SKILL.md +4 -3
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/pmx-canvas/SKILL.md +20 -15
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/ax-html-control-surface.md +3 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +2 -2
- package/skills/pmx-canvas/references/full-reference.md +65 -62
- package/skills/pmx-canvas/references/html-primitives.md +3 -3
- package/skills/tufte-viz/SKILL.md +5 -4
- package/skills/web-artifacts-builder/SKILL.md +4 -4
- package/src/client/App.tsx +2 -1
- package/src/mcp/canvas-access.ts +115 -107
- package/src/mcp/server.ts +74 -201
- package/src/server/ax-state-manager.ts +3 -2
- package/src/server/bundled-skills.ts +3 -3
- package/src/server/canvas-schema.ts +46 -29
- package/src/server/canvas-state.ts +5 -0
- package/src/server/code-graph.ts +1 -1
- package/src/server/ext-app-lookup.ts +1 -8
- package/src/server/index.ts +1 -1
- package/src/server/intent-registry.ts +1 -1
- package/src/server/operations/composites.ts +39 -39
- package/src/server/operations/http.ts +3 -2
- package/src/server/operations/mcp.ts +7 -6
- package/src/server/operations/ops/app.ts +4 -4
- package/src/server/operations/ops/nodes.ts +5 -5
- package/src/server/operations/ops/query.ts +19 -8
- package/src/server/operations/ops/snapshots.ts +6 -6
- package/src/server/server.ts +40 -4
- package/src/shared/ax-intent.ts +1 -1
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,
|
|
@@ -15,15 +16,12 @@ import {
|
|
|
15
16
|
type OperationInvoker,
|
|
16
17
|
} from '../server/operations/index.js';
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// migrated to the operation registry (mcpapp.open / diagram.open /
|
|
19
|
+
// openMcpApp / addDiagram / buildWebArtifact / refreshWebpageNode / addHtmlNode /
|
|
20
|
+
// addHtmlPrimitive CanvasAccess methods + their type aliases removed with the
|
|
21
|
+
// standalone MCP tools (plan-008 Wave 4; refresh/html tools removed v0.3.0):
|
|
22
|
+
// those tools migrated to the operation registry (mcpapp.open / diagram.open /
|
|
22
23
|
// webartifact.build) and the composite/registry tools dispatch via the invoker,
|
|
23
24
|
// not CanvasAccess. The public SDK PmxCanvas methods are unchanged.
|
|
24
|
-
type AddHtmlNodeInput = Parameters<PmxCanvas['addHtmlNode']>[0];
|
|
25
|
-
type AddHtmlPrimitiveInput = Parameters<PmxCanvas['addHtmlPrimitive']>[0];
|
|
26
|
-
type AddHtmlPrimitiveResult = ReturnType<PmxCanvas['addHtmlPrimitive']>;
|
|
27
25
|
type AxStateResult = ReturnType<PmxCanvas['getAxState']>;
|
|
28
26
|
type AxContextResult = ReturnType<PmxCanvas['getAxContext']>;
|
|
29
27
|
type SubmitAxInteractionInput = Parameters<PmxCanvas['submitAxInteraction']>[0];
|
|
@@ -55,11 +53,6 @@ interface HealthResponse {
|
|
|
55
53
|
workspace?: string;
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
interface NodeResponse {
|
|
59
|
-
id?: string;
|
|
60
|
-
node?: { id?: string };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
56
|
export interface CanvasAccess {
|
|
64
57
|
readonly port: number;
|
|
65
58
|
readonly remoteBaseUrl: string | null;
|
|
@@ -67,9 +60,6 @@ export interface CanvasAccess {
|
|
|
67
60
|
invoker(): OperationInvoker;
|
|
68
61
|
getLayout(): Promise<CanvasLayout>;
|
|
69
62
|
getNode(id: string): Promise<CanvasNodeState | undefined>;
|
|
70
|
-
refreshWebpageNode(id: string, url?: string): Promise<RefreshWebpageNodeResult>;
|
|
71
|
-
addHtmlNode(input: AddHtmlNodeInput): Promise<string>;
|
|
72
|
-
addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult>;
|
|
73
63
|
getAxState(): Promise<AxStateResult>;
|
|
74
64
|
getAxContext(options?: { consumer?: string }): Promise<AxContextResult>;
|
|
75
65
|
getAxTimeline(query?: GetAxTimelineQuery): Promise<GetAxTimelineResult>;
|
|
@@ -118,20 +108,6 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
118
108
|
return this.canvas.getNode(id);
|
|
119
109
|
}
|
|
120
110
|
|
|
121
|
-
async refreshWebpageNode(id: string, url?: string): Promise<RefreshWebpageNodeResult> {
|
|
122
|
-
return await this.canvas.refreshWebpageNode(id, url);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
|
|
126
|
-
// PmxCanvas.addHtmlNode returns the created node; the CanvasAccess contract
|
|
127
|
-
// is a bare id string, so extract it (mirrors addNode above).
|
|
128
|
-
return this.canvas.addHtmlNode(input).id;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
|
|
132
|
-
return this.canvas.addHtmlPrimitive(input);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
111
|
async getAxState(): Promise<AxStateResult> {
|
|
136
112
|
return this.canvas.getAxState();
|
|
137
113
|
}
|
|
@@ -248,17 +224,6 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
248
224
|
return parsed as T;
|
|
249
225
|
}
|
|
250
226
|
|
|
251
|
-
private async requestNodeId(method: string, path: string, body?: unknown): Promise<string> {
|
|
252
|
-
const response = await this.requestJson<NodeResponse>(method, path, body);
|
|
253
|
-
const id = typeof response.id === 'string'
|
|
254
|
-
? response.id
|
|
255
|
-
: typeof response.node?.id === 'string'
|
|
256
|
-
? response.node.id
|
|
257
|
-
: '';
|
|
258
|
-
if (!id) throw new Error('Canvas response did not include a node id.');
|
|
259
|
-
return id;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
227
|
async getLayout(): Promise<CanvasLayout> {
|
|
263
228
|
return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state?includeBlobs=true');
|
|
264
229
|
}
|
|
@@ -284,65 +249,6 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
284
249
|
return parsed as CanvasNodeState;
|
|
285
250
|
}
|
|
286
251
|
|
|
287
|
-
async refreshWebpageNode(id: string, url?: string): Promise<RefreshWebpageNodeResult> {
|
|
288
|
-
return await this.requestJson<RefreshWebpageNodeResult>('POST', `/api/canvas/node/${encodeURIComponent(id)}/refresh`, {
|
|
289
|
-
...(url ? { url } : {}),
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
|
|
294
|
-
const {
|
|
295
|
-
summary,
|
|
296
|
-
agentSummary,
|
|
297
|
-
description,
|
|
298
|
-
presentation,
|
|
299
|
-
slideTitles,
|
|
300
|
-
embeddedNodeIds,
|
|
301
|
-
embeddedUrls,
|
|
302
|
-
axCapabilities,
|
|
303
|
-
...rest
|
|
304
|
-
} = input as AddHtmlNodeInput & {
|
|
305
|
-
summary?: string;
|
|
306
|
-
agentSummary?: string;
|
|
307
|
-
description?: string;
|
|
308
|
-
presentation?: boolean;
|
|
309
|
-
slideTitles?: string[];
|
|
310
|
-
embeddedNodeIds?: string[];
|
|
311
|
-
embeddedUrls?: string[];
|
|
312
|
-
axCapabilities?: { enabled?: boolean; allowed?: string[] };
|
|
313
|
-
};
|
|
314
|
-
return await this.requestNodeId('POST', '/api/canvas/node', {
|
|
315
|
-
type: 'html',
|
|
316
|
-
...rest,
|
|
317
|
-
data: {
|
|
318
|
-
...(typeof summary === 'string' ? { summary } : {}),
|
|
319
|
-
...(typeof agentSummary === 'string' ? { agentSummary } : {}),
|
|
320
|
-
...(typeof description === 'string' ? { description } : {}),
|
|
321
|
-
...(presentation === true ? { presentation: true } : {}),
|
|
322
|
-
...(Array.isArray(slideTitles) ? { slideTitles } : {}),
|
|
323
|
-
...(Array.isArray(embeddedNodeIds) ? { embeddedNodeIds } : {}),
|
|
324
|
-
...(Array.isArray(embeddedUrls) ? { embeddedUrls } : {}),
|
|
325
|
-
...(axCapabilities ? { axCapabilities } : {}),
|
|
326
|
-
},
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
|
|
331
|
-
const response = await this.requestJson<{
|
|
332
|
-
id?: string;
|
|
333
|
-
node?: { id?: string };
|
|
334
|
-
primitive?: { kind?: string; title?: string; htmlBytes?: number };
|
|
335
|
-
}>('POST', '/api/canvas/node', { type: 'html', ...input, primitive: input.kind });
|
|
336
|
-
const id = typeof response.id === 'string' ? response.id : response.node?.id;
|
|
337
|
-
if (!id) throw new Error('html primitive response did not include a node id.');
|
|
338
|
-
return {
|
|
339
|
-
id,
|
|
340
|
-
kind: input.kind,
|
|
341
|
-
title: response.primitive?.title ?? input.title ?? input.kind,
|
|
342
|
-
htmlBytes: response.primitive?.htmlBytes ?? 0,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
252
|
async getHistory(): Promise<HistoryResult> {
|
|
347
253
|
return await this.requestJson<HistoryResult>('GET', '/api/canvas/history');
|
|
348
254
|
}
|
|
@@ -526,24 +432,126 @@ export async function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasA
|
|
|
526
432
|
return remoteBaseUrl ? new RemoteCanvasAccess(remoteBaseUrl) : access;
|
|
527
433
|
}
|
|
528
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Finding I (0.2.6): decide whether to ATTACH to the daemon already holding the
|
|
437
|
+
* preferred port instead of spawning a split daemon on a fallback port. True only
|
|
438
|
+
* when the split is not opted in AND the preferred port is held by a healthy canvas
|
|
439
|
+
* daemon that reports a workspace (i.e. a real different-workspace daemon, the
|
|
440
|
+
* "wrong-workspace split" trap — not a free port or a non-canvas occupant). Pure +
|
|
441
|
+
* exported for deterministic testing.
|
|
442
|
+
*/
|
|
443
|
+
export function shouldAttachToExistingDaemon(
|
|
444
|
+
occupant: { ok?: boolean; workspace?: unknown } | null,
|
|
445
|
+
allowSplit: boolean,
|
|
446
|
+
): boolean {
|
|
447
|
+
return !allowSplit && occupant?.ok === true && typeof occupant.workspace === 'string' && occupant.workspace.length > 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Finding I (0.2.6, first-binder gap): true when the launch cwd looks like a
|
|
452
|
+
* host/agent config dir rather than a project root — the home dir itself, or a
|
|
453
|
+
* dot-prefixed DIRECT child of home (e.g. `~/.copilot`, `~/.codex`, `~/.claude`,
|
|
454
|
+
* `~/.config`). POSITIVE-signal ONLY — never "absence of project markers", because
|
|
455
|
+
* the MCP/SDK test harness runs from bare `mkdtemp` temp dirs (under `os.tmpdir()`,
|
|
456
|
+
* never under home) that a marker-absence heuristic would misflag. Both sides are
|
|
457
|
+
* canonicalized (realpath) so a symlinked home matches. Pure + exported for tests;
|
|
458
|
+
* FS-safe (defaults to false on any error).
|
|
459
|
+
*/
|
|
460
|
+
export function looksLikeIncidentalCwd(cwd: string): boolean {
|
|
461
|
+
let home: string;
|
|
462
|
+
try {
|
|
463
|
+
home = canonicalWorkspacePath(homedir());
|
|
464
|
+
} catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
if (!home || home === '/') return false;
|
|
468
|
+
const canonical = canonicalWorkspacePath(cwd);
|
|
469
|
+
if (canonical === home) return true;
|
|
470
|
+
// A dot-prefixed direct child of home: ~/.copilot, ~/.codex, ~/.claude, ~/.config …
|
|
471
|
+
return dirname(canonical) === home && basename(canonical).startsWith('.');
|
|
472
|
+
}
|
|
473
|
+
|
|
529
474
|
export async function createCanvasAccess(): Promise<CanvasAccess> {
|
|
530
|
-
|
|
475
|
+
// PMX_CANVAS_WORKSPACE_ROOT (Finding I escape hatch): an explicit project root the
|
|
476
|
+
// host can pass so the MCP server keys off it instead of an incidental launch cwd
|
|
477
|
+
// (e.g. ~/.copilot). When set, it overrides process.cwd() for the whole acquisition
|
|
478
|
+
// and suppresses the incidental-cwd guard below (the operator stated intent).
|
|
479
|
+
const override = process.env.PMX_CANVAS_WORKSPACE_ROOT?.trim();
|
|
480
|
+
const explicitRoot = Boolean(override);
|
|
481
|
+
const workspaceRoot = explicitRoot ? resolve(override as string) : resolve(process.cwd());
|
|
531
482
|
const port = targetPort();
|
|
532
483
|
const remoteBaseUrl = await findExistingCanvasServer(workspaceRoot, port);
|
|
533
484
|
if (remoteBaseUrl) return new RemoteCanvasAccess(remoteBaseUrl);
|
|
534
485
|
|
|
535
|
-
// No
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
486
|
+
// No SAME-workspace server to attach to. The preferred port may still be held by
|
|
487
|
+
// a healthy canvas daemon serving a DIFFERENT workspace. The old behavior silently
|
|
488
|
+
// started our own canvas on a FALLBACK port adopting this process's launch cwd as
|
|
489
|
+
// the workspace — but the open workbench panel renders the PREFERRED port and never
|
|
490
|
+
// shows that fallback, so MCP writes land on a phantom workspace nobody sees (report
|
|
491
|
+
// Finding I, the "wrong-workspace daemon split"; the launch cwd is often incidental,
|
|
492
|
+
// e.g. the host spawns `--mcp` from ~/.copilot). Default to the safer behavior:
|
|
493
|
+
// ATTACH to the existing preferred-port daemon (inherit its workspace) so writes are
|
|
494
|
+
// visible where the human is looking. Opt back into a separate canvas with
|
|
495
|
+
// PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1 or by pinning a distinct PMX_CANVAS_PORT.
|
|
496
|
+
// An explicit PMX_CANVAS_WORKSPACE_ROOT is an operator statement of intent and WINS:
|
|
497
|
+
// skip the heuristic attach so the pinned root is honored (it binds its own daemon —
|
|
498
|
+
// on a fallback port if the preferred port is foreign-held) rather than silently
|
|
499
|
+
// inheriting the foreign daemon's workspace. So the pin is genuinely deterministic.
|
|
500
|
+
const occupantBaseUrl = `http://127.0.0.1:${port}`;
|
|
501
|
+
const allowSplit = process.env.PMX_CANVAS_ALLOW_WORKSPACE_SPLIT === '1';
|
|
502
|
+
if (!explicitRoot) {
|
|
503
|
+
const occupant = await readHealth(occupantBaseUrl);
|
|
504
|
+
if (occupant && shouldAttachToExistingDaemon(occupant, allowSplit)) {
|
|
505
|
+
// stderr only — stdout is the MCP stdio JSON-RPC channel.
|
|
506
|
+
process.stderr.write(
|
|
507
|
+
`[pmx-canvas] port ${port} is serving a different workspace (${occupant.workspace}); this ` +
|
|
508
|
+
`MCP server launched from ${workspaceRoot}. Attaching to that canvas so writes are visible ` +
|
|
509
|
+
`in the open workbench instead of splitting to a hidden fallback port. For a SEPARATE canvas, ` +
|
|
510
|
+
`set PMX_CANVAS_PORT to a free port or PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1.\n`,
|
|
511
|
+
);
|
|
512
|
+
return new RemoteCanvasAccess(occupantBaseUrl);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// First-binder gap (Finding I): the attach branch above only fires when the
|
|
517
|
+
// preferred port is HELD. If it is FREE (or a non-canvas occupant) AND this process
|
|
518
|
+
// launched from an incidental host/agent config dir (e.g. ~/.copilot), binding the
|
|
519
|
+
// preferred port here would adopt that incidental cwd as the workspace — a canvas the
|
|
520
|
+
// human's project panel would never render. Don't silently do that.
|
|
521
|
+
if (!allowSplit && !explicitRoot && looksLikeIncidentalCwd(workspaceRoot)) {
|
|
522
|
+
// Race-tolerant: a real daemon may have appeared on the preferred port since the
|
|
523
|
+
// first probe. Attach to ANY healthy canvas now there (inherit its workspace)
|
|
524
|
+
// rather than inventing a phantom workspace under the incidental launch dir.
|
|
525
|
+
const racedOccupant = await readHealth(occupantBaseUrl);
|
|
526
|
+
if (racedOccupant?.ok === true) {
|
|
527
|
+
process.stderr.write(
|
|
528
|
+
`[pmx-canvas] launch cwd ${workspaceRoot} looks like a host config dir; attaching to the ` +
|
|
529
|
+
`canvas now on port ${port}.\n`,
|
|
530
|
+
);
|
|
531
|
+
return new RemoteCanvasAccess(occupantBaseUrl);
|
|
532
|
+
}
|
|
533
|
+
// Still free: bind it anyway (the agent always gets a working canvas) but warn
|
|
534
|
+
// loudly so a wrong-workspace canvas is diagnosed, not silent. stderr only.
|
|
535
|
+
process.stderr.write(
|
|
536
|
+
`[pmx-canvas] launch cwd ${workspaceRoot} looks like a host/agent config dir, not a project ` +
|
|
537
|
+
`root. This canvas will persist under it and may not be visible in a workbench opened for ` +
|
|
538
|
+
`your project. Set PMX_CANVAS_WORKSPACE_ROOT=/abs/project to target it, PMX_CANVAS_URL to ` +
|
|
539
|
+
`attach to a running daemon, or PMX_CANVAS_ALLOW_WORKSPACE_SPLIT=1 / PMX_CANVAS_PORT=<free ` +
|
|
540
|
+
`port> for a deliberate separate canvas.\n`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Either the split is opted in, the root is explicit, the cwd is a real project, or
|
|
545
|
+
// the preferred port is genuinely free / not a canvas daemon. Allow a port fallback
|
|
546
|
+
// so a non-canvas occupant doesn't crash this session with EADDRINUSE — start our
|
|
547
|
+
// own canvas and explain how to share one.
|
|
539
548
|
const canvas = createCanvas({ port });
|
|
540
549
|
await canvas.start({ open: true, allowPortFallback: true });
|
|
541
550
|
const boundPort = canvas.port;
|
|
542
551
|
if (boundPort !== port) {
|
|
543
|
-
const occupant = await readHealth(
|
|
552
|
+
const occupant = await readHealth(occupantBaseUrl);
|
|
544
553
|
const occupantWorkspace =
|
|
545
554
|
typeof occupant?.workspace === 'string' ? ` (serving ${occupant.workspace})` : '';
|
|
546
|
-
// stderr only — stdout is the MCP stdio JSON-RPC channel.
|
|
547
555
|
process.stderr.write(
|
|
548
556
|
`[pmx-canvas] preferred port ${port} was in use${occupantWorkspace}; ` +
|
|
549
557
|
`started this canvas on port ${boundPort} instead. To share one canvas, run the daemon ` +
|