pmx-canvas 0.7.2 → 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.
Files changed (251) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +60 -0
  10. package/Readme.md +44 -16
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +844 -710
  23. package/dist/canvas/index.js +92 -90
  24. package/dist/canvas/mcp-app.html +7878 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +1 -1
  28. package/dist/json-render/index.js +55 -55
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  32. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  33. package/dist/types/client/icons.d.ts +1 -2
  34. package/dist/types/client/mcp-app/index.d.ts +1 -0
  35. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  36. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  37. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  38. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  39. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  40. package/dist/types/client/state/attention-store.d.ts +0 -5
  41. package/dist/types/client/state/canvas-store.d.ts +2 -0
  42. package/dist/types/client/state/context-status-store.d.ts +53 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +4 -1
  44. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  45. package/dist/types/client/state/presence-store.d.ts +5 -0
  46. package/dist/types/client/state/session-store.d.ts +33 -3
  47. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  48. package/dist/types/client/theme/tokens.d.ts +1 -3
  49. package/dist/types/client/types.d.ts +9 -0
  50. package/dist/types/json-render/renderer/index.d.ts +2 -1
  51. package/dist/types/json-render/server.d.ts +3 -1
  52. package/dist/types/mcp/app-server.d.ts +3 -0
  53. package/dist/types/mcp/canvas-access.d.ts +1 -1
  54. package/dist/types/mcp/canvas-app.d.ts +4 -0
  55. package/dist/types/mcp/workbench-app.d.ts +4 -0
  56. package/dist/types/server/agent-presence.d.ts +1 -1
  57. package/dist/types/server/ax-state.d.ts +1 -1
  58. package/dist/types/server/canvas-db.d.ts +8 -0
  59. package/dist/types/server/canvas-geometry.d.ts +25 -0
  60. package/dist/types/server/canvas-operations.d.ts +1 -11
  61. package/dist/types/server/canvas-state.d.ts +25 -3
  62. package/dist/types/server/context-brief.d.ts +8 -1
  63. package/dist/types/server/context-reads.d.ts +30 -7
  64. package/dist/types/server/index.d.ts +6 -1
  65. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  66. package/dist/types/server/operations/http.d.ts +2 -0
  67. package/dist/types/server/operations/invoker.d.ts +4 -2
  68. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  69. package/dist/types/server/server.d.ts +1 -0
  70. package/dist/types/server/spatial-analysis.d.ts +2 -0
  71. package/dist/types/shared/agent-context.d.ts +22 -0
  72. package/dist/types/shared/agent-presence.d.ts +3 -10
  73. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  74. package/dist/types/shared/canvas-app.d.ts +54 -0
  75. package/dist/types/shared/themes.d.ts +1 -1
  76. package/docs/RELEASE.md +7 -3
  77. package/docs/agent-plugin.md +123 -0
  78. package/docs/ax-host-adapter-contract.md +13 -2
  79. package/docs/ax-state-contract.md +40 -0
  80. package/docs/cli.md +7 -2
  81. package/docs/design/AgentContext.dc.html +186 -0
  82. package/docs/design/Before.dc.html +208 -0
  83. package/docs/design/BoardPins.dc.html +151 -0
  84. package/docs/design/Chrome.dc.html +208 -0
  85. package/docs/design/Context.dc.html +239 -0
  86. package/docs/design/Flows.dc.html +181 -0
  87. package/docs/design/Home.dc.html +209 -0
  88. package/docs/design/HomeGraph.dc.html +211 -0
  89. package/docs/design/HomeMap.dc.html +117 -0
  90. package/docs/design/HomeTeam.dc.html +176 -0
  91. package/docs/design/Light.dc.html +28 -0
  92. package/docs/design/Main.dc.html +280 -0
  93. package/docs/design/NearPin.dc.html +260 -0
  94. package/docs/design/Pane600.dc.html +120 -0
  95. package/docs/design/Relations.dc.html +297 -0
  96. package/docs/design/Themes.dc.html +121 -0
  97. package/docs/design/TokenSystem.dc.html +136 -0
  98. package/docs/design/Tokens.dc.html +163 -0
  99. package/docs/design/Types1.dc.html +177 -0
  100. package/docs/design/Types2.dc.html +226 -0
  101. package/docs/design/Types3.dc.html +195 -0
  102. package/docs/design/Uses.dc.html +152 -0
  103. package/docs/design/Vision.dc.html +116 -0
  104. package/docs/design/Zoom.dc.html +82 -0
  105. package/docs/design/canvas.json +274 -0
  106. package/docs/design.md +214 -0
  107. package/docs/environment.md +1 -1
  108. package/docs/http-api.md +80 -15
  109. package/docs/mcp.md +198 -3
  110. package/docs/node-types.md +34 -10
  111. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  112. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  113. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  114. package/docs/product-vision-2026-09.md +47 -7
  115. package/docs/screenshot.png +0 -0
  116. package/docs/sdk.md +1 -0
  117. package/package.json +10 -4
  118. package/plugins/pmx-canvas/LICENSE +21 -0
  119. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  120. package/plugins/pmx-canvas/assets/preview.png +0 -0
  121. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  122. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  123. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  124. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  125. package/plugins/pmx-canvas/mcp.json +12 -0
  126. package/plugins/pmx-canvas/plugin.json +24 -0
  127. package/plugins/pmx-canvas/runtime.json +3 -0
  128. package/plugins/pmx-canvas/runtime.mjs +194 -0
  129. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  130. package/plugins/pmx-canvas/tools.json +2560 -0
  131. package/skills/pmx-canvas/SKILL.md +31 -16
  132. package/skills/pmx-canvas/evals/evals.json +69 -0
  133. package/skills/pmx-canvas/references/full-reference.md +14 -10
  134. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  135. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  136. package/skills/pmx-canvas-testing/SKILL.md +54 -4
  137. package/src/cli/commands/copilot.ts +5 -0
  138. package/src/cli/commands/view.ts +1 -1
  139. package/src/cli/index.ts +8 -3
  140. package/src/client/App.tsx +6 -1
  141. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  142. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  143. package/src/client/canvas/CanvasNode.tsx +208 -220
  144. package/src/client/canvas/CanvasViewport.tsx +6 -2
  145. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  146. package/src/client/canvas/CommandBar.tsx +25 -23
  147. package/src/client/canvas/CommandPalette.tsx +3 -2
  148. package/src/client/canvas/ContextMenu.tsx +11 -0
  149. package/src/client/canvas/EdgeLayer.tsx +1 -1
  150. package/src/client/canvas/ExportDialog.tsx +33 -6
  151. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  152. package/src/client/canvas/IntentLayer.tsx +2 -2
  153. package/src/client/canvas/Minimap.tsx +12 -7
  154. package/src/client/canvas/NodeContextMark.tsx +156 -0
  155. package/src/client/canvas/SessionPanel.tsx +4 -7
  156. package/src/client/canvas/SessionReceipt.tsx +114 -17
  157. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  158. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  159. package/src/client/canvas/ToolRail.tsx +58 -108
  160. package/src/client/canvas/TopBar.tsx +16 -11
  161. package/src/client/icons.tsx +2 -12
  162. package/src/client/mcp-app/index.tsx +137 -0
  163. package/src/client/mcp-app/style.css +10 -0
  164. package/src/client/nodes/AttachmentNode.tsx +8 -1
  165. package/src/client/nodes/AxStepControls.tsx +1 -1
  166. package/src/client/nodes/ContextNode.tsx +9 -9
  167. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  168. package/src/client/nodes/FileNode.tsx +8 -5
  169. package/src/client/nodes/ImageNode.tsx +4 -2
  170. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  171. package/src/client/nodes/LedgerNode.tsx +2 -2
  172. package/src/client/nodes/MarkdownNode.tsx +44 -9
  173. package/src/client/nodes/McpAppNode.tsx +22 -4
  174. package/src/client/nodes/PromptNode.tsx +5 -17
  175. package/src/client/nodes/ResponseNode.tsx +3 -15
  176. package/src/client/nodes/StatusNode.tsx +2 -2
  177. package/src/client/nodes/StatusSummary.tsx +3 -1
  178. package/src/client/nodes/TraceNode.tsx +6 -4
  179. package/src/client/nodes/WebpageNode.tsx +1 -1
  180. package/src/client/nodes/ax-node-actions.ts +2 -2
  181. package/src/client/nodes/iframe-document-url.ts +3 -2
  182. package/src/client/nodes/markdown-sanitize.ts +10 -0
  183. package/src/client/nodes/surface-url.ts +10 -0
  184. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  185. package/src/client/nodes/use-surface-frame.ts +3 -1
  186. package/src/client/nodes/use-workbench-asset.ts +26 -0
  187. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  188. package/src/client/state/attention-bridge.ts +0 -8
  189. package/src/client/state/attention-store.ts +0 -17
  190. package/src/client/state/canvas-store.ts +7 -2
  191. package/src/client/state/context-status-store.ts +66 -0
  192. package/src/client/state/human-store.ts +2 -0
  193. package/src/client/state/iframe-mode.ts +5 -0
  194. package/src/client/state/intent-bridge.ts +24 -6
  195. package/src/client/state/near-pin-store.ts +53 -0
  196. package/src/client/state/presence-store.ts +5 -10
  197. package/src/client/state/session-store.ts +106 -9
  198. package/src/client/state/sse-bridge.ts +26 -7
  199. package/src/client/state/theme-override.ts +1 -1
  200. package/src/client/state/workbench-transport.ts +36 -0
  201. package/src/client/theme/global.css +844 -710
  202. package/src/client/theme/surface-theme.css +151 -46
  203. package/src/client/theme/tokens.ts +7 -11
  204. package/src/client/types.ts +10 -0
  205. package/src/json-render/renderer/index.tsx +58 -38
  206. package/src/json-render/server.ts +79 -15
  207. package/src/mcp/app-server.ts +64 -0
  208. package/src/mcp/canvas-access.ts +14 -11
  209. package/src/mcp/canvas-app.ts +318 -0
  210. package/src/mcp/server.ts +1 -1
  211. package/src/mcp/workbench-app.ts +211 -0
  212. package/src/server/agent-presence.ts +45 -6
  213. package/src/server/ax-context.ts +1 -1
  214. package/src/server/ax-state.ts +1 -1
  215. package/src/server/board-export.ts +5 -2
  216. package/src/server/canvas-db.ts +46 -12
  217. package/src/server/canvas-geometry.ts +64 -0
  218. package/src/server/canvas-operations.ts +6 -24
  219. package/src/server/canvas-state.ts +105 -15
  220. package/src/server/canvas-validation.ts +19 -4
  221. package/src/server/context-brief.ts +39 -2
  222. package/src/server/context-reads.ts +86 -15
  223. package/src/server/demo-state.json +31 -31
  224. package/src/server/html-surface.ts +4 -5
  225. package/src/server/index.ts +19 -3
  226. package/src/server/mcp-app-runtime.ts +11 -0
  227. package/src/server/operations/composites.ts +4 -3
  228. package/src/server/operations/http.ts +4 -4
  229. package/src/server/operations/invoker.ts +13 -4
  230. package/src/server/operations/ops/ax-read.ts +1 -1
  231. package/src/server/operations/ops/ax-timeline.ts +37 -0
  232. package/src/server/operations/ops/boards.ts +1 -1
  233. package/src/server/operations/ops/canvas-wire.ts +3 -6
  234. package/src/server/operations/ops/intent.ts +31 -2
  235. package/src/server/operations/ops/json-render.ts +1 -1
  236. package/src/server/operations/ops/nodes.ts +8 -76
  237. package/src/server/operations/ops/query.ts +8 -2
  238. package/src/server/operations/ops/snapshots.ts +4 -1
  239. package/src/server/operations/ops/viewport.ts +29 -5
  240. package/src/server/operations/registry.ts +38 -4
  241. package/src/server/scope-fence.ts +55 -10
  242. package/src/server/server.ts +86 -6
  243. package/src/server/spatial-analysis.ts +2 -2
  244. package/src/server/workboard.ts +6 -1
  245. package/src/{server → shared}/agent-context.ts +2 -2
  246. package/src/shared/agent-presence.ts +3 -15
  247. package/src/shared/ax-surface-protocol.ts +3 -0
  248. package/src/shared/canvas-app.ts +62 -0
  249. package/src/shared/themes.ts +5 -5
  250. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  251. package/src/client/canvas/kind-colors.ts +0 -23
@@ -0,0 +1,930 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createServer as createHttpServer } from "node:http";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
6
+ import { createSteeringDeliveryPump } from "./steering-delivery.mjs";
7
+ import { canonicalWorkspace, ensurePmxServer, explicitServerUrl, findPmxServer, preferredPort, runtimeCommand } from "./runtime.mjs";
8
+
9
+ const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
10
+ const PROJECT_ROOT = resolve(EXTENSION_DIR, "../../..");
11
+ const MAX_AX_CONTEXT_CHARS = 16_000;
12
+ const HEALTH_TIMEOUT_MS = 500;
13
+ const STEERING_WAIT_MS = 30_000;
14
+
15
+ let copilotSession;
16
+ let steeringDeliveryPump;
17
+ let sessionWorkspaceRoot = resolve(process.env.PMX_CANVAS_WORKSPACE_ROOT || process.cwd());
18
+ const panelServers = new Map();
19
+ // Canvas actions are invoked separately from `open`, and their context can
20
+ // omit the canvas input. Keep the server selected for each visible instance so
21
+ // an action cannot fall back to the process/default port behind the panel.
22
+ const panelTargets = new Map();
23
+
24
+ function candidateBaseUrls(input) {
25
+ const explicit = explicitServerUrl(input);
26
+ if (explicit) return [{ baseUrl: explicit, explicit: true }];
27
+
28
+ const port = preferredPort(input);
29
+ return [
30
+ { baseUrl: `http://127.0.0.1:${port}`, explicit: false },
31
+ { baseUrl: `http://localhost:${port}`, explicit: false },
32
+ ];
33
+ }
34
+
35
+ function workspaceRootFrom(ctxOrInput) {
36
+ const inputWorkspace = typeof ctxOrInput?.input?.workspaceRoot === "string" ? ctxOrInput.input.workspaceRoot : null;
37
+ const sessionWorkspace = typeof ctxOrInput?.session?.workingDirectory === "string" ? ctxOrInput.session.workingDirectory : null;
38
+ return resolve(inputWorkspace ?? sessionWorkspace ?? sessionWorkspaceRoot);
39
+ }
40
+
41
+ function workspaceMatches(health, workspaceRoot) {
42
+ if (!health || typeof health.workspace !== "string") return false;
43
+ return canonicalWorkspace(health.workspace) === canonicalWorkspace(workspaceRoot);
44
+ }
45
+
46
+ function delay(ms) {
47
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
48
+ }
49
+
50
+ async function fetchJson(baseUrl, path, options = {}) {
51
+ const response = await fetch(`${baseUrl}${path}`, {
52
+ ...options,
53
+ signal: AbortSignal.timeout(options.timeoutMs ?? HEALTH_TIMEOUT_MS),
54
+ });
55
+ if (!response.ok) {
56
+ throw new Error(`${response.status} ${response.statusText}`);
57
+ }
58
+ return await response.json();
59
+ }
60
+
61
+ async function probeServer(baseUrl, workspaceRoot, options = {}) {
62
+ try {
63
+ const health = await fetchJson(baseUrl, "/health", { timeoutMs: options.timeoutMs ?? HEALTH_TIMEOUT_MS });
64
+ const workspaceOk = options.allowWorkspaceMismatch === true || workspaceMatches(health, workspaceRoot);
65
+ return {
66
+ ok: Boolean(health?.ok) && workspaceOk,
67
+ baseUrl,
68
+ health,
69
+ workspaceOk,
70
+ error: workspaceOk ? null : `PMX server belongs to ${health?.workspace ?? "another workspace"}`,
71
+ };
72
+ } catch (error) {
73
+ return {
74
+ ok: false,
75
+ baseUrl,
76
+ health: null,
77
+ workspaceOk: false,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ };
80
+ }
81
+ }
82
+
83
+ function managedCommand(workspaceRoot) {
84
+ const sourceEntry = resolve(workspaceRoot, "src/cli/index.ts");
85
+ if (workspaceRoot === PROJECT_ROOT && existsSync(sourceEntry)) {
86
+ return {
87
+ command: "bun",
88
+ args: ["run", sourceEntry],
89
+ };
90
+ }
91
+ return runtimeCommand();
92
+ }
93
+
94
+ async function resolvePmxServer(ctxOrInput, options = {}) {
95
+ const workspaceRoot = workspaceRootFrom(ctxOrInput);
96
+ const input = ctxOrInput?.input ?? ctxOrInput ?? {};
97
+ if (options.autoStart === false || input?.autoStart === false) {
98
+ return await findPmxServer(workspaceRoot, input);
99
+ }
100
+ return await ensurePmxServer(workspaceRoot, input, managedCommand(workspaceRoot));
101
+ }
102
+
103
+ async function resolvePanelTarget(ctx, options = {}) {
104
+ const selected = panelTargets.get(ctx?.instanceId);
105
+ if (!selected) {
106
+ const panel = panelServers.get(ctx?.instanceId);
107
+ if (!panel) return await resolvePmxServer(ctx, options);
108
+ // A fallback panel still owns its original target. Action inputs may
109
+ // contain neither that URL nor its workspace, so never rediscover from them.
110
+ const resolved = await resolvePmxServer({
111
+ input: { ...panel.entry.input, ...(options.autoStart === true ? { autoStart: true } : {}) },
112
+ session: { workingDirectory: panel.entry.workspaceRoot },
113
+ }, options);
114
+ panel.entry.pmx = resolved;
115
+ rememberPanelTarget(ctx, resolved);
116
+ return resolved;
117
+ }
118
+
119
+ const probe = await probeServer(selected.baseUrl, selected.workspaceRoot, {
120
+ timeoutMs: options.timeoutMs ?? 2_000,
121
+ });
122
+ if (probe.ok) return probe;
123
+ return {
124
+ ...probe,
125
+ baseUrl: null,
126
+ error: `The displayed PMX Canvas target is no longer valid: ${probe.error ?? "health check failed"}. Action withheld.`,
127
+ };
128
+ }
129
+
130
+ function rememberPanelTarget(ctx, pmx) {
131
+ if (!ctx?.instanceId || !pmx?.ok || !pmx.baseUrl) return;
132
+ panelTargets.set(ctx.instanceId, {
133
+ baseUrl: pmx.baseUrl,
134
+ workspaceRoot: pmx.health.workspace,
135
+ });
136
+ }
137
+
138
+ function escapeHtml(value) {
139
+ return String(value)
140
+ .replaceAll("&", "&")
141
+ .replaceAll("<", "&lt;")
142
+ .replaceAll(">", "&gt;")
143
+ .replaceAll('"', "&quot;");
144
+ }
145
+
146
+ function renderShell(entry) {
147
+ return `<!doctype html>
148
+ <html lang="en">
149
+ <head>
150
+ <meta charset="utf-8" />
151
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
152
+ <title>PMX Canvas</title>
153
+ <style>
154
+ body {
155
+ margin: 0;
156
+ background: var(--background-color-default, #0d1117);
157
+ color: var(--text-color-default, #f0f6fc);
158
+ font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
159
+ }
160
+ .shell {
161
+ max-width: 560px;
162
+ margin: 12vh auto;
163
+ padding: 24px;
164
+ }
165
+ .actions {
166
+ display: flex;
167
+ flex-wrap: wrap;
168
+ gap: 12px;
169
+ margin-top: 24px;
170
+ }
171
+ p { line-height: 1.6; overflow-wrap: anywhere; }
172
+ .workspace {
173
+ color: var(--text-color-muted, #8b949e);
174
+ font-size: 13px;
175
+ }
176
+ .error {
177
+ color: var(--true-color-red, #ff7b72);
178
+ min-height: 24px;
179
+ }
180
+ button {
181
+ border: 1px solid var(--border-color-default, #6e7681);
182
+ border-radius: 8px;
183
+ background: var(--background-color-muted, #21262d);
184
+ color: inherit;
185
+ font: inherit;
186
+ cursor: pointer;
187
+ min-height: 40px;
188
+ padding: 8px 16px;
189
+ }
190
+ button:hover, button:focus-visible {
191
+ border-color: var(--color-focus-outline, #2f81f7);
192
+ }
193
+ button:disabled { opacity: 0.6; cursor: wait; }
194
+ </style>
195
+ </head>
196
+ <body>
197
+ <main class="shell">
198
+ <h1>Connect to PMX Canvas</h1>
199
+ <p>The workbench is not connected yet. Home and recent boards will appear when the server is available.</p>
200
+ <p class="workspace">Workspace: ${escapeHtml(entry.workspaceRoot)}</p>
201
+ <p class="workspace">Server: ${escapeHtml(candidateBaseUrls(entry.input)[0].baseUrl)}</p>
202
+ <p id="connection-status" class="error" role="status">${escapeHtml(entry.pmx?.error ?? "Checking connection…")}</p>
203
+ <div class="actions">
204
+ <button type="button" onclick="connect(false)">Check connection</button>
205
+ <button type="button" onclick="connect(true)">Start server</button>
206
+ </div>
207
+ <p class="workspace">This page checks for the server automatically. Start server launches a local server for this workspace.</p>
208
+ </main>
209
+ <script>
210
+ let busy = false;
211
+ let timer;
212
+ async function connect(start) {
213
+ if (busy) return;
214
+ busy = true;
215
+ clearTimeout(timer);
216
+ const buttons = document.querySelectorAll('button');
217
+ buttons.forEach(button => button.disabled = true);
218
+ const status = document.getElementById('connection-status');
219
+ status.textContent = start ? 'Starting server…' : 'Checking connection…';
220
+ try {
221
+ const response = await fetch(start ? '/start' : '/status', { method: start ? 'POST' : 'GET', cache: 'no-store' });
222
+ const target = await response.json();
223
+ if (response.ok && target.ok && target.baseUrl) {
224
+ // Load the real workbench at its own origin, including its token,
225
+ // Home, boards, and relative API paths. The shell is not a proxy.
226
+ window.location.replace(target.baseUrl + '/workbench?theme=daylight');
227
+ return;
228
+ }
229
+ status.textContent = target.error || 'No matching PMX Canvas server is running.';
230
+ } catch (error) {
231
+ status.textContent = 'Connection check failed. Reopen the canvas if the adapter has stopped.';
232
+ }
233
+ busy = false;
234
+ buttons.forEach(button => button.disabled = false);
235
+ timer = setTimeout(() => connect(false), 2000);
236
+ }
237
+ connect(false);
238
+ </script>
239
+ </body>
240
+ </html>`;
241
+ }
242
+
243
+ async function readRequestJson(req) {
244
+ const chunks = [];
245
+ for await (const chunk of req) chunks.push(chunk);
246
+ if (chunks.length === 0) return {};
247
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
248
+ }
249
+
250
+ async function verifiedEntryTarget(entry) {
251
+ if (!entry.pmx?.ok || !entry.pmx.baseUrl) return { ok: false, baseUrl: null, error: "PMX Canvas server is unavailable." };
252
+ const probe = await probeServer(entry.pmx.baseUrl, entry.pmx.health.workspace, {
253
+ timeoutMs: 2_000,
254
+ });
255
+ if (probe.ok) return probe;
256
+ return {
257
+ ...probe,
258
+ baseUrl: null,
259
+ error: `The displayed PMX Canvas target is no longer valid: ${probe.error ?? "health check failed"}. Action withheld.`,
260
+ };
261
+ }
262
+
263
+ function jsonResponse(res, statusCode, body) {
264
+ res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
265
+ res.end(JSON.stringify(body));
266
+ }
267
+
268
+ async function startPanelServer(instanceId, ctx, pmx) {
269
+ const entry = { pmx, workspaceRoot: workspaceRootFrom(ctx), input: ctx.input ?? {} };
270
+ const server = createHttpServer(async (req, res) => {
271
+ try {
272
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
273
+ if (req.method === "GET" && url.pathname === "/") {
274
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
275
+ res.end(renderShell(entry));
276
+ return;
277
+ }
278
+ if (req.method === "POST" && url.pathname === "/start") {
279
+ const latest = await resolvePanelTarget({ instanceId }, { autoStart: true });
280
+ jsonResponse(res, 200, latest);
281
+ return;
282
+ }
283
+ if (req.method === "GET" && url.pathname === "/status") {
284
+ const latest = await resolvePanelTarget({ instanceId }, { autoStart: false });
285
+ jsonResponse(res, 200, latest);
286
+ return;
287
+ }
288
+ if (req.method === "GET" && url.pathname === "/context") {
289
+ const target = await verifiedEntryTarget(entry);
290
+ if (!target.ok || !target.baseUrl) {
291
+ jsonResponse(res, 409, target);
292
+ return;
293
+ }
294
+ const context = await getAxContext(target.baseUrl, entry.workspaceRoot, entry.input);
295
+ jsonResponse(res, 200, context);
296
+ return;
297
+ }
298
+ if (req.method === "POST" && url.pathname === "/focus") {
299
+ const body = await readRequestJson(req);
300
+ const target = await verifiedEntryTarget(entry);
301
+ if (!target.ok || !target.baseUrl) {
302
+ jsonResponse(res, 409, target);
303
+ return;
304
+ }
305
+ const result = await setAxFocus(target.baseUrl, entry.workspaceRoot, entry.input, body.nodeIds);
306
+ jsonResponse(res, 200, result);
307
+ return;
308
+ }
309
+ if (req.method === "POST" && url.pathname === "/send") {
310
+ const body = await readRequestJson(req);
311
+ if (typeof body.prompt !== "string" || body.prompt.trim() === "") {
312
+ jsonResponse(res, 400, { ok: false, error: "prompt is required" });
313
+ return;
314
+ }
315
+ await copilotSession?.send({ prompt: body.prompt });
316
+ // Explicit user instruction from the panel → mirror onto the AX
317
+ // timeline as a steering message (fire-and-forget).
318
+ const target = await verifiedEntryTarget(entry);
319
+ if (target.ok && target.baseUrl) {
320
+ void fetchJson(target.baseUrl, "/api/canvas/ax/steer", {
321
+ method: "POST",
322
+ headers: { "Content-Type": "application/json" },
323
+ body: JSON.stringify({ message: body.prompt, source: "copilot" }),
324
+ timeoutMs: 2_000,
325
+ }).catch(() => {});
326
+ }
327
+ jsonResponse(res, 200, { ok: true });
328
+ return;
329
+ }
330
+ jsonResponse(res, 404, { ok: false, error: "Not found" });
331
+ } catch (error) {
332
+ jsonResponse(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
333
+ }
334
+ });
335
+ await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
336
+ const address = server.address();
337
+ const port = typeof address === "object" && address ? address.port : 0;
338
+ return { server, url: `http://127.0.0.1:${port}/`, entry };
339
+ }
340
+
341
+ async function getAxContext(baseUrl, workspaceRoot, input = {}, options = {}) {
342
+ const resolved = baseUrl
343
+ ? { ok: true, baseUrl }
344
+ : await resolvePmxServer({ input, session: { workingDirectory: workspaceRoot } }, { autoStart: false });
345
+ if (!resolved.ok || !resolved.baseUrl) {
346
+ return { ok: false, error: resolved.error ?? "PMX Canvas server is unavailable." };
347
+ }
348
+ // A proxied read is one this extension records itself with what it actually
349
+ // injected (after truncation), so the server does not record it again.
350
+ const headers = { "x-pmx-source": "copilot", ...(options.proxied ? { "x-pmx-proxied-read": "1" } : {}) };
351
+ return await fetchJson(resolved.baseUrl, "/api/canvas/ax/context", { headers, timeoutMs: 2_000 });
352
+ }
353
+
354
+ function deliveredPinnedIds(pinnedNodeIds, payloadText) {
355
+ const pinned = new Set(pinnedNodeIds);
356
+ const delivered = new Set();
357
+ const objectStarts = [];
358
+ let inString = false;
359
+ let escaped = false;
360
+ for (let index = 0; index < payloadText.length; index += 1) {
361
+ const character = payloadText[index];
362
+ if (inString) {
363
+ if (escaped) escaped = false;
364
+ else if (character === "\\") escaped = true;
365
+ else if (character === '"') inString = false;
366
+ continue;
367
+ }
368
+ if (character === '"') {
369
+ inString = true;
370
+ } else if (character === "{") {
371
+ objectStarts.push(index);
372
+ } else if (character === "}" && objectStarts.length > 0) {
373
+ const start = objectStarts.pop();
374
+ try {
375
+ const value = JSON.parse(payloadText.slice(start, index + 1));
376
+ if (value && !Array.isArray(value) && typeof value === "object" &&
377
+ Object.hasOwn(value, "id") && typeof value.id === "string" && pinned.has(value.id)) {
378
+ delivered.add(value.id);
379
+ }
380
+ } catch {
381
+ // An inner object may be complete even when its containing object was clipped.
382
+ }
383
+ }
384
+ }
385
+ return pinnedNodeIds.filter((id) => delivered.has(id));
386
+ }
387
+
388
+ /** Records what the per-prompt hook injected, so delivery is measured on what Copilot received. */
389
+ async function recordInjectedContext(baseUrl, context, injected) {
390
+ const pinnedNodeIds = Array.isArray(context?.pinned?.nodeIds) ? context.pinned.nodeIds : [];
391
+ const text = injected ?? "";
392
+ try {
393
+ await fetchJson(baseUrl, "/api/canvas/ax/context-reads", {
394
+ method: "POST",
395
+ headers: { "Content-Type": "application/json", "x-pmx-source": "copilot" },
396
+ body: JSON.stringify({
397
+ channel: "adapter",
398
+ resource: "copilot:prompt-context",
399
+ source: "copilot",
400
+ consumer: "copilot",
401
+ boardId: context.boardId,
402
+ pinnedNodeIds,
403
+ // Same rule as the server (context-reads.ts): a node object with that id, not a bare id list.
404
+ deliveredNodeIds: deliveredPinnedIds(pinnedNodeIds, text),
405
+ bytes: Buffer.byteLength(text, "utf-8"),
406
+ }),
407
+ timeoutMs: 1_500,
408
+ });
409
+ } catch {
410
+ // Instrumentation must never block the prompt.
411
+ }
412
+ }
413
+
414
+ async function getAxStatus(ctx) {
415
+ const resolved = await resolvePanelTarget(ctx, { autoStart: false });
416
+ if (!resolved.ok || !resolved.baseUrl) return { ok: false, server: resolved };
417
+ const state = await fetchJson(resolved.baseUrl, "/api/canvas/ax?includeContext=false", { timeoutMs: 2_000 });
418
+ return { ok: true, server: resolved, ax: state };
419
+ }
420
+
421
+ async function setAxFocus(baseUrl, workspaceRoot, input = {}, nodeIds = []) {
422
+ const resolved = baseUrl
423
+ ? { ok: true, baseUrl }
424
+ : await resolvePmxServer({ input, session: { workingDirectory: workspaceRoot } });
425
+ if (!resolved.ok || !resolved.baseUrl) {
426
+ throw new CanvasError("pmx_unavailable", resolved.error ?? "PMX Canvas server is unavailable.");
427
+ }
428
+ return await fetchJson(resolved.baseUrl, "/api/canvas/ax/focus", {
429
+ method: "POST",
430
+ headers: { "Content-Type": "application/json" },
431
+ body: JSON.stringify({ nodeIds: Array.isArray(nodeIds) ? nodeIds : [], source: "copilot" }),
432
+ timeoutMs: 2_000,
433
+ });
434
+ }
435
+
436
+ // POST a host-agnostic AX record to the canvas with the copilot source label.
437
+ // Resolves the server first so the action returns an actionable error when the
438
+ // canvas is unavailable, then maps the neutral primitive over plain HTTP.
439
+ async function postAxRecord(ctx, path, payload) {
440
+ const resolved = await resolvePanelTarget(ctx, { autoStart: false });
441
+ if (!resolved.ok || !resolved.baseUrl) {
442
+ return { ok: false, error: resolved.error ?? "PMX Canvas server is unavailable." };
443
+ }
444
+ try {
445
+ return await fetchJson(resolved.baseUrl, path, {
446
+ method: "POST",
447
+ headers: { "Content-Type": "application/json" },
448
+ body: JSON.stringify({ ...payload, source: "copilot" }),
449
+ timeoutMs: 2_000,
450
+ });
451
+ } catch (error) {
452
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
453
+ }
454
+ }
455
+
456
+ // Agent presence (rail-chrome-v2): the Copilot session attaches itself so the
457
+ // canvas shows its cursor + phase chip, and the server attributes the agent's
458
+ // MCP/HTTP writes to this session. Best-effort and never blocking the host.
459
+ async function postPresence(workspaceRoot, payload, options = {}) {
460
+ try {
461
+ const resolved = await resolvePmxServer(
462
+ { input: {}, session: { workingDirectory: workspaceRoot } },
463
+ { autoStart: false },
464
+ );
465
+ if (!resolved.ok || !resolved.baseUrl) return false;
466
+ await fetchJson(resolved.baseUrl, "/api/canvas/ax/presence", {
467
+ method: "POST",
468
+ headers: { "Content-Type": "application/json" },
469
+ body: JSON.stringify({ source: "copilot", label: "GitHub Copilot", ...payload }),
470
+ timeoutMs: options.timeoutMs ?? 1_500,
471
+ });
472
+ return true;
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+
478
+ async function getCopilotPresence() {
479
+ try {
480
+ const resolved = await resolvePmxServer(
481
+ { input: {}, session: { workingDirectory: sessionWorkspaceRoot } },
482
+ { autoStart: false },
483
+ );
484
+ if (!resolved.ok || !resolved.baseUrl) return null;
485
+ const snapshot = await fetchJson(resolved.baseUrl, "/api/canvas/ax/presence", { timeoutMs: 1_500 });
486
+ if (!Array.isArray(snapshot.presences)) return null;
487
+ return (
488
+ snapshot.presences.find(
489
+ (presence) =>
490
+ presence?.sessionId === "copilot" ||
491
+ (presence?.source === "copilot" && presence?.agentId == null),
492
+ ) ?? null
493
+ );
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+
499
+ // Fire-and-forget mirror of a copilot-originated action onto the AX timeline.
500
+ // Never throws — adapter UX must not depend on the canvas being reachable.
501
+ function recordCopilotSteering(ctx, message) {
502
+ void postAxRecord(ctx, "/api/canvas/ax/steer", { message }).catch(() => {});
503
+ }
504
+
505
+ async function claimCopilotSteering(consumer) {
506
+ const workspaceRoot = sessionWorkspaceRoot;
507
+ const resolved = await resolvePmxServer(
508
+ { input: {}, session: { workingDirectory: workspaceRoot } },
509
+ { autoStart: false },
510
+ );
511
+ if (!resolved.ok || !resolved.baseUrl) {
512
+ throw new Error(resolved.error ?? "PMX Canvas server is unavailable.");
513
+ }
514
+ const delivery = await fetchJson(
515
+ resolved.baseUrl,
516
+ `/api/canvas/ax/delivery/pending?consumer=${encodeURIComponent(consumer)}&limit=1&order=oldest&waitMs=${STEERING_WAIT_MS}`,
517
+ { timeoutMs: STEERING_WAIT_MS + 2_000 },
518
+ );
519
+ return Array.isArray(delivery.pending) ? delivery.pending : [];
520
+ }
521
+
522
+ async function markCopilotSteering(id, consumer) {
523
+ const workspaceRoot = sessionWorkspaceRoot;
524
+ const resolved = await resolvePmxServer(
525
+ { input: {}, session: { workingDirectory: workspaceRoot } },
526
+ { autoStart: false },
527
+ );
528
+ if (!resolved.ok || !resolved.baseUrl) {
529
+ throw new Error(resolved.error ?? "PMX Canvas server is unavailable.");
530
+ }
531
+ await fetchJson(resolved.baseUrl, `/api/canvas/ax/delivery/${encodeURIComponent(id)}/mark`, {
532
+ method: "POST",
533
+ headers: { "Content-Type": "application/json" },
534
+ body: JSON.stringify({ consumer }),
535
+ timeoutMs: 2_000,
536
+ });
537
+ }
538
+
539
+ let lastSteeringDeliveryError = null;
540
+ function reportSteeringDeliveryError(error) {
541
+ const message = error instanceof Error ? error.message : String(error);
542
+ if (message === lastSteeringDeliveryError) return;
543
+ lastSteeringDeliveryError = message;
544
+ process.stderr.write(`[pmx-canvas] Copilot steering delivery retrying: ${message}\n`);
545
+ }
546
+
547
+ async function getAxTimeline(ctx, limit) {
548
+ const resolved = await resolvePanelTarget(ctx, { autoStart: false });
549
+ if (!resolved.ok || !resolved.baseUrl) return { ok: false, error: resolved.error };
550
+ const query = typeof limit === "number" && limit > 0 ? `?limit=${limit}` : "";
551
+ return await fetchJson(resolved.baseUrl, `/api/canvas/ax/timeline${query}`, { timeoutMs: 2_000 });
552
+ }
553
+
554
+ function hasUsefulAxContext(context) {
555
+ return Boolean(context?.pinned?.count > 0 || context?.focus?.nodeIds?.length > 0);
556
+ }
557
+
558
+ function formatAdditionalContext(context, baseUrl) {
559
+ if (!hasUsefulAxContext(context)) return null;
560
+ const json = JSON.stringify(context, null, 2);
561
+ const clipped = json.length > MAX_AX_CONTEXT_CHARS
562
+ ? `${json.slice(0, MAX_AX_CONTEXT_CHARS)}\n...<truncated>`
563
+ : json;
564
+ return [
565
+ "PMX Canvas AX context from the visible workbench.",
566
+ "Treat pinned nodes and focused nodes as human-selected working context when relevant.",
567
+ `Server: ${baseUrl}`,
568
+ clipped,
569
+ ].join("\n");
570
+ }
571
+
572
+ const pmxCanvas = createCanvas({
573
+ id: "pmx-canvas",
574
+ displayName: "PMX Canvas",
575
+ description: "Open the PMX Canvas workbench and bridge AX pinned/focused context into Copilot.",
576
+ inputSchema: {
577
+ type: "object",
578
+ properties: {
579
+ serverUrl: { type: "string", description: "Optional existing PMX Canvas server URL." },
580
+ port: { type: "integer", minimum: 1, description: "Preferred PMX Canvas server port." },
581
+ autoStart: { type: "boolean", description: "Start PMX Canvas if no matching server is running." },
582
+ allowWorkspaceMismatch: { type: "boolean", description: "Allow connecting to a PMX server from another workspace." },
583
+ workspaceRoot: { type: "string", description: "Workspace root for server discovery/startup." },
584
+ },
585
+ additionalProperties: false,
586
+ },
587
+ actions: [
588
+ {
589
+ name: "status",
590
+ description: "Return PMX Canvas server and AX state without serialized node context. Use get_ax_context for full pinned/focused context.",
591
+ handler: async (ctx) => await getAxStatus(ctx),
592
+ },
593
+ {
594
+ name: "get_ax_context",
595
+ description: "Return the current PMX Canvas AX pinned and focused context.",
596
+ handler: async (ctx) => {
597
+ const resolved = await resolvePanelTarget(ctx, { autoStart: false });
598
+ if (!resolved.ok || !resolved.baseUrl) return { ok: false, error: resolved.error };
599
+ return await getAxContext(resolved.baseUrl, workspaceRootFrom(ctx), ctx.input ?? {});
600
+ },
601
+ },
602
+ {
603
+ name: "focus_nodes",
604
+ description: "Set PMX Canvas AX focus to the provided node IDs using Copilot as the source.",
605
+ inputSchema: {
606
+ type: "object",
607
+ properties: {
608
+ nodeIds: {
609
+ type: "array",
610
+ items: { type: "string" },
611
+ },
612
+ },
613
+ required: ["nodeIds"],
614
+ additionalProperties: false,
615
+ },
616
+ handler: async (ctx) => {
617
+ const resolved = await resolvePanelTarget(ctx, { autoStart: false });
618
+ if (!resolved.ok || !resolved.baseUrl) {
619
+ throw new CanvasError("pmx_unavailable", resolved.error ?? "PMX Canvas server is unavailable.");
620
+ }
621
+ return await setAxFocus(resolved.baseUrl, workspaceRootFrom(ctx), ctx.input ?? {}, ctx.input?.nodeIds);
622
+ },
623
+ },
624
+ {
625
+ name: "send_instruction",
626
+ description: "Send a prompt from the PMX Canvas adapter into the active Copilot session.",
627
+ inputSchema: {
628
+ type: "object",
629
+ properties: {
630
+ prompt: { type: "string" },
631
+ },
632
+ required: ["prompt"],
633
+ additionalProperties: false,
634
+ },
635
+ handler: async (ctx) => {
636
+ const prompt = typeof ctx.input?.prompt === "string" ? ctx.input.prompt.trim() : "";
637
+ if (!prompt) throw new CanvasError("prompt_required", "prompt is required");
638
+ await copilotSession?.send({ prompt });
639
+ // Mirror the explicit user instruction onto the AX timeline as a
640
+ // steering message (fire-and-forget). This is an explicit action
641
+ // flow, never a sync from the prompt hook.
642
+ recordCopilotSteering(ctx, prompt);
643
+ return { ok: true };
644
+ },
645
+ },
646
+ {
647
+ name: "add_work_item",
648
+ description: "Add a canvas-bound PMX AX work item (visible task/plan/status) from Copilot.",
649
+ inputSchema: {
650
+ type: "object",
651
+ properties: {
652
+ title: { type: "string" },
653
+ status: { type: "string", enum: ["todo", "in-progress", "blocked", "done", "cancelled"] },
654
+ detail: { type: "string" },
655
+ nodeIds: { type: "array", items: { type: "string" } },
656
+ },
657
+ required: ["title"],
658
+ additionalProperties: false,
659
+ },
660
+ handler: async (ctx) => await postAxRecord(ctx, "/api/canvas/ax/work", {
661
+ title: ctx.input?.title,
662
+ ...(ctx.input?.status ? { status: ctx.input.status } : {}),
663
+ ...(ctx.input?.detail ? { detail: ctx.input.detail } : {}),
664
+ ...(Array.isArray(ctx.input?.nodeIds) ? { nodeIds: ctx.input.nodeIds } : {}),
665
+ }),
666
+ },
667
+ {
668
+ name: "request_approval",
669
+ description: "Request human approval before a high-impact action via a PMX AX approval gate.",
670
+ inputSchema: {
671
+ type: "object",
672
+ properties: {
673
+ title: { type: "string" },
674
+ detail: { type: "string" },
675
+ action: { type: "string" },
676
+ nodeIds: { type: "array", items: { type: "string" } },
677
+ },
678
+ required: ["title"],
679
+ additionalProperties: false,
680
+ },
681
+ handler: async (ctx) => await postAxRecord(ctx, "/api/canvas/ax/approval", {
682
+ title: ctx.input?.title,
683
+ ...(ctx.input?.detail ? { detail: ctx.input.detail } : {}),
684
+ ...(ctx.input?.action ? { action: ctx.input.action } : {}),
685
+ ...(Array.isArray(ctx.input?.nodeIds) ? { nodeIds: ctx.input.nodeIds } : {}),
686
+ }),
687
+ },
688
+ {
689
+ name: "resolve_approval",
690
+ description: "Resolve a pending PMX AX approval gate (approved or rejected).",
691
+ inputSchema: {
692
+ type: "object",
693
+ properties: {
694
+ id: { type: "string" },
695
+ decision: { type: "string", enum: ["approved", "rejected"] },
696
+ resolution: { type: "string" },
697
+ },
698
+ required: ["id", "decision"],
699
+ additionalProperties: false,
700
+ },
701
+ handler: async (ctx) => await postAxRecord(
702
+ ctx,
703
+ `/api/canvas/ax/approval/${encodeURIComponent(String(ctx.input?.id ?? ""))}/resolve`,
704
+ {
705
+ decision: ctx.input?.decision,
706
+ ...(ctx.input?.resolution ? { resolution: ctx.input.resolution } : {}),
707
+ },
708
+ ),
709
+ },
710
+ {
711
+ name: "add_review_annotation",
712
+ description: "Add a canvas-bound PMX AX review annotation (comment/finding) anchored to a node, file, or region.",
713
+ inputSchema: {
714
+ type: "object",
715
+ properties: {
716
+ body: { type: "string" },
717
+ kind: { type: "string", enum: ["comment", "finding"] },
718
+ severity: { type: "string", enum: ["info", "warning", "error"] },
719
+ anchorType: { type: "string", enum: ["node", "file", "region"] },
720
+ nodeId: { type: "string" },
721
+ file: { type: "string" },
722
+ },
723
+ required: ["body"],
724
+ additionalProperties: false,
725
+ },
726
+ handler: async (ctx) => await postAxRecord(ctx, "/api/canvas/ax/review", {
727
+ body: ctx.input?.body,
728
+ ...(ctx.input?.kind ? { kind: ctx.input.kind } : {}),
729
+ ...(ctx.input?.severity ? { severity: ctx.input.severity } : {}),
730
+ ...(ctx.input?.anchorType ? { anchorType: ctx.input.anchorType } : {}),
731
+ ...(ctx.input?.nodeId ? { nodeId: ctx.input.nodeId } : {}),
732
+ ...(ctx.input?.file ? { file: ctx.input.file } : {}),
733
+ }),
734
+ },
735
+ {
736
+ name: "get_timeline",
737
+ description: "Read the bounded PMX AX timeline (events, evidence, steering) for diagnostics and continuity.",
738
+ inputSchema: {
739
+ type: "object",
740
+ properties: {
741
+ limit: { type: "integer", minimum: 1, maximum: 200 },
742
+ },
743
+ additionalProperties: false,
744
+ },
745
+ handler: async (ctx) => await getAxTimeline(ctx, ctx.input?.limit),
746
+ },
747
+ {
748
+ name: "report_capability",
749
+ description: "Report this Copilot host/session capability to the canvas for AX diagnostics.",
750
+ inputSchema: {
751
+ type: "object",
752
+ properties: {
753
+ canvas: { type: "boolean" },
754
+ hooks: { type: "boolean" },
755
+ tools: { type: "boolean" },
756
+ sessionMessaging: { type: "boolean" },
757
+ permissions: { type: "boolean" },
758
+ files: { type: "boolean" },
759
+ uiPrompts: { type: "boolean" },
760
+ },
761
+ additionalProperties: false,
762
+ },
763
+ handler: async (ctx) => {
764
+ const resolved = await resolvePanelTarget(ctx, { autoStart: false });
765
+ if (!resolved.ok || !resolved.baseUrl) {
766
+ return { ok: false, error: resolved.error ?? "PMX Canvas server is unavailable." };
767
+ }
768
+ try {
769
+ return await fetchJson(resolved.baseUrl, "/api/canvas/ax/host-capability", {
770
+ method: "PUT",
771
+ headers: { "Content-Type": "application/json" },
772
+ body: JSON.stringify({
773
+ host: "copilot",
774
+ canvas: ctx.input?.canvas === true,
775
+ hooks: ctx.input?.hooks === true,
776
+ tools: ctx.input?.tools === true,
777
+ sessionMessaging: ctx.input?.sessionMessaging === true,
778
+ permissions: ctx.input?.permissions === true,
779
+ files: ctx.input?.files === true,
780
+ uiPrompts: ctx.input?.uiPrompts === true,
781
+ source: "copilot",
782
+ }),
783
+ timeoutMs: 2_000,
784
+ });
785
+ } catch (error) {
786
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
787
+ }
788
+ },
789
+ },
790
+ ],
791
+ open: async (ctx) => {
792
+ panelTargets.delete(ctx.instanceId);
793
+ let pmx;
794
+ try {
795
+ pmx = await resolvePmxServer(ctx);
796
+ } catch (error) {
797
+ pmx = {
798
+ ok: false,
799
+ baseUrl: null,
800
+ health: null,
801
+ workspaceOk: false,
802
+ error: error instanceof Error ? error.message : String(error),
803
+ };
804
+ }
805
+
806
+ if (pmx.ok && pmx.baseUrl) {
807
+ rememberPanelTarget(ctx, pmx);
808
+ const fallbackPanel = panelServers.get(ctx.instanceId);
809
+ if (fallbackPanel) {
810
+ panelServers.delete(ctx.instanceId);
811
+ await new Promise((resolveClose) => fallbackPanel.server.close(() => resolveClose()));
812
+ }
813
+ return {
814
+ title: "PMX Canvas",
815
+ status: "Connected",
816
+ url: `${pmx.baseUrl}/workbench?theme=daylight`,
817
+ };
818
+ }
819
+
820
+ let panel = panelServers.get(ctx.instanceId);
821
+ if (!panel) {
822
+ panel = await startPanelServer(ctx.instanceId, ctx, pmx);
823
+ panelServers.set(ctx.instanceId, panel);
824
+ } else {
825
+ panel.entry.pmx = pmx;
826
+ panel.entry.workspaceRoot = workspaceRootFrom(ctx);
827
+ panel.entry.input = ctx.input ?? {};
828
+ }
829
+ rememberPanelTarget(ctx, pmx);
830
+ return {
831
+ title: "PMX Canvas",
832
+ status: pmx.ok ? "Connected" : "Needs server",
833
+ url: panel.url,
834
+ };
835
+ },
836
+ onClose: async (ctx) => {
837
+ panelTargets.delete(ctx.instanceId);
838
+ const panel = panelServers.get(ctx.instanceId);
839
+ if (!panel) return;
840
+ panelServers.delete(ctx.instanceId);
841
+ await new Promise((resolveClose) => panel.server.close(() => resolveClose()));
842
+ },
843
+ });
844
+
845
+ copilotSession = await joinSession({
846
+ canvases: [pmxCanvas],
847
+ hooks: {
848
+ onUserPromptSubmitted: async (input) => {
849
+ const workspaceRoot = resolve(input?.workingDirectory ?? sessionWorkspaceRoot);
850
+ sessionWorkspaceRoot = workspaceRoot;
851
+ // A submitted prompt means the agent is about to think: attach (idempotent)
852
+ // and flip the phase. The agent's subsequent canvas writes reach the server
853
+ // as MCP/HTTP tool calls and are attributed to this session.
854
+ void postPresence(workspaceRoot, { attached: true, phase: "thinking", detail: null });
855
+ const resolved = await resolvePmxServer({ input: {}, session: { workingDirectory: workspaceRoot } }, { autoStart: false });
856
+ if (!resolved.ok || !resolved.baseUrl) return undefined;
857
+ const context = await getAxContext(resolved.baseUrl, workspaceRoot, {}, { proxied: true });
858
+ const additionalContext = formatAdditionalContext(context, resolved.baseUrl);
859
+ void recordInjectedContext(resolved.baseUrl, context, additionalContext);
860
+ return additionalContext ? { additionalContext } : undefined;
861
+ },
862
+ },
863
+ });
864
+
865
+ sessionWorkspaceRoot = resolve((await copilotSession.rpc.metadata.snapshot()).workingDirectory);
866
+
867
+ steeringDeliveryPump = createSteeringDeliveryPump({
868
+ consumer: "copilot",
869
+ claim: claimCopilotSteering,
870
+ send: async (message) => await copilotSession.send({ prompt: message }),
871
+ mark: markCopilotSteering,
872
+ getPresence: getCopilotPresence,
873
+ setPresence: async (patch) => await postPresence(sessionWorkspaceRoot, patch),
874
+ shouldSend: (() => {
875
+ const startedAt = Date.now();
876
+ return (steering) => Date.parse(steering.createdAt) >= startedAt;
877
+ })(),
878
+ pause: delay,
879
+ onError: reportSteeringDeliveryError,
880
+ });
881
+ void steeringDeliveryPump.start();
882
+
883
+ // Attach the session as soon as the host joins (the canvas may not be running
884
+ // yet — the prompt hook above re-attaches lazily in that case).
885
+ void postPresence(sessionWorkspaceRoot, { attached: true });
886
+
887
+ // Real context window (rail-chrome-v2): Copilot emits `session.usage_info`
888
+ // with the live token count and the model's window after each turn. Report it
889
+ // on the presence so the board's top-bar meter shows the agent's ACTUAL window
890
+ // instead of the pinned-context estimate. Root agent only — sub-agent events
891
+ // carry an `agentId` and have their own windows. Coalesced to one POST per
892
+ // 500ms: a turn can emit several usage events back to back.
893
+ let usageFlush = null;
894
+ let pendingUsage = null;
895
+ function reportContextUsage(event) {
896
+ const data = event?.data;
897
+ if (event?.agentId || !data || typeof data.currentTokens !== "number" || typeof data.tokenLimit !== "number" || data.tokenLimit <= 0) {
898
+ return;
899
+ }
900
+ pendingUsage = { used: Math.max(0, Math.round(data.currentTokens)), total: Math.round(data.tokenLimit) };
901
+ if (usageFlush) return;
902
+ usageFlush = setTimeout(() => {
903
+ usageFlush = null;
904
+ const usage = pendingUsage;
905
+ pendingUsage = null;
906
+ if (usage) void postPresence(sessionWorkspaceRoot, { contextUsage: usage });
907
+ }, 500);
908
+ }
909
+ if (typeof copilotSession?.on === "function") {
910
+ try {
911
+ copilotSession.on("session.usage_info", reportContextUsage);
912
+ } catch {
913
+ // An SDK without the event keeps the estimate — the meter says so.
914
+ }
915
+ }
916
+
917
+ async function shutdown() {
918
+ steeringDeliveryPump?.stop();
919
+ // Detach the presence first so the canvas returns to the quiet board the
920
+ // moment the host goes away. The shared daemon belongs to the workspace. The whole
921
+ // detach is capped at 800ms (server probing + the POST) so a host with a
922
+ // short SIGTERM grace window can never be held past it.
923
+ await Promise.race([
924
+ postPresence(sessionWorkspaceRoot, { attached: false }, { timeoutMs: 500 }),
925
+ delay(800),
926
+ ]);
927
+ process.exit(0);
928
+ }
929
+ process.once("SIGTERM", () => void shutdown());
930
+ process.once("SIGINT", () => void shutdown());