pmx-canvas 0.1.23 → 0.1.24
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/.github/extensions/pmx-canvas/extension.mjs +591 -0
- package/CHANGELOG.md +70 -0
- package/Readme.md +36 -5
- package/dist/canvas/global.css +36 -3
- package/dist/canvas/index.js +54 -54
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -0
- package/dist/types/client/types.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +9 -0
- package/dist/types/server/ax-context.d.ts +3 -0
- package/dist/types/server/ax-state.d.ts +43 -0
- package/dist/types/server/canvas-db.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +19 -3
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +13 -0
- package/docs/http-api.md +24 -0
- package/docs/mcp.md +20 -2
- package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +5 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +14 -0
- package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
- package/src/cli/agent.ts +34 -0
- package/src/cli/index.ts +2 -1
- package/src/client/App.tsx +2 -0
- package/src/client/canvas/CanvasNode.tsx +7 -0
- package/src/client/canvas/CommandPalette.tsx +2 -1
- package/src/client/canvas/use-node-drag.ts +29 -7
- package/src/client/canvas/use-node-resize.ts +27 -7
- package/src/client/nodes/ExtAppFrame.tsx +51 -10
- package/src/client/nodes/HtmlNode.tsx +5 -2
- package/src/client/nodes/iframe-document-url.ts +58 -0
- package/src/client/state/intent-bridge.ts +8 -0
- package/src/client/state/sse-bridge.ts +2 -2
- package/src/client/theme/global.css +36 -3
- package/src/client/types.ts +1 -0
- package/src/mcp/canvas-access.ts +38 -0
- package/src/mcp/server.ts +113 -4
- package/src/server/ax-context.ts +38 -0
- package/src/server/ax-state.ts +130 -0
- package/src/server/canvas-db.ts +36 -1
- package/src/server/canvas-operations.ts +79 -0
- package/src/server/canvas-state.ts +113 -2
- package/src/server/index.ts +18 -0
- package/src/server/mutation-history.ts +1 -0
- package/src/server/server.ts +193 -8
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createServer as createHttpServer } from "node:http";
|
|
4
|
+
import { createServer as createNetServer } from "node:net";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
|
8
|
+
|
|
9
|
+
const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PROJECT_ROOT = resolve(EXTENSION_DIR, "../../..");
|
|
11
|
+
const DEFAULT_PORT = 4313;
|
|
12
|
+
const MAX_AX_CONTEXT_CHARS = 16_000;
|
|
13
|
+
const MANAGED_START_TIMEOUT_MS = 10_000;
|
|
14
|
+
const HEALTH_TIMEOUT_MS = 500;
|
|
15
|
+
|
|
16
|
+
let copilotSession;
|
|
17
|
+
let managedProcess = null;
|
|
18
|
+
let managedBaseUrl = null;
|
|
19
|
+
let managedWorkspaceRoot = null;
|
|
20
|
+
let managedPort = null;
|
|
21
|
+
const managedLogs = [];
|
|
22
|
+
const panelServers = new Map();
|
|
23
|
+
|
|
24
|
+
function normalizeBaseUrl(value) {
|
|
25
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(value.trim());
|
|
28
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
29
|
+
url.pathname = url.pathname.replace(/\/$/, "");
|
|
30
|
+
url.search = "";
|
|
31
|
+
url.hash = "";
|
|
32
|
+
return url.toString().replace(/\/$/, "");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizePort(value) {
|
|
39
|
+
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
40
|
+
const parsed = Number(value);
|
|
41
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
42
|
+
return Math.floor(parsed);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function preferredPort(input) {
|
|
46
|
+
return normalizePort(input?.port) ??
|
|
47
|
+
normalizePort(process.env.PMX_CANVAS_PORT) ??
|
|
48
|
+
normalizePort(process.env.PMX_WEB_CANVAS_PORT) ??
|
|
49
|
+
DEFAULT_PORT;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function candidateBaseUrls(input) {
|
|
53
|
+
const explicit = normalizeBaseUrl(input?.serverUrl) ?? normalizeBaseUrl(process.env.PMX_CANVAS_URL);
|
|
54
|
+
if (explicit) return [{ baseUrl: explicit, explicit: true }];
|
|
55
|
+
|
|
56
|
+
const port = preferredPort(input);
|
|
57
|
+
return [
|
|
58
|
+
{ baseUrl: `http://127.0.0.1:${port}`, explicit: false },
|
|
59
|
+
{ baseUrl: `http://localhost:${port}`, explicit: false },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function workspaceRootFrom(ctxOrInput) {
|
|
64
|
+
const inputWorkspace = typeof ctxOrInput?.input?.workspaceRoot === "string" ? ctxOrInput.input.workspaceRoot : null;
|
|
65
|
+
const sessionWorkspace = typeof ctxOrInput?.session?.workingDirectory === "string" ? ctxOrInput.session.workingDirectory : null;
|
|
66
|
+
const currentWorkspace = typeof copilotSession?.workspacePath === "string" ? copilotSession.workspacePath : null;
|
|
67
|
+
return resolve(inputWorkspace ?? sessionWorkspace ?? currentWorkspace ?? PROJECT_ROOT);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function workspaceMatches(health, workspaceRoot) {
|
|
71
|
+
if (!health || typeof health.workspace !== "string") return false;
|
|
72
|
+
return resolve(health.workspace) === resolve(workspaceRoot);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function delay(ms) {
|
|
76
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchJson(baseUrl, path, options = {}) {
|
|
80
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
81
|
+
...options,
|
|
82
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? HEALTH_TIMEOUT_MS),
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
86
|
+
}
|
|
87
|
+
return await response.json();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function probeServer(baseUrl, workspaceRoot, options = {}) {
|
|
91
|
+
try {
|
|
92
|
+
const health = await fetchJson(baseUrl, "/health", { timeoutMs: options.timeoutMs ?? HEALTH_TIMEOUT_MS });
|
|
93
|
+
const workspaceOk = options.allowWorkspaceMismatch === true || workspaceMatches(health, workspaceRoot);
|
|
94
|
+
return {
|
|
95
|
+
ok: Boolean(health?.ok) && workspaceOk,
|
|
96
|
+
baseUrl,
|
|
97
|
+
health,
|
|
98
|
+
workspaceOk,
|
|
99
|
+
error: workspaceOk ? null : `PMX server belongs to ${health?.workspace ?? "another workspace"}`,
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
baseUrl,
|
|
105
|
+
health: null,
|
|
106
|
+
workspaceOk: false,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function isPortAvailable(port) {
|
|
113
|
+
return await new Promise((resolveAvailable) => {
|
|
114
|
+
const server = createNetServer();
|
|
115
|
+
server.once("error", () => resolveAvailable(false));
|
|
116
|
+
server.once("listening", () => {
|
|
117
|
+
server.close(() => resolveAvailable(true));
|
|
118
|
+
});
|
|
119
|
+
server.listen(port, "127.0.0.1");
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function pickManagedPort(startPort) {
|
|
124
|
+
for (let port = startPort; port < startPort + 20; port += 1) {
|
|
125
|
+
if (await isPortAvailable(port)) return port;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`No available PMX Canvas port found near ${startPort}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function captureManagedLog(chunk) {
|
|
131
|
+
const text = chunk.toString("utf8").trim();
|
|
132
|
+
if (!text) return;
|
|
133
|
+
managedLogs.push(text);
|
|
134
|
+
while (managedLogs.length > 20) managedLogs.shift();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stopManagedServer() {
|
|
138
|
+
if (managedProcess && managedProcess.exitCode === null && !managedProcess.killed) {
|
|
139
|
+
managedProcess.kill("SIGTERM");
|
|
140
|
+
}
|
|
141
|
+
managedProcess = null;
|
|
142
|
+
managedBaseUrl = null;
|
|
143
|
+
managedWorkspaceRoot = null;
|
|
144
|
+
managedPort = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function managedCommand(workspaceRoot, port) {
|
|
148
|
+
const sourceEntry = resolve(workspaceRoot, "src/cli/index.ts");
|
|
149
|
+
const localBin = resolve(workspaceRoot, "node_modules/.bin/pmx-canvas");
|
|
150
|
+
if (existsSync(sourceEntry)) {
|
|
151
|
+
return {
|
|
152
|
+
command: "bun",
|
|
153
|
+
args: ["run", "src/cli/index.ts", "--no-open", `--port=${port}`],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (existsSync(localBin)) {
|
|
157
|
+
return {
|
|
158
|
+
command: localBin,
|
|
159
|
+
args: ["--no-open", `--port=${port}`],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
command: "pmx-canvas",
|
|
164
|
+
args: ["--no-open", `--port=${port}`],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function startManagedServer(workspaceRoot, input) {
|
|
169
|
+
if (managedProcess && managedBaseUrl && managedWorkspaceRoot === workspaceRoot) {
|
|
170
|
+
const probe = await probeServer(managedBaseUrl, workspaceRoot, { allowWorkspaceMismatch: false });
|
|
171
|
+
if (probe.ok) return probe;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
stopManagedServer();
|
|
175
|
+
const port = await pickManagedPort(preferredPort(input));
|
|
176
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
177
|
+
const managed = managedCommand(workspaceRoot, port);
|
|
178
|
+
let managedError = null;
|
|
179
|
+
managedLogs.length = 0;
|
|
180
|
+
managedProcess = spawn(managed.command, managed.args, {
|
|
181
|
+
cwd: workspaceRoot,
|
|
182
|
+
env: {
|
|
183
|
+
...process.env,
|
|
184
|
+
PMX_CANVAS_DISABLE_BROWSER_OPEN: "1",
|
|
185
|
+
PMX_WEB_CANVAS_PORT: String(port),
|
|
186
|
+
},
|
|
187
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
188
|
+
});
|
|
189
|
+
managedBaseUrl = baseUrl;
|
|
190
|
+
managedWorkspaceRoot = workspaceRoot;
|
|
191
|
+
managedPort = port;
|
|
192
|
+
managedProcess.stdout?.on("data", captureManagedLog);
|
|
193
|
+
managedProcess.stderr?.on("data", captureManagedLog);
|
|
194
|
+
managedProcess.once("error", (error) => {
|
|
195
|
+
managedError = error;
|
|
196
|
+
captureManagedLog(Buffer.from(error.message));
|
|
197
|
+
});
|
|
198
|
+
managedProcess.once("exit", () => {
|
|
199
|
+
managedProcess = null;
|
|
200
|
+
managedBaseUrl = null;
|
|
201
|
+
managedWorkspaceRoot = null;
|
|
202
|
+
managedPort = null;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
while (Date.now() - startedAt < MANAGED_START_TIMEOUT_MS) {
|
|
207
|
+
const probe = await probeServer(baseUrl, workspaceRoot, {
|
|
208
|
+
allowWorkspaceMismatch: false,
|
|
209
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
210
|
+
});
|
|
211
|
+
if (probe.ok) return probe;
|
|
212
|
+
if (managedError) throw managedError;
|
|
213
|
+
if (!managedProcess || managedProcess.exitCode !== null) break;
|
|
214
|
+
await delay(250);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const tail = managedLogs.slice(-4).join("\n");
|
|
218
|
+
throw new Error(`PMX Canvas did not become healthy on port ${port}.${tail ? `\n${tail}` : ""}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function resolvePmxServer(ctxOrInput, options = {}) {
|
|
222
|
+
const workspaceRoot = workspaceRootFrom(ctxOrInput);
|
|
223
|
+
const input = ctxOrInput?.input ?? ctxOrInput ?? {};
|
|
224
|
+
const allowWorkspaceMismatch = input?.allowWorkspaceMismatch === true;
|
|
225
|
+
for (const candidate of candidateBaseUrls(input)) {
|
|
226
|
+
const probe = await probeServer(candidate.baseUrl, workspaceRoot, {
|
|
227
|
+
allowWorkspaceMismatch: candidate.explicit || allowWorkspaceMismatch,
|
|
228
|
+
});
|
|
229
|
+
if (probe.ok) return probe;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (options.autoStart === false || input?.autoStart === false) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
baseUrl: null,
|
|
236
|
+
health: null,
|
|
237
|
+
workspaceOk: false,
|
|
238
|
+
error: "No matching PMX Canvas server is running.",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return await startManagedServer(workspaceRoot, input);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function escapeHtml(value) {
|
|
246
|
+
return String(value)
|
|
247
|
+
.replaceAll("&", "&")
|
|
248
|
+
.replaceAll("<", "<")
|
|
249
|
+
.replaceAll(">", ">")
|
|
250
|
+
.replaceAll('"', """);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function renderShell(instanceId, entry) {
|
|
254
|
+
const status = entry.pmx?.ok ? "Connected" : "Not connected";
|
|
255
|
+
const frameSrc = entry.pmx?.baseUrl ? `${entry.pmx.baseUrl}/workbench` : "about:blank";
|
|
256
|
+
const error = entry.pmx?.error ? `<p class="error">${escapeHtml(entry.pmx.error)}</p>` : "";
|
|
257
|
+
return `<!doctype html>
|
|
258
|
+
<html>
|
|
259
|
+
<head>
|
|
260
|
+
<meta charset="utf-8" />
|
|
261
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
262
|
+
<title>PMX Canvas</title>
|
|
263
|
+
<style>
|
|
264
|
+
body {
|
|
265
|
+
margin: 0;
|
|
266
|
+
background: var(--background-color-default, #0d1117);
|
|
267
|
+
color: var(--text-color-default, #f0f6fc);
|
|
268
|
+
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
|
|
269
|
+
}
|
|
270
|
+
.shell {
|
|
271
|
+
display: grid;
|
|
272
|
+
grid-template-rows: auto 1fr;
|
|
273
|
+
height: 100vh;
|
|
274
|
+
}
|
|
275
|
+
header {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 12px;
|
|
279
|
+
padding: 10px 12px;
|
|
280
|
+
border-bottom: 1px solid var(--border-color-default, #30363d);
|
|
281
|
+
background: var(--background-color-muted, rgba(22, 27, 34, 0.95));
|
|
282
|
+
}
|
|
283
|
+
strong { font-weight: var(--font-weight-semibold, 600); }
|
|
284
|
+
.status {
|
|
285
|
+
color: var(--text-color-muted, #8b949e);
|
|
286
|
+
font-size: var(--text-body-small, 12px);
|
|
287
|
+
}
|
|
288
|
+
.error {
|
|
289
|
+
margin: 0;
|
|
290
|
+
color: var(--true-color-red, #ff7b72);
|
|
291
|
+
font-size: var(--text-body-small, 12px);
|
|
292
|
+
}
|
|
293
|
+
button {
|
|
294
|
+
border: 1px solid var(--border-color-default, #30363d);
|
|
295
|
+
border-radius: 8px;
|
|
296
|
+
background: var(--background-color-default, #0d1117);
|
|
297
|
+
color: inherit;
|
|
298
|
+
cursor: pointer;
|
|
299
|
+
padding: 6px 10px;
|
|
300
|
+
}
|
|
301
|
+
button:hover {
|
|
302
|
+
border-color: var(--color-focus-outline, #2f81f7);
|
|
303
|
+
}
|
|
304
|
+
iframe {
|
|
305
|
+
width: 100%;
|
|
306
|
+
height: 100%;
|
|
307
|
+
border: 0;
|
|
308
|
+
background: #0d1117;
|
|
309
|
+
}
|
|
310
|
+
.spacer { flex: 1; }
|
|
311
|
+
</style>
|
|
312
|
+
</head>
|
|
313
|
+
<body>
|
|
314
|
+
<div class="shell">
|
|
315
|
+
<header>
|
|
316
|
+
<strong>PMX Canvas</strong>
|
|
317
|
+
<span class="status">${escapeHtml(status)}${entry.pmx?.baseUrl ? ` · ${escapeHtml(entry.pmx.baseUrl)}` : ""}</span>
|
|
318
|
+
${error}
|
|
319
|
+
<span class="spacer"></span>
|
|
320
|
+
<button type="button" onclick="refreshContext()">Refresh AX</button>
|
|
321
|
+
<button type="button" onclick="startServer()">Start server</button>
|
|
322
|
+
</header>
|
|
323
|
+
<iframe title="PMX Canvas workbench" src="${escapeHtml(frameSrc)}"></iframe>
|
|
324
|
+
</div>
|
|
325
|
+
<script>
|
|
326
|
+
async function startServer() {
|
|
327
|
+
await fetch('/start', { method: 'POST' });
|
|
328
|
+
window.location.reload();
|
|
329
|
+
}
|
|
330
|
+
async function refreshContext() {
|
|
331
|
+
const response = await fetch('/context');
|
|
332
|
+
const context = await response.json();
|
|
333
|
+
window.parent.postMessage({ type: 'pmx-canvas-ax-context', instanceId: ${JSON.stringify(instanceId)}, context }, '*');
|
|
334
|
+
}
|
|
335
|
+
</script>
|
|
336
|
+
</body>
|
|
337
|
+
</html>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function readRequestJson(req) {
|
|
341
|
+
const chunks = [];
|
|
342
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
343
|
+
if (chunks.length === 0) return {};
|
|
344
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function jsonResponse(res, statusCode, body) {
|
|
348
|
+
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
349
|
+
res.end(JSON.stringify(body));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function startPanelServer(instanceId, ctx, pmx) {
|
|
353
|
+
const entry = { pmx, workspaceRoot: workspaceRootFrom(ctx), input: ctx.input ?? {} };
|
|
354
|
+
const server = createHttpServer(async (req, res) => {
|
|
355
|
+
try {
|
|
356
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
357
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
358
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
359
|
+
res.end(renderShell(instanceId, entry));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (req.method === "POST" && url.pathname === "/start") {
|
|
363
|
+
entry.pmx = await resolvePmxServer({ input: entry.input, session: { workingDirectory: entry.workspaceRoot } });
|
|
364
|
+
jsonResponse(res, 200, entry.pmx);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
368
|
+
const latest = await resolvePmxServer({ input: entry.input, session: { workingDirectory: entry.workspaceRoot } }, { autoStart: false });
|
|
369
|
+
entry.pmx = latest.ok ? latest : entry.pmx;
|
|
370
|
+
jsonResponse(res, 200, latest);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (req.method === "GET" && url.pathname === "/context") {
|
|
374
|
+
const context = await getAxContext(entry.pmx?.baseUrl, entry.workspaceRoot, entry.input);
|
|
375
|
+
jsonResponse(res, 200, context);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (req.method === "POST" && url.pathname === "/focus") {
|
|
379
|
+
const body = await readRequestJson(req);
|
|
380
|
+
const result = await setAxFocus(entry.pmx?.baseUrl, entry.workspaceRoot, entry.input, body.nodeIds);
|
|
381
|
+
jsonResponse(res, 200, result);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (req.method === "POST" && url.pathname === "/send") {
|
|
385
|
+
const body = await readRequestJson(req);
|
|
386
|
+
if (typeof body.prompt !== "string" || body.prompt.trim() === "") {
|
|
387
|
+
jsonResponse(res, 400, { ok: false, error: "prompt is required" });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await copilotSession?.send({ prompt: body.prompt });
|
|
391
|
+
jsonResponse(res, 200, { ok: true });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
jsonResponse(res, 404, { ok: false, error: "Not found" });
|
|
395
|
+
} catch (error) {
|
|
396
|
+
jsonResponse(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
|
|
400
|
+
const address = server.address();
|
|
401
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
402
|
+
return { server, url: `http://127.0.0.1:${port}/`, entry };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function getAxContext(baseUrl, workspaceRoot, input = {}) {
|
|
406
|
+
const resolved = baseUrl
|
|
407
|
+
? { ok: true, baseUrl }
|
|
408
|
+
: await resolvePmxServer({ input, session: { workingDirectory: workspaceRoot } }, { autoStart: false });
|
|
409
|
+
if (!resolved.ok || !resolved.baseUrl) {
|
|
410
|
+
return { ok: false, error: resolved.error ?? "PMX Canvas server is unavailable." };
|
|
411
|
+
}
|
|
412
|
+
return await fetchJson(resolved.baseUrl, "/api/canvas/ax/context", { timeoutMs: 2_000 });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function getAxStatus(ctx) {
|
|
416
|
+
const resolved = await resolvePmxServer(ctx, { autoStart: false });
|
|
417
|
+
if (!resolved.ok || !resolved.baseUrl) return { ok: false, server: resolved };
|
|
418
|
+
const state = await fetchJson(resolved.baseUrl, "/api/canvas/ax", { timeoutMs: 2_000 });
|
|
419
|
+
return { ok: true, server: resolved, ax: state };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function setAxFocus(baseUrl, workspaceRoot, input = {}, nodeIds = []) {
|
|
423
|
+
const resolved = baseUrl
|
|
424
|
+
? { ok: true, baseUrl }
|
|
425
|
+
: await resolvePmxServer({ input, session: { workingDirectory: workspaceRoot } });
|
|
426
|
+
if (!resolved.ok || !resolved.baseUrl) {
|
|
427
|
+
throw new CanvasError("pmx_unavailable", resolved.error ?? "PMX Canvas server is unavailable.");
|
|
428
|
+
}
|
|
429
|
+
return await fetchJson(resolved.baseUrl, "/api/canvas/ax/focus", {
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: { "Content-Type": "application/json" },
|
|
432
|
+
body: JSON.stringify({ nodeIds: Array.isArray(nodeIds) ? nodeIds : [], source: "copilot" }),
|
|
433
|
+
timeoutMs: 2_000,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function hasUsefulAxContext(context) {
|
|
438
|
+
return Boolean(context?.pinned?.count > 0 || context?.focus?.nodeIds?.length > 0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function formatAdditionalContext(context, baseUrl) {
|
|
442
|
+
if (!hasUsefulAxContext(context)) return null;
|
|
443
|
+
const json = JSON.stringify(context, null, 2);
|
|
444
|
+
const clipped = json.length > MAX_AX_CONTEXT_CHARS
|
|
445
|
+
? `${json.slice(0, MAX_AX_CONTEXT_CHARS)}\n...<truncated>`
|
|
446
|
+
: json;
|
|
447
|
+
return [
|
|
448
|
+
"PMX Canvas AX context from the visible workbench.",
|
|
449
|
+
"Treat pinned nodes and focused nodes as human-selected working context when relevant.",
|
|
450
|
+
`Server: ${baseUrl}`,
|
|
451
|
+
clipped,
|
|
452
|
+
].join("\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const pmxCanvas = createCanvas({
|
|
456
|
+
id: "pmx-canvas",
|
|
457
|
+
displayName: "PMX Canvas",
|
|
458
|
+
description: "Open the PMX Canvas workbench and bridge AX pinned/focused context into Copilot.",
|
|
459
|
+
inputSchema: {
|
|
460
|
+
type: "object",
|
|
461
|
+
properties: {
|
|
462
|
+
serverUrl: { type: "string", description: "Optional existing PMX Canvas server URL." },
|
|
463
|
+
port: { type: "integer", minimum: 1, description: "Preferred PMX Canvas server port." },
|
|
464
|
+
autoStart: { type: "boolean", description: "Start PMX Canvas if no matching server is running." },
|
|
465
|
+
allowWorkspaceMismatch: { type: "boolean", description: "Allow connecting to a PMX server from another workspace." },
|
|
466
|
+
workspaceRoot: { type: "string", description: "Workspace root for server discovery/startup." },
|
|
467
|
+
},
|
|
468
|
+
additionalProperties: false,
|
|
469
|
+
},
|
|
470
|
+
actions: [
|
|
471
|
+
{
|
|
472
|
+
name: "status",
|
|
473
|
+
description: "Return PMX Canvas server and AX state status for this workspace.",
|
|
474
|
+
handler: async (ctx) => await getAxStatus(ctx),
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "get_ax_context",
|
|
478
|
+
description: "Return the current PMX Canvas AX pinned and focused context.",
|
|
479
|
+
handler: async (ctx) => {
|
|
480
|
+
const resolved = await resolvePmxServer(ctx, { autoStart: false });
|
|
481
|
+
if (!resolved.ok || !resolved.baseUrl) return { ok: false, error: resolved.error };
|
|
482
|
+
return await getAxContext(resolved.baseUrl, workspaceRootFrom(ctx), ctx.input ?? {});
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
name: "focus_nodes",
|
|
487
|
+
description: "Set PMX Canvas AX focus to the provided node IDs using Copilot as the source.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
type: "object",
|
|
490
|
+
properties: {
|
|
491
|
+
nodeIds: {
|
|
492
|
+
type: "array",
|
|
493
|
+
items: { type: "string" },
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
required: ["nodeIds"],
|
|
497
|
+
additionalProperties: false,
|
|
498
|
+
},
|
|
499
|
+
handler: async (ctx) => await setAxFocus(null, workspaceRootFrom(ctx), ctx.input ?? {}, ctx.input?.nodeIds),
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "send_instruction",
|
|
503
|
+
description: "Send a prompt from the PMX Canvas adapter into the active Copilot session.",
|
|
504
|
+
inputSchema: {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
prompt: { type: "string" },
|
|
508
|
+
},
|
|
509
|
+
required: ["prompt"],
|
|
510
|
+
additionalProperties: false,
|
|
511
|
+
},
|
|
512
|
+
handler: async (ctx) => {
|
|
513
|
+
const prompt = typeof ctx.input?.prompt === "string" ? ctx.input.prompt.trim() : "";
|
|
514
|
+
if (!prompt) throw new CanvasError("prompt_required", "prompt is required");
|
|
515
|
+
await copilotSession?.send({ prompt });
|
|
516
|
+
return { ok: true };
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
open: async (ctx) => {
|
|
521
|
+
let pmx;
|
|
522
|
+
try {
|
|
523
|
+
pmx = await resolvePmxServer(ctx);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
pmx = {
|
|
526
|
+
ok: false,
|
|
527
|
+
baseUrl: null,
|
|
528
|
+
health: null,
|
|
529
|
+
workspaceOk: false,
|
|
530
|
+
error: error instanceof Error ? error.message : String(error),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (pmx.ok && pmx.baseUrl) {
|
|
535
|
+
const fallbackPanel = panelServers.get(ctx.instanceId);
|
|
536
|
+
if (fallbackPanel) {
|
|
537
|
+
panelServers.delete(ctx.instanceId);
|
|
538
|
+
await new Promise((resolveClose) => fallbackPanel.server.close(() => resolveClose()));
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
title: "PMX Canvas",
|
|
542
|
+
status: "Connected",
|
|
543
|
+
url: `${pmx.baseUrl}/workbench`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let panel = panelServers.get(ctx.instanceId);
|
|
548
|
+
if (!panel) {
|
|
549
|
+
panel = await startPanelServer(ctx.instanceId, ctx, pmx);
|
|
550
|
+
panelServers.set(ctx.instanceId, panel);
|
|
551
|
+
} else {
|
|
552
|
+
panel.entry.pmx = pmx;
|
|
553
|
+
panel.entry.workspaceRoot = workspaceRootFrom(ctx);
|
|
554
|
+
panel.entry.input = ctx.input ?? {};
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
title: "PMX Canvas",
|
|
558
|
+
status: pmx.ok ? "Connected" : "Needs server",
|
|
559
|
+
url: panel.url,
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
onClose: async (ctx) => {
|
|
563
|
+
const panel = panelServers.get(ctx.instanceId);
|
|
564
|
+
if (!panel) return;
|
|
565
|
+
panelServers.delete(ctx.instanceId);
|
|
566
|
+
await new Promise((resolveClose) => panel.server.close(() => resolveClose()));
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
copilotSession = await joinSession({
|
|
571
|
+
canvases: [pmxCanvas],
|
|
572
|
+
hooks: {
|
|
573
|
+
onUserPromptSubmitted: async (input) => {
|
|
574
|
+
const workspaceRoot = resolve(input?.workingDirectory ?? copilotSession?.workspacePath ?? PROJECT_ROOT);
|
|
575
|
+
const resolved = await resolvePmxServer({ input: {}, session: { workingDirectory: workspaceRoot } }, { autoStart: false });
|
|
576
|
+
if (!resolved.ok || !resolved.baseUrl) return undefined;
|
|
577
|
+
const context = await getAxContext(resolved.baseUrl, workspaceRoot, {});
|
|
578
|
+
const additionalContext = formatAdditionalContext(context, resolved.baseUrl);
|
|
579
|
+
return additionalContext ? { additionalContext } : undefined;
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
process.once("SIGTERM", () => {
|
|
585
|
+
stopManagedServer();
|
|
586
|
+
process.exit(0);
|
|
587
|
+
});
|
|
588
|
+
process.once("SIGINT", () => {
|
|
589
|
+
stopManagedServer();
|
|
590
|
+
process.exit(0);
|
|
591
|
+
});
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,75 @@
|
|
|
3
3
|
All notable changes to `pmx-canvas` are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.1.24] - 2026-06-03
|
|
7
|
+
|
|
8
|
+
Host-adapter and agent-experience (AX) release. Adds host-agnostic AX
|
|
9
|
+
focus/context primitives across every layer, ships GitHub Copilot and
|
|
10
|
+
Codex canvas adapters, moves embedded HTML/MCP-app iframes onto a
|
|
11
|
+
same-origin frame-document transport (still strictly sandboxed via a
|
|
12
|
+
CSP `sandbox` response header), and fixes a batch of iframe-backed
|
|
13
|
+
node drag/resize/fullscreen interaction glitches.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **PMX AX focus + context primitives.** A new host-agnostic
|
|
18
|
+
"agent experience" focus field lets any surface mark which nodes
|
|
19
|
+
an agent is attending to without moving the viewport. Implemented
|
|
20
|
+
end to end with full parity:
|
|
21
|
+
- State: `CanvasStateManager.getAxFocus()` / `setAxFocus()` /
|
|
22
|
+
`getAxState()`, recorded as a `setAxFocus` mutation-history op
|
|
23
|
+
(undo/redo) and persisted in a new SQLite `ax_state` table.
|
|
24
|
+
- SDK: `PmxCanvas.getAxState()`, `getAxContext()`, `setAxFocus()`.
|
|
25
|
+
- HTTP: `GET`/`PATCH /api/canvas/ax`, `GET /api/canvas/ax/context`,
|
|
26
|
+
`POST /api/canvas/ax/focus`.
|
|
27
|
+
- MCP: `canvas_get_ax`, `canvas_set_ax_focus` (45 tools total),
|
|
28
|
+
plus `canvas://ax` and `canvas://ax-context` resources that emit
|
|
29
|
+
`notifications/resources/updated` on change.
|
|
30
|
+
- CLI: `pmx-canvas ax focus <node-id...>` / `--clear`.
|
|
31
|
+
Focus state carries a `source` tag (`agent`/`api`/`browser`/`cli`/
|
|
32
|
+
`codex`/`copilot`/`mcp`/`sdk`/`system`) and node IDs are validated
|
|
33
|
+
against the live layout.
|
|
34
|
+
- **GitHub Copilot canvas adapter.** A new
|
|
35
|
+
`.github/extensions/pmx-canvas/extension.mjs` (591 lines) plus
|
|
36
|
+
`skills/pmx-canvas/references/github-copilot-app-adapter.md`
|
|
37
|
+
document and implement driving the canvas from GitHub Copilot.
|
|
38
|
+
- **Codex canvas adapter coverage.**
|
|
39
|
+
`skills/pmx-canvas/references/codex-app-adapter.md` documents the
|
|
40
|
+
Codex host integration, with browser regression coverage.
|
|
41
|
+
- **Same-origin frame-document transport for embedded apps.**
|
|
42
|
+
Embedded HTML and MCP-app iframes now load their document from
|
|
43
|
+
`POST /api/canvas/frame-documents` → `GET /api/canvas/frame-
|
|
44
|
+
documents/<id>` instead of an inline `srcdoc`. The served document
|
|
45
|
+
carries `Content-Security-Policy: sandbox <tokens>`,
|
|
46
|
+
`Referrer-Policy: no-referrer`, and `X-Content-Type-Options:
|
|
47
|
+
nosniff`. The sandbox-token allowlist deliberately excludes
|
|
48
|
+
`allow-same-origin` and top-navigation tokens, so frame content
|
|
49
|
+
stays in an opaque origin and cannot reach the canvas host. The
|
|
50
|
+
document store is in-memory, capped at 128 entries (LRU eviction)
|
|
51
|
+
and 5 MB per document.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **Iframe-backed node drag is flicker-free.** Node drag now
|
|
56
|
+
rAF-throttles pointer moves, clears the browser text selection,
|
|
57
|
+
and toggles an `is-node-dragging` document class to suppress
|
|
58
|
+
selection and attention-field repaint artifacts. Inline app
|
|
59
|
+
iframes are kept pointer-inert near the resize handle so resize
|
|
60
|
+
starts reliably.
|
|
61
|
+
- **Docs tool/resource references updated.** `docs/mcp.md` and the
|
|
62
|
+
README now read 45 tools + 9 core resources; the `AGENTS.md` and
|
|
63
|
+
`CLAUDE.md` MCP tool enumerations were corrected to the full
|
|
64
|
+
45-tool list (they had drifted to 42 and 39 respectively and were
|
|
65
|
+
missing `canvas_fit_view` and the new AX tools).
|
|
66
|
+
|
|
67
|
+
### Internal
|
|
68
|
+
|
|
69
|
+
- Regression coverage for: AX focus set/clear/persistence through
|
|
70
|
+
state, HTTP, MCP, SDK, and CLI; AX focus round-tripping through
|
|
71
|
+
SQLite; arrange-lock interactions; iframe-backed node
|
|
72
|
+
drag/resize/fullscreen behavior (large browser e2e additions); and
|
|
73
|
+
the Copilot/Codex adapter surfaces.
|
|
74
|
+
|
|
6
75
|
## [0.1.23] - 2026-05-12
|
|
7
76
|
|
|
8
77
|
Persistence overhaul. Canvas state, snapshots, context pins, and the
|
|
@@ -1114,6 +1183,7 @@ otherwise have to discover by trial and error.
|
|
|
1114
1183
|
- Regression coverage for snapshot flat-`id` aliases on both MCP and
|
|
1115
1184
|
HTTP surfaces, plus async / top-level-`await` WebView script bodies.
|
|
1116
1185
|
|
|
1186
|
+
[0.1.24]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.24
|
|
1117
1187
|
[0.1.23]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.23
|
|
1118
1188
|
[0.1.22]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.22
|
|
1119
1189
|
[0.1.21]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.21
|