pmx-canvas 0.7.3 → 0.8.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/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/.github/extensions/pmx-canvas/extension.mjs +25 -172
- package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
- package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
- package/.github/extensions/pmx-canvas/runtime.json +3 -0
- package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
- package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
- package/.github/extensions/pmx-canvas/tools.json +2560 -0
- package/CHANGELOG.md +47 -0
- package/Readme.md +21 -7
- package/dist/canvas/fonts/OFL.txt +93 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/canvas/global.css +723 -672
- package/dist/canvas/index.js +90 -88
- package/dist/canvas/mcp-app.html +7878 -0
- package/dist/canvas/mermaid-entry.js +1 -1
- package/dist/canvas/surface-theme.css +151 -46
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/commands/record.d.ts +1 -1
- package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
- package/dist/types/client/icons.d.ts +1 -2
- package/dist/types/client/mcp-app/index.d.ts +1 -0
- package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
- package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
- package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
- package/dist/types/client/state/attention-store.d.ts +0 -5
- package/dist/types/client/state/context-status-store.d.ts +53 -0
- package/dist/types/client/state/intent-bridge.d.ts +3 -0
- package/dist/types/client/state/near-pin-store.d.ts +16 -0
- package/dist/types/client/state/presence-store.d.ts +5 -0
- package/dist/types/client/state/session-store.d.ts +33 -3
- package/dist/types/client/state/workbench-transport.d.ts +16 -0
- package/dist/types/client/theme/tokens.d.ts +1 -3
- package/dist/types/client/types.d.ts +9 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/mcp/app-server.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -1
- package/dist/types/mcp/canvas-app.d.ts +4 -0
- package/dist/types/mcp/workbench-app.d.ts +4 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +8 -0
- package/dist/types/server/canvas-geometry.d.ts +25 -0
- package/dist/types/server/canvas-operations.d.ts +1 -11
- package/dist/types/server/canvas-state.d.ts +25 -3
- package/dist/types/server/context-brief.d.ts +8 -1
- package/dist/types/server/context-reads.d.ts +30 -7
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/operations/http.d.ts +2 -0
- package/dist/types/server/operations/invoker.d.ts +4 -2
- package/dist/types/server/operations/ops/nodes.d.ts +0 -6
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/spatial-analysis.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +22 -0
- package/dist/types/shared/agent-presence.d.ts +3 -10
- package/dist/types/shared/canvas-app.d.ts +54 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +7 -3
- package/docs/agent-plugin.md +123 -0
- package/docs/ax-state-contract.md +40 -0
- package/docs/cli.md +7 -2
- package/docs/design/AgentContext.dc.html +186 -0
- package/docs/design/Before.dc.html +208 -0
- package/docs/design/BoardPins.dc.html +151 -0
- package/docs/design/Chrome.dc.html +208 -0
- package/docs/design/Context.dc.html +239 -0
- package/docs/design/Flows.dc.html +181 -0
- package/docs/design/Home.dc.html +209 -0
- package/docs/design/HomeGraph.dc.html +211 -0
- package/docs/design/HomeMap.dc.html +117 -0
- package/docs/design/HomeTeam.dc.html +176 -0
- package/docs/design/Light.dc.html +28 -0
- package/docs/design/Main.dc.html +280 -0
- package/docs/design/NearPin.dc.html +260 -0
- package/docs/design/Pane600.dc.html +120 -0
- package/docs/design/Relations.dc.html +297 -0
- package/docs/design/Themes.dc.html +121 -0
- package/docs/design/TokenSystem.dc.html +136 -0
- package/docs/design/Tokens.dc.html +163 -0
- package/docs/design/Types1.dc.html +177 -0
- package/docs/design/Types2.dc.html +226 -0
- package/docs/design/Types3.dc.html +195 -0
- package/docs/design/Uses.dc.html +152 -0
- package/docs/design/Vision.dc.html +116 -0
- package/docs/design/Zoom.dc.html +82 -0
- package/docs/design/canvas.json +274 -0
- package/docs/design.md +214 -0
- package/docs/environment.md +1 -1
- package/docs/http-api.md +80 -15
- package/docs/mcp.md +198 -3
- package/docs/node-types.md +34 -10
- package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
- package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
- package/docs/product-vision-2026-09.md +47 -7
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +10 -4
- package/plugins/pmx-canvas/LICENSE +21 -0
- package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/plugins/pmx-canvas/mcp.json +12 -0
- package/plugins/pmx-canvas/plugin.json +24 -0
- package/plugins/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/start-mcp.mjs +51 -0
- package/plugins/pmx-canvas/tools.json +2560 -0
- package/skills/pmx-canvas/SKILL.md +10 -7
- package/skills/pmx-canvas/references/full-reference.md +11 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas-testing/SKILL.md +5 -4
- package/src/cli/commands/copilot.ts +5 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +8 -3
- package/src/client/App.tsx +6 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
- package/src/client/canvas/AnnotationLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +32 -49
- package/src/client/canvas/CanvasViewport.tsx +0 -2
- package/src/client/canvas/CommandPalette.tsx +3 -2
- package/src/client/canvas/ContextMenu.tsx +11 -0
- package/src/client/canvas/EdgeLayer.tsx +1 -1
- package/src/client/canvas/ExportDialog.tsx +33 -6
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Minimap.tsx +12 -7
- package/src/client/canvas/NodeContextMark.tsx +156 -0
- package/src/client/canvas/SessionPanel.tsx +4 -7
- package/src/client/canvas/SessionReceipt.tsx +114 -17
- package/src/client/canvas/ShortcutOverlay.tsx +6 -1
- package/src/client/canvas/SnapshotPanel.tsx +11 -2
- package/src/client/canvas/ToolRail.tsx +12 -9
- package/src/client/canvas/TopBar.tsx +16 -11
- package/src/client/icons.tsx +2 -12
- package/src/client/mcp-app/index.tsx +137 -0
- package/src/client/mcp-app/style.css +10 -0
- package/src/client/nodes/AttachmentNode.tsx +8 -1
- package/src/client/nodes/AxStepControls.tsx +1 -1
- package/src/client/nodes/ContextNode.tsx +9 -9
- package/src/client/nodes/ExtAppFrame.tsx +13 -8
- package/src/client/nodes/FileNode.tsx +8 -5
- package/src/client/nodes/ImageNode.tsx +4 -2
- package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
- package/src/client/nodes/LedgerNode.tsx +2 -2
- package/src/client/nodes/MarkdownNode.tsx +44 -9
- package/src/client/nodes/McpAppNode.tsx +6 -4
- package/src/client/nodes/PromptNode.tsx +5 -17
- package/src/client/nodes/ResponseNode.tsx +3 -15
- package/src/client/nodes/StatusNode.tsx +2 -2
- package/src/client/nodes/StatusSummary.tsx +3 -1
- package/src/client/nodes/TraceNode.tsx +6 -4
- package/src/client/nodes/WebpageNode.tsx +1 -1
- package/src/client/nodes/ax-node-actions.ts +2 -2
- package/src/client/nodes/iframe-document-url.ts +3 -2
- package/src/client/nodes/markdown-sanitize.ts +10 -0
- package/src/client/nodes/surface-url.ts +10 -0
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/nodes/use-workbench-asset.ts +26 -0
- package/src/client/state/attention-bridge.ts +0 -8
- package/src/client/state/attention-store.ts +0 -17
- package/src/client/state/canvas-store.ts +4 -2
- package/src/client/state/context-status-store.ts +66 -0
- package/src/client/state/human-store.ts +2 -0
- package/src/client/state/iframe-mode.ts +5 -0
- package/src/client/state/intent-bridge.ts +10 -4
- package/src/client/state/near-pin-store.ts +53 -0
- package/src/client/state/presence-store.ts +5 -10
- package/src/client/state/session-store.ts +106 -9
- package/src/client/state/sse-bridge.ts +26 -7
- package/src/client/state/theme-override.ts +1 -1
- package/src/client/state/workbench-transport.ts +36 -0
- package/src/client/theme/global.css +723 -672
- package/src/client/theme/surface-theme.css +151 -46
- package/src/client/theme/tokens.ts +7 -11
- package/src/client/types.ts +10 -0
- package/src/json-render/server.ts +71 -14
- package/src/mcp/app-server.ts +64 -0
- package/src/mcp/canvas-access.ts +14 -11
- package/src/mcp/canvas-app.ts +318 -0
- package/src/mcp/server.ts +1 -1
- package/src/mcp/workbench-app.ts +211 -0
- package/src/server/agent-presence.ts +45 -6
- package/src/server/ax-context.ts +1 -1
- package/src/server/ax-state.ts +1 -1
- package/src/server/board-export.ts +5 -2
- package/src/server/canvas-db.ts +46 -12
- package/src/server/canvas-geometry.ts +64 -0
- package/src/server/canvas-operations.ts +6 -24
- package/src/server/canvas-state.ts +105 -15
- package/src/server/canvas-validation.ts +19 -4
- package/src/server/context-brief.ts +39 -2
- package/src/server/context-reads.ts +86 -15
- package/src/server/demo-state.json +31 -31
- package/src/server/html-surface.ts +4 -5
- package/src/server/index.ts +19 -3
- package/src/server/mcp-app-runtime.ts +11 -0
- package/src/server/operations/composites.ts +4 -3
- package/src/server/operations/http.ts +4 -4
- package/src/server/operations/invoker.ts +13 -4
- package/src/server/operations/ops/ax-read.ts +1 -1
- package/src/server/operations/ops/ax-timeline.ts +37 -0
- package/src/server/operations/ops/boards.ts +1 -1
- package/src/server/operations/ops/canvas-wire.ts +3 -6
- package/src/server/operations/ops/intent.ts +31 -2
- package/src/server/operations/ops/json-render.ts +1 -1
- package/src/server/operations/ops/nodes.ts +8 -76
- package/src/server/operations/ops/query.ts +8 -2
- package/src/server/operations/ops/snapshots.ts +4 -1
- package/src/server/operations/ops/viewport.ts +29 -5
- package/src/server/operations/registry.ts +38 -4
- package/src/server/scope-fence.ts +55 -10
- package/src/server/server.ts +83 -5
- package/src/server/spatial-analysis.ts +2 -2
- package/src/server/workboard.ts +6 -1
- package/src/{server → shared}/agent-context.ts +2 -2
- package/src/shared/agent-presence.ts +3 -15
- package/src/shared/canvas-app.ts +62 -0
- package/src/shared/themes.ts +5 -5
- package/src/client/canvas/FocusFieldLayer.tsx +0 -116
- package/src/client/canvas/kind-colors.ts +0 -23
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const runtimeVersion = JSON.parse(readFileSync(new URL("./runtime.json", import.meta.url), "utf8")).version;
|
|
7
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(runtimeVersion)) {
|
|
8
|
+
throw new Error("PMX Canvas runtime.json must contain an exact package version.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function canonicalWorkspace(path) {
|
|
12
|
+
// .native expands Windows 8.3 short names (RUNNER~1), so every caller compares one spelling.
|
|
13
|
+
const directory = realpathSync.native(resolve(path));
|
|
14
|
+
if (!statSync(directory).isDirectory()) throw new Error("PMX Canvas workspace must be a directory.");
|
|
15
|
+
return directory;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function runtimeCommand() {
|
|
19
|
+
return { command: "bun", args: ["x", "--bun", `pmx-canvas@${runtimeVersion}`] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function preferredPort(input = {}) {
|
|
23
|
+
const port = Number(input.port ?? process.env.PMX_CANVAS_PORT ?? process.env.PMX_WEB_CANVAS_PORT ?? 4313);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PMX Canvas port must be between 1 and 65535.");
|
|
25
|
+
return port;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function explicitServerUrl(input = {}) {
|
|
29
|
+
const value = input.serverUrl ?? process.env.PMX_CANVAS_URL;
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error("PMX Canvas server URL must use HTTP or HTTPS.");
|
|
33
|
+
url.search = "";
|
|
34
|
+
url.hash = "";
|
|
35
|
+
return url.toString().replace(/\/$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NO_SERVER = "No matching PMX Canvas server is running.";
|
|
39
|
+
|
|
40
|
+
export async function probePmxServer(baseUrl, workspaceRoot, input = {}, fetchImpl = fetch) {
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetchImpl(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) });
|
|
44
|
+
} catch {
|
|
45
|
+
// Nothing answered (refused, timed out). The raw fetch error ("fetch failed") is not
|
|
46
|
+
// actionable; a reachable server's own failure below is reported verbatim.
|
|
47
|
+
return { ok: false, baseUrl, health: null, workspaceOk: false, error: NO_SERVER };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (!response.ok) throw new Error(`Health check returned HTTP ${response.status}.`);
|
|
51
|
+
const health = await response.json();
|
|
52
|
+
const workspaceOk = typeof health.workspace === "string" &&
|
|
53
|
+
(input.allowWorkspaceMismatch === true || canonicalWorkspace(health.workspace) === canonicalWorkspace(workspaceRoot));
|
|
54
|
+
return {
|
|
55
|
+
ok: health.ok === true && workspaceOk,
|
|
56
|
+
baseUrl,
|
|
57
|
+
health,
|
|
58
|
+
workspaceOk,
|
|
59
|
+
error: workspaceOk ? null : `PMX server belongs to ${health.workspace ?? "an unknown workspace"}.`,
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { ok: false, baseUrl, health: null, workspaceOk: false, error: error.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function daemonPorts(workspaceRoot) {
|
|
67
|
+
try {
|
|
68
|
+
return readdirSync(resolve(workspaceRoot, ".pmx-canvas"))
|
|
69
|
+
.map((name) => /^daemon-(\d+)\.pid$/.exec(name))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map((match) => Number(match[1]))
|
|
72
|
+
.filter((port) => port > 0 && port <= 65535)
|
|
73
|
+
.sort((a, b) => a - b);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === "ENOENT") return [];
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function findPmxServer(workspaceRoot, input = {}, fetchImpl = fetch) {
|
|
81
|
+
const explicit = explicitServerUrl(input);
|
|
82
|
+
if (explicit) return await probePmxServer(explicit, workspaceRoot, input, fetchImpl);
|
|
83
|
+
const port = preferredPort(input);
|
|
84
|
+
// Daemon pid filenames locate arbitrary ports; health, not the pid file, proves ownership.
|
|
85
|
+
const ports = [...new Set([...daemonPorts(workspaceRoot), port, ...Array.from({ length: 9 }, (_, i) => 4313 + i)])];
|
|
86
|
+
const results = await Promise.all(ports.map((candidate) =>
|
|
87
|
+
probePmxServer(`http://127.0.0.1:${candidate}`, workspaceRoot, input, fetchImpl)));
|
|
88
|
+
return results.find((result) => result.ok) ??
|
|
89
|
+
{ ok: false, baseUrl: null, health: null, workspaceOk: false, error: NO_SERVER };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function portAvailable(port) {
|
|
93
|
+
return await new Promise((resolveAvailable) => {
|
|
94
|
+
const server = createServer();
|
|
95
|
+
server.once("error", () => resolveAvailable(false));
|
|
96
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolveAvailable(true)));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function ensurePmxServer(workspaceRoot, input = {}, command = runtimeCommand()) {
|
|
101
|
+
workspaceRoot = canonicalWorkspace(workspaceRoot);
|
|
102
|
+
const existing = await findPmxServer(workspaceRoot, input);
|
|
103
|
+
if (existing.ok || input.autoStart === false) return existing;
|
|
104
|
+
if (explicitServerUrl(input)) throw new Error(existing.error);
|
|
105
|
+
|
|
106
|
+
const directory = resolve(workspaceRoot, ".pmx-canvas");
|
|
107
|
+
const lock = resolve(directory, "plugin-start.lock");
|
|
108
|
+
mkdirSync(directory, { recursive: true });
|
|
109
|
+
const deadline = Date.now() + 75_000;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
let fd;
|
|
112
|
+
try {
|
|
113
|
+
fd = openSync(lock, "wx");
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "EEXIST") throw error;
|
|
116
|
+
try {
|
|
117
|
+
const pid = Number(readFileSync(lock, "utf8"));
|
|
118
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 0);
|
|
121
|
+
} catch (signalError) {
|
|
122
|
+
if (signalError.code !== "ESRCH") throw signalError;
|
|
123
|
+
rmSync(lock);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
} else if (Date.now() - statSync(lock).mtimeMs > 5_000) {
|
|
127
|
+
rmSync(lock);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
} catch (lockError) {
|
|
131
|
+
if (lockError.code === "ENOENT") continue;
|
|
132
|
+
throw lockError;
|
|
133
|
+
}
|
|
134
|
+
const server = await findPmxServer(workspaceRoot, input);
|
|
135
|
+
if (server.ok) return server;
|
|
136
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(fd, String(process.pid));
|
|
142
|
+
} finally {
|
|
143
|
+
closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
const server = await findPmxServer(workspaceRoot, input);
|
|
146
|
+
if (server.ok) return server;
|
|
147
|
+
return await startPmxServer(workspaceRoot, input, command);
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(lock, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw new Error("Timed out waiting for another PMX Canvas plugin startup.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function startPmxServer(workspaceRoot, input, command) {
|
|
156
|
+
const firstPort = preferredPort(input);
|
|
157
|
+
let port;
|
|
158
|
+
for (let candidate = firstPort; candidate < Math.min(firstPort + 20, 65536); candidate++) {
|
|
159
|
+
// Another starter may have claimed this port since discovery.
|
|
160
|
+
const probe = await probePmxServer(`http://127.0.0.1:${candidate}`, workspaceRoot);
|
|
161
|
+
if (probe.ok) return probe;
|
|
162
|
+
if (await portAvailable(candidate)) {
|
|
163
|
+
port = candidate;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!port) throw new Error(`No free PMX Canvas port near ${firstPort}.`);
|
|
168
|
+
|
|
169
|
+
// serve --daemon owns the exclusive spawn lock and outlives either client.
|
|
170
|
+
let startupError;
|
|
171
|
+
try {
|
|
172
|
+
await new Promise((resolveStart, rejectStart) => execFile(command.command, [
|
|
173
|
+
...command.args, "serve", "--daemon", "--no-open", `--port=${port}`,
|
|
174
|
+
], {
|
|
175
|
+
cwd: workspaceRoot,
|
|
176
|
+
env: { ...process.env, PMX_CANVAS_WORKSPACE_ROOT: workspaceRoot, PMX_CANVAS_DISABLE_BROWSER_OPEN: "1" },
|
|
177
|
+
timeout: 60_000,
|
|
178
|
+
maxBuffer: 1024 * 1024,
|
|
179
|
+
}, (error, stdout, stderr) => {
|
|
180
|
+
if (error) rejectStart(new Error(`PMX Canvas startup failed: ${stderr || stdout || error.message}`));
|
|
181
|
+
else resolveStart();
|
|
182
|
+
}));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
startupError = error;
|
|
185
|
+
}
|
|
186
|
+
// A concurrent starter can win the lock. Only verified health makes that a success.
|
|
187
|
+
for (let attempt = 0; attempt < (startupError ? 40 : 1); attempt++) {
|
|
188
|
+
const result = await findPmxServer(workspaceRoot, input);
|
|
189
|
+
if (result.ok) return result;
|
|
190
|
+
if (startupError?.message.includes("ENOENT")) break;
|
|
191
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
192
|
+
}
|
|
193
|
+
throw startupError ?? new Error("PMX Canvas started but its workspace health check failed.");
|
|
194
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function createSteeringDeliveryPump({
|
|
2
|
+
consumer,
|
|
3
|
+
claim,
|
|
4
|
+
send,
|
|
5
|
+
mark,
|
|
6
|
+
shouldSend = () => true,
|
|
7
|
+
getPresence,
|
|
8
|
+
setPresence,
|
|
9
|
+
pause,
|
|
10
|
+
onError,
|
|
11
|
+
retryMs = 1_000,
|
|
12
|
+
}) {
|
|
13
|
+
let stopped = false;
|
|
14
|
+
let loopPromise = null;
|
|
15
|
+
const awaitingMark = [];
|
|
16
|
+
|
|
17
|
+
async function flushMarks() {
|
|
18
|
+
while (awaitingMark.length > 0) {
|
|
19
|
+
const id = awaitingMark[0];
|
|
20
|
+
await mark(id, consumer);
|
|
21
|
+
awaitingMark.shift();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function sendWithOwnedPhase(message) {
|
|
26
|
+
const detail = `steer: ${message.slice(0, 60)}`;
|
|
27
|
+
const presence = await getPresence?.();
|
|
28
|
+
const ownsPhase =
|
|
29
|
+
presence?.phase === "idle" &&
|
|
30
|
+
typeof setPresence === "function" &&
|
|
31
|
+
(await setPresence({ phase: "tooling", detail })) !== false;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await send(message);
|
|
35
|
+
} finally {
|
|
36
|
+
if (ownsPhase && typeof getPresence === "function" && typeof setPresence === "function") {
|
|
37
|
+
const current = await getPresence();
|
|
38
|
+
if (current?.phase === "tooling" && current.detail === detail) {
|
|
39
|
+
await setPresence({ phase: "idle", detail: null });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runOnce() {
|
|
46
|
+
await flushMarks();
|
|
47
|
+
const pending = await claim(consumer);
|
|
48
|
+
for (const steering of pending) {
|
|
49
|
+
if (stopped) break;
|
|
50
|
+
if (shouldSend(steering)) await sendWithOwnedPhase(steering.message);
|
|
51
|
+
awaitingMark.push(steering.id);
|
|
52
|
+
await flushMarks();
|
|
53
|
+
}
|
|
54
|
+
return pending.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function run() {
|
|
58
|
+
while (!stopped) {
|
|
59
|
+
try {
|
|
60
|
+
const claimed = await runOnce();
|
|
61
|
+
if (claimed === 0 && !stopped) await pause(retryMs);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
onError(error);
|
|
64
|
+
if (!stopped) await pause(retryMs);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
runOnce,
|
|
71
|
+
start() {
|
|
72
|
+
if (!loopPromise) loopPromise = run();
|
|
73
|
+
return loopPromise;
|
|
74
|
+
},
|
|
75
|
+
stop() {
|
|
76
|
+
stopped = true;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "pmx-canvas",
|
|
4
|
+
"description": "PMX Canvas spatial workbench and MCP tools, with a native canvas panel in the GitHub Copilot app.",
|
|
5
|
+
"version": "0.8.0",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "pskoett",
|
|
8
|
+
"url": "https://github.com/pskoett"
|
|
9
|
+
},
|
|
10
|
+
"repository": "https://github.com/pskoett/pmx-canvas",
|
|
11
|
+
"homepage": "https://github.com/pskoett/pmx-canvas#readme",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"canvas",
|
|
15
|
+
"mcp",
|
|
16
|
+
"spatial",
|
|
17
|
+
"copilot-canvas"
|
|
18
|
+
],
|
|
19
|
+
"extensions": {
|
|
20
|
+
"com.github.copilot": {
|
|
21
|
+
"logo": "assets/preview.png"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const runtimeVersion = JSON.parse(readFileSync(new URL("./runtime.json", import.meta.url), "utf8")).version;
|
|
7
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(runtimeVersion)) {
|
|
8
|
+
throw new Error("PMX Canvas runtime.json must contain an exact package version.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function canonicalWorkspace(path) {
|
|
12
|
+
// .native expands Windows 8.3 short names (RUNNER~1), so every caller compares one spelling.
|
|
13
|
+
const directory = realpathSync.native(resolve(path));
|
|
14
|
+
if (!statSync(directory).isDirectory()) throw new Error("PMX Canvas workspace must be a directory.");
|
|
15
|
+
return directory;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function runtimeCommand() {
|
|
19
|
+
return { command: "bun", args: ["x", "--bun", `pmx-canvas@${runtimeVersion}`] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function preferredPort(input = {}) {
|
|
23
|
+
const port = Number(input.port ?? process.env.PMX_CANVAS_PORT ?? process.env.PMX_WEB_CANVAS_PORT ?? 4313);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PMX Canvas port must be between 1 and 65535.");
|
|
25
|
+
return port;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function explicitServerUrl(input = {}) {
|
|
29
|
+
const value = input.serverUrl ?? process.env.PMX_CANVAS_URL;
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error("PMX Canvas server URL must use HTTP or HTTPS.");
|
|
33
|
+
url.search = "";
|
|
34
|
+
url.hash = "";
|
|
35
|
+
return url.toString().replace(/\/$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NO_SERVER = "No matching PMX Canvas server is running.";
|
|
39
|
+
|
|
40
|
+
export async function probePmxServer(baseUrl, workspaceRoot, input = {}, fetchImpl = fetch) {
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetchImpl(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) });
|
|
44
|
+
} catch {
|
|
45
|
+
// Nothing answered (refused, timed out). The raw fetch error ("fetch failed") is not
|
|
46
|
+
// actionable; a reachable server's own failure below is reported verbatim.
|
|
47
|
+
return { ok: false, baseUrl, health: null, workspaceOk: false, error: NO_SERVER };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (!response.ok) throw new Error(`Health check returned HTTP ${response.status}.`);
|
|
51
|
+
const health = await response.json();
|
|
52
|
+
const workspaceOk = typeof health.workspace === "string" &&
|
|
53
|
+
(input.allowWorkspaceMismatch === true || canonicalWorkspace(health.workspace) === canonicalWorkspace(workspaceRoot));
|
|
54
|
+
return {
|
|
55
|
+
ok: health.ok === true && workspaceOk,
|
|
56
|
+
baseUrl,
|
|
57
|
+
health,
|
|
58
|
+
workspaceOk,
|
|
59
|
+
error: workspaceOk ? null : `PMX server belongs to ${health.workspace ?? "an unknown workspace"}.`,
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { ok: false, baseUrl, health: null, workspaceOk: false, error: error.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function daemonPorts(workspaceRoot) {
|
|
67
|
+
try {
|
|
68
|
+
return readdirSync(resolve(workspaceRoot, ".pmx-canvas"))
|
|
69
|
+
.map((name) => /^daemon-(\d+)\.pid$/.exec(name))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map((match) => Number(match[1]))
|
|
72
|
+
.filter((port) => port > 0 && port <= 65535)
|
|
73
|
+
.sort((a, b) => a - b);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === "ENOENT") return [];
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function findPmxServer(workspaceRoot, input = {}, fetchImpl = fetch) {
|
|
81
|
+
const explicit = explicitServerUrl(input);
|
|
82
|
+
if (explicit) return await probePmxServer(explicit, workspaceRoot, input, fetchImpl);
|
|
83
|
+
const port = preferredPort(input);
|
|
84
|
+
// Daemon pid filenames locate arbitrary ports; health, not the pid file, proves ownership.
|
|
85
|
+
const ports = [...new Set([...daemonPorts(workspaceRoot), port, ...Array.from({ length: 9 }, (_, i) => 4313 + i)])];
|
|
86
|
+
const results = await Promise.all(ports.map((candidate) =>
|
|
87
|
+
probePmxServer(`http://127.0.0.1:${candidate}`, workspaceRoot, input, fetchImpl)));
|
|
88
|
+
return results.find((result) => result.ok) ??
|
|
89
|
+
{ ok: false, baseUrl: null, health: null, workspaceOk: false, error: NO_SERVER };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function portAvailable(port) {
|
|
93
|
+
return await new Promise((resolveAvailable) => {
|
|
94
|
+
const server = createServer();
|
|
95
|
+
server.once("error", () => resolveAvailable(false));
|
|
96
|
+
server.listen(port, "127.0.0.1", () => server.close(() => resolveAvailable(true)));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function ensurePmxServer(workspaceRoot, input = {}, command = runtimeCommand()) {
|
|
101
|
+
workspaceRoot = canonicalWorkspace(workspaceRoot);
|
|
102
|
+
const existing = await findPmxServer(workspaceRoot, input);
|
|
103
|
+
if (existing.ok || input.autoStart === false) return existing;
|
|
104
|
+
if (explicitServerUrl(input)) throw new Error(existing.error);
|
|
105
|
+
|
|
106
|
+
const directory = resolve(workspaceRoot, ".pmx-canvas");
|
|
107
|
+
const lock = resolve(directory, "plugin-start.lock");
|
|
108
|
+
mkdirSync(directory, { recursive: true });
|
|
109
|
+
const deadline = Date.now() + 75_000;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
let fd;
|
|
112
|
+
try {
|
|
113
|
+
fd = openSync(lock, "wx");
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "EEXIST") throw error;
|
|
116
|
+
try {
|
|
117
|
+
const pid = Number(readFileSync(lock, "utf8"));
|
|
118
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 0);
|
|
121
|
+
} catch (signalError) {
|
|
122
|
+
if (signalError.code !== "ESRCH") throw signalError;
|
|
123
|
+
rmSync(lock);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
} else if (Date.now() - statSync(lock).mtimeMs > 5_000) {
|
|
127
|
+
rmSync(lock);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
} catch (lockError) {
|
|
131
|
+
if (lockError.code === "ENOENT") continue;
|
|
132
|
+
throw lockError;
|
|
133
|
+
}
|
|
134
|
+
const server = await findPmxServer(workspaceRoot, input);
|
|
135
|
+
if (server.ok) return server;
|
|
136
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(fd, String(process.pid));
|
|
142
|
+
} finally {
|
|
143
|
+
closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
const server = await findPmxServer(workspaceRoot, input);
|
|
146
|
+
if (server.ok) return server;
|
|
147
|
+
return await startPmxServer(workspaceRoot, input, command);
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(lock, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw new Error("Timed out waiting for another PMX Canvas plugin startup.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function startPmxServer(workspaceRoot, input, command) {
|
|
156
|
+
const firstPort = preferredPort(input);
|
|
157
|
+
let port;
|
|
158
|
+
for (let candidate = firstPort; candidate < Math.min(firstPort + 20, 65536); candidate++) {
|
|
159
|
+
// Another starter may have claimed this port since discovery.
|
|
160
|
+
const probe = await probePmxServer(`http://127.0.0.1:${candidate}`, workspaceRoot);
|
|
161
|
+
if (probe.ok) return probe;
|
|
162
|
+
if (await portAvailable(candidate)) {
|
|
163
|
+
port = candidate;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!port) throw new Error(`No free PMX Canvas port near ${firstPort}.`);
|
|
168
|
+
|
|
169
|
+
// serve --daemon owns the exclusive spawn lock and outlives either client.
|
|
170
|
+
let startupError;
|
|
171
|
+
try {
|
|
172
|
+
await new Promise((resolveStart, rejectStart) => execFile(command.command, [
|
|
173
|
+
...command.args, "serve", "--daemon", "--no-open", `--port=${port}`,
|
|
174
|
+
], {
|
|
175
|
+
cwd: workspaceRoot,
|
|
176
|
+
env: { ...process.env, PMX_CANVAS_WORKSPACE_ROOT: workspaceRoot, PMX_CANVAS_DISABLE_BROWSER_OPEN: "1" },
|
|
177
|
+
timeout: 60_000,
|
|
178
|
+
maxBuffer: 1024 * 1024,
|
|
179
|
+
}, (error, stdout, stderr) => {
|
|
180
|
+
if (error) rejectStart(new Error(`PMX Canvas startup failed: ${stderr || stdout || error.message}`));
|
|
181
|
+
else resolveStart();
|
|
182
|
+
}));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
startupError = error;
|
|
185
|
+
}
|
|
186
|
+
// A concurrent starter can win the lock. Only verified health makes that a success.
|
|
187
|
+
for (let attempt = 0; attempt < (startupError ? 40 : 1); attempt++) {
|
|
188
|
+
const result = await findPmxServer(workspaceRoot, input);
|
|
189
|
+
if (result.ok) return result;
|
|
190
|
+
if (startupError?.message.includes("ENOENT")) break;
|
|
191
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
192
|
+
}
|
|
193
|
+
throw startupError ?? new Error("PMX Canvas started but its workspace health check failed.");
|
|
194
|
+
}
|