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.
Files changed (51) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +36 -5
  4. package/dist/canvas/global.css +36 -3
  5. package/dist/canvas/index.js +54 -54
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
  7. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  8. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  9. package/dist/types/client/types.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +1 -1
  11. package/dist/types/mcp/canvas-access.d.ts +9 -0
  12. package/dist/types/server/ax-context.d.ts +3 -0
  13. package/dist/types/server/ax-state.d.ts +43 -0
  14. package/dist/types/server/canvas-db.d.ts +5 -0
  15. package/dist/types/server/canvas-state.d.ts +19 -3
  16. package/dist/types/server/index.d.ts +6 -0
  17. package/dist/types/server/mutation-history.d.ts +1 -1
  18. package/docs/cli.md +13 -0
  19. package/docs/http-api.md +24 -0
  20. package/docs/mcp.md +20 -2
  21. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  22. package/docs/screenshot.png +0 -0
  23. package/docs/sdk.md +5 -0
  24. package/package.json +2 -1
  25. package/skills/pmx-canvas/SKILL.md +14 -0
  26. package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
  27. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
  28. package/src/cli/agent.ts +34 -0
  29. package/src/cli/index.ts +2 -1
  30. package/src/client/App.tsx +2 -0
  31. package/src/client/canvas/CanvasNode.tsx +7 -0
  32. package/src/client/canvas/CommandPalette.tsx +2 -1
  33. package/src/client/canvas/use-node-drag.ts +29 -7
  34. package/src/client/canvas/use-node-resize.ts +27 -7
  35. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  36. package/src/client/nodes/HtmlNode.tsx +5 -2
  37. package/src/client/nodes/iframe-document-url.ts +58 -0
  38. package/src/client/state/intent-bridge.ts +8 -0
  39. package/src/client/state/sse-bridge.ts +2 -2
  40. package/src/client/theme/global.css +36 -3
  41. package/src/client/types.ts +1 -0
  42. package/src/mcp/canvas-access.ts +38 -0
  43. package/src/mcp/server.ts +113 -4
  44. package/src/server/ax-context.ts +38 -0
  45. package/src/server/ax-state.ts +130 -0
  46. package/src/server/canvas-db.ts +36 -1
  47. package/src/server/canvas-operations.ts +79 -0
  48. package/src/server/canvas-state.ts +113 -2
  49. package/src/server/index.ts +18 -0
  50. package/src/server/mutation-history.ts +1 -0
  51. 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("&", "&amp;")
248
+ .replaceAll("<", "&lt;")
249
+ .replaceAll(">", "&gt;")
250
+ .replaceAll('"', "&quot;");
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