pmx-canvas 0.1.23 → 0.1.25

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