@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98

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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,973 @@
1
+ import { h as ReviewCliInstallStampSchema } from "./src-DnwdaQ2r.js";
2
+ import { G as readStoreAuth, Z as StoreClient } from "./review-agent-traces-DNJFYzc8.js";
3
+ import { A as devReviewHome, N as writeFileAtomicAsync, O as withFileLock, P as writePrivateJsonAtomic } from "./s3-config-CAxhO9u_.js";
4
+ import { B as installHarnessHooks, C as disableTraceMachine, E as traceMachineStatus, F as listTraceRepositoryRoots, G as traceScope, I as traceRepositoryStatus, N as disableAllTraceRepositories, P as enableTraceRepository, R as AGENT_TRACE_HOOK_AGENTS, S as configureTraceMachine, T as traceMachineEnabled, V as removeAgentTraceHook, n as reviewDesktopStateDir, z as describeTraceHookOwners } from "./review-home-paths-6zZH-1c9.js";
5
+ import { i as readReviewPackageVersion } from "./package-paths-B6-zxvIO.js";
6
+ import { n as isFile, t as isDirectory } from "./fs-utils-BMPLt0cr.js";
7
+ import { createHash } from "node:crypto";
8
+ import { constants } from "node:fs";
9
+ import { access, chmod, lstat, readFile, rm, stat } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { pathToFileURL } from "node:url";
15
+ import { valid } from "semver";
16
+ //#region src/cursor-deeplink.ts
17
+ /** Cursor's one-click MCP install link for Whiteboard's server. */
18
+ function cursorInstallDeeplink(launch) {
19
+ const config = Buffer.from(JSON.stringify({
20
+ command: launch.command,
21
+ args: launch.args
22
+ })).toString("base64");
23
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=whiteboard&config=${encodeURIComponent(config)}`;
24
+ }
25
+ //#endregion
26
+ //#region src/connect-prompts.ts
27
+ /** The macOS and Linux launch form; the Claude and Cursor plugins must match it byte for byte, and the Codex plugin's bin/whiteboard-mcp runs the same command. */
28
+ const REVIEW_MCP_LAUNCH = {
29
+ command: "sh",
30
+ args: ["-c", "exec \"$HOME/.local/bin/whiteboard\" mcp"]
31
+ };
32
+ /**
33
+ * Windows has no sh, and Node-based harnesses cannot start a .cmd file
34
+ * directly. cmd finds whiteboard.cmd on PATH, whether the first-run step or
35
+ * the installer put it there.
36
+ */
37
+ const WINDOWS_MCP_LAUNCH = {
38
+ command: "cmd",
39
+ args: [
40
+ "/d",
41
+ "/c",
42
+ "whiteboard",
43
+ "mcp"
44
+ ]
45
+ };
46
+ const FFF_INSTALL_URL = "https://raw.githubusercontent.com/dmtrKovalenko/fff/v0.11.0/install-mcp.sh";
47
+ const PI_FFF_PACKAGE = "npm:@ff-labs/pi-fff";
48
+ const PI_WHITEBOARD_PACKAGE = "npm:@dev.fast/pi-whiteboard";
49
+ function reviewMcpLaunch(hasShim, platform = process.platform) {
50
+ if (platform === "win32") return {
51
+ command: WINDOWS_MCP_LAUNCH.command,
52
+ args: [...WINDOWS_MCP_LAUNCH.args]
53
+ };
54
+ return hasShim ? {
55
+ command: REVIEW_MCP_LAUNCH.command,
56
+ args: [...REVIEW_MCP_LAUNCH.args]
57
+ } : {
58
+ command: "whiteboard",
59
+ args: ["mcp"]
60
+ };
61
+ }
62
+ function shellQuote(value) {
63
+ return "'" + value.replaceAll("'", "'\"'\"'") + "'";
64
+ }
65
+ function numbered(steps) {
66
+ return steps.map((step, index) => `${index + 1}. ${step}`);
67
+ }
68
+ function fffSteps(input, target) {
69
+ if (!input.traceEnabled) return [];
70
+ if (target === "pi" || target === "omp") return [`Run: ${target} install ${PI_FFF_PACKAGE}`];
71
+ const binary = shellQuote(input.fffBinaryPath);
72
+ const root = shellQuote(input.fffCorpusRoot);
73
+ return [`Unless \`${target} mcp get fff\` finds an existing server, run:\n\n\`\`\`sh\n[ -x ${binary} ] || curl -fsSL ${FFF_INSTALL_URL} | bash\n${target === "claude" ? "claude mcp add -s user" : "codex mcp add"} fff -- ${binary} ${root}\n\`\`\``];
74
+ }
75
+ /** The Claude plugin's sh launch cannot start on Windows, so register the server directly. */
76
+ const CLAUDE_WINDOWS_MCP_ADD = `claude mcp add -s user whiteboard -- ${WINDOWS_MCP_LAUNCH.command} ${WINDOWS_MCP_LAUNCH.args.join(" ")}`;
77
+ /** Copilot CLI loads the Claude plugin, so on Windows it too registers the server directly. */
78
+ const COPILOT_WINDOWS_MCP_ADD = `copilot mcp add whiteboard -- ${WINDOWS_MCP_LAUNCH.command} ${WINDOWS_MCP_LAUNCH.args.join(" ")}`;
79
+ function pluginSteps(target, platform) {
80
+ switch (target) {
81
+ case "claude":
82
+ if (platform === "win32") return [`Run:\n\n\`\`\`sh\nclaude plugin uninstall whiteboard@devfast # its launch cannot start on Windows, if installed\nclaude mcp remove -s user whiteboard # old registration, if any\n${CLAUDE_WINDOWS_MCP_ADD}\n\`\`\``];
83
+ return ["Run:\n\n```sh\nclaude plugin marketplace add devdotfast/whiteboard\nclaude plugin install whiteboard@devfast --scope user\nclaude mcp remove -s user whiteboard # old manual registration, if any\n```"];
84
+ case "codex": return ["Run:\n\n```sh\ncodex plugin marketplace add devdotfast/whiteboard\ncodex plugin add whiteboard@devfast\ncodex mcp remove whiteboard # old manual registration, if any\n```"];
85
+ case "opencode": {
86
+ const launch = reviewMcpLaunch(true, platform);
87
+ const entry = JSON.stringify({ whiteboard: {
88
+ type: "local",
89
+ command: [launch.command, ...launch.args]
90
+ } }, null, 2);
91
+ return [`On OpenCode 2.0 or later, run:\n\n\`\`\`sh\nopencode mcp add --global whiteboard -- ${launchCommand(launch)}\n\`\`\`\n\nOn older OpenCode, add this entry to "mcp" in ~/.config/opencode/opencode.json, keeping the rest of the file:\n\n\`\`\`json\n${entry}\n\`\`\``, `Remove "@dev.fast/opencode-whiteboard" from "plugin" in ~/.config/opencode/opencode.json if it is there.`];
92
+ }
93
+ case "copilot":
94
+ if (platform === "win32") return [`Run:\n\n\`\`\`sh\ncopilot plugin uninstall whiteboard@devfast # its launch cannot start on Windows, if installed\ncopilot mcp remove whiteboard # old registration, if any\n${COPILOT_WINDOWS_MCP_ADD}\n\`\`\``];
95
+ return ["Run:\n\n```sh\ncopilot plugin marketplace add devdotfast/whiteboard\ncopilot plugin install whiteboard@devfast\ncopilot mcp remove whiteboard # old manual registration, if any\n```"];
96
+ case "pi": return [`On Pi 0.99.0 or later, run:\n\n\`\`\`sh\npi mcp add whiteboard -- ${launchCommand(reviewMcpLaunch(true, platform))}\npi remove ${PI_WHITEBOARD_PACKAGE} # if installed\n\`\`\`\n\nOn older Pi, run \`pi install ${PI_WHITEBOARD_PACKAGE}\`.`];
97
+ case "omp": return [`Add this entry to mcpServers in ~/.omp/agent/mcp.json, keeping the rest of the file:\n\n\`\`\`json\n${JSON.stringify({ whiteboard: reviewMcpLaunch(true, platform) }, null, 2)}\n\`\`\``, `Run \`omp plugin uninstall @dev.fast/pi-whiteboard\` if installed.`];
98
+ }
99
+ }
100
+ function launchCommand(launch) {
101
+ return [launch.command, ...launch.args].map((arg) => /^[\w./-]+$/.test(arg) ? arg : shellQuote(arg)).join(" ");
102
+ }
103
+ function cursorSteps() {
104
+ const launch = reviewMcpLaunch(true);
105
+ const deeplink = cursorInstallDeeplink(launch);
106
+ const entry = JSON.stringify({ whiteboard: launch }, null, 2);
107
+ return [`Do not paste the install link in chat. Chat clients do not open cursor:// links. Open Cursor's MCP install deeplink with the OS URL handler using the command for this operating system, then stop and let me confirm the install:\n\nmacOS: open ${shellQuote(deeplink)}\nLinux: xdg-open ${shellQuote(deeplink)}\nWindows: cmd /c start "" "${deeplink}"`, `After I confirm, check for a whiteboard server in ~/.cursor/mcp.json. If the deeplink did not add it, merge this entry into the file's mcpServers object without removing other servers or settings (create the file and object if missing). Do not use this fallback if I declined the install:\n\n\`\`\`json\n${entry}\n\`\`\``];
108
+ }
109
+ function connectPrompt(target, input) {
110
+ if (!input.hasShim) return "Connect this agent to dev.fast Whiteboard.\n\nStop and ask me to install the whiteboard command in Whiteboard Desktop (Settings → Command line), then run this connect command again. The connection requires ~/.local/bin/whiteboard.";
111
+ const extra = target === "claude" || target === "codex" || target === "pi" || target === "omp" ? fffSteps(input, target) : [];
112
+ const verify = target === "pi" ? "Ask me to run /reload, then call `session_get_instructions` on the Whiteboard server (older Pi: `whiteboard api session_get_instructions '{}'`). Do not author anything yet." : target === "omp" ? "Ask me to run /mcp reload, then call `session_get_instructions` on the Whiteboard server. Do not author anything yet." : target === "opencode" ? "Stop and tell me to quit and reopen OpenCode: it loads MCP servers only at startup. After I reopen it, call `session_get_instructions` on the Whiteboard server to confirm the connection. Do not author anything yet." : "Reload your MCP tools and call `session_get_instructions` on the Whiteboard server. If a restart is needed, tell me and verify after it. Do not author anything yet.";
113
+ return [
114
+ "Connect this agent to dev.fast Whiteboard.",
115
+ "",
116
+ ...numbered([
117
+ ...input.legacyPaths.length ? [`Delete these old Whiteboard skill folders and plugin files:\n${input.legacyPaths.map((item) => `- ${JSON.stringify(item)}`).join("\n")}`] : [],
118
+ ...target === "cursor" ? cursorSteps() : [...pluginSteps(target, input.platform ?? process.platform), ...extra],
119
+ verify
120
+ ])
121
+ ].join("\n");
122
+ }
123
+ function connectSetupPrompt(target) {
124
+ return `Run \`whiteboard connect ${target}\` and follow the instructions to connect this agent to Whiteboard.`;
125
+ }
126
+ function connectSetupPrompts() {
127
+ return {
128
+ claude: connectSetupPrompt("claude"),
129
+ codex: connectSetupPrompt("codex"),
130
+ cursor: connectSetupPrompt("cursor"),
131
+ opencode: connectSetupPrompt("opencode"),
132
+ pi: connectSetupPrompt("pi"),
133
+ omp: connectSetupPrompt("omp"),
134
+ copilot: connectSetupPrompt("copilot")
135
+ };
136
+ }
137
+ function connectPrompts(input) {
138
+ return {
139
+ claude: connectPrompt("claude", input),
140
+ codex: connectPrompt("codex", input),
141
+ cursor: connectPrompt("cursor", input),
142
+ opencode: connectPrompt("opencode", input),
143
+ pi: connectPrompt("pi", input),
144
+ omp: connectPrompt("omp", input),
145
+ copilot: connectPrompt("copilot", input)
146
+ };
147
+ }
148
+ //#endregion
149
+ //#region src/legacy-skills.ts
150
+ const LEGACY_SKILL_NAMES = [
151
+ "whiteboard",
152
+ "dev-review",
153
+ "dev-review-batch",
154
+ "dev-file-lenses",
155
+ "scratchpad",
156
+ "trace-archaeology",
157
+ "dev-review-map",
158
+ "review",
159
+ "review-map",
160
+ "review-stop",
161
+ "progressive-review",
162
+ "pr-review"
163
+ ];
164
+ function legacySkillRoots(homeDir) {
165
+ return [
166
+ path.join(homeDir, ".claude", "skills"),
167
+ path.join(homeDir, ".cursor", "skills"),
168
+ path.join(homeDir, ".config", "opencode", "skills"),
169
+ path.join(homeDir, ".agents", "skills")
170
+ ];
171
+ }
172
+ /**
173
+ * True only for a real skill directory (never a symlink to someone else's)
174
+ * holding a regular SKILL.md whose frontmatter Review Desktop wrote.
175
+ */
176
+ async function isReviewStamped(skillDir) {
177
+ const file = path.join(skillDir, "SKILL.md");
178
+ try {
179
+ if (!(await lstat(skillDir)).isDirectory()) return false;
180
+ if (!(await lstat(file)).isFile()) return false;
181
+ } catch {
182
+ return false;
183
+ }
184
+ const source = await readTextOrUndefined(file);
185
+ if (source === void 0) return false;
186
+ const metadata = (/^---\r?\n([\s\S]*?)\r?\n---/.exec(source)?.[1])?.match(/^metadata:\r?\n((?:[ \t]+[^\n]*(?:\n|$))*)/m)?.[1];
187
+ if (!metadata || !(/^ review-managed-by: "Review Desktop"\r?$/m.test(metadata) && /^ review-generated: "[^"\r\n]+"\r?$/m.test(metadata) || /^ whiteboard-managed-by: "Whiteboard(?: Desktop)?"\r?$/m.test(metadata) && /^ whiteboard-generated: "[^"\r\n]+"\r?$/m.test(metadata))) return false;
188
+ const version = metadata.match(/^ (?:review|whiteboard)-version: "([^"\r\n]+)"\r?$/m)?.[1];
189
+ return version !== void 0 && (version === "development" || valid(version) !== null);
190
+ }
191
+ const OPENCODE_PLUGIN_MARKER = "// Managed by Review Desktop (@dev.fast/review).";
192
+ /** The OpenCode plugin file Review Desktop wrote before it connected over MCP. */
193
+ async function isLegacyOpenCodePlugin(file) {
194
+ try {
195
+ if (!(await lstat(file)).isFile()) return false;
196
+ } catch {
197
+ return false;
198
+ }
199
+ return (await readTextOrUndefined(file))?.split(/\r?\n/, 1)[0] === OPENCODE_PLUGIN_MARKER;
200
+ }
201
+ async function readTextOrUndefined(file) {
202
+ try {
203
+ return await readFile(file, "utf8");
204
+ } catch {
205
+ return;
206
+ }
207
+ }
208
+ async function scanLegacySkills(homeDir) {
209
+ const found = [];
210
+ for (const name of ["review.ts", "whiteboard.ts"]) {
211
+ const plugin = path.join(homeDir, ".config", "opencode", "plugins", name);
212
+ if (await isLegacyOpenCodePlugin(plugin)) found.push(plugin);
213
+ }
214
+ for (const root of legacySkillRoots(homeDir)) for (const name of LEGACY_SKILL_NAMES) {
215
+ const skillDir = path.join(root, name);
216
+ if (await isReviewStamped(skillDir)) found.push(skillDir);
217
+ }
218
+ return found.sort();
219
+ }
220
+ async function removeLegacySkills(homeDir) {
221
+ const removed = [];
222
+ for (const skillDir of await scanLegacySkills(homeDir)) {
223
+ await rm(skillDir, {
224
+ recursive: true,
225
+ force: true
226
+ });
227
+ removed.push(skillDir);
228
+ }
229
+ return { removed };
230
+ }
231
+ //#endregion
232
+ //#region src/windows-cli.ts
233
+ const execFileAsync = promisify(execFile);
234
+ /** cmd expands percent signs even inside quotes; delayed expansion stays off. */
235
+ function batchPath(value) {
236
+ if (/["\r\n\0]/.test(value)) throw new Error("Invalid Windows command path");
237
+ return value.replaceAll("%", "%%");
238
+ }
239
+ function windowsCliShim(cliPath, runtimePath, devHome) {
240
+ const lines = [
241
+ "@echo off",
242
+ "rem Managed by Whiteboard Desktop. Do not edit.",
243
+ "setlocal DisableDelayedExpansion",
244
+ "set \"ELECTRON_RUN_AS_NODE=1\""
245
+ ];
246
+ if (devHome) lines.push(`if not defined DEV_REVIEW_HOME set "DEV_REVIEW_HOME=${batchPath(devHome)}"`);
247
+ lines.push(`"${batchPath(runtimePath)}" "${batchPath(cliPath)}" %*`, "exit /b %errorlevel%", "");
248
+ return lines.join("\r\n");
249
+ }
250
+ /** Preserve the unexpanded user PATH, including entries such as %USERPROFILE%. */
251
+ async function updateWindowsUserPath(directory, remove = false) {
252
+ await execFileAsync("powershell.exe", [
253
+ "-NoProfile",
254
+ "-NonInteractive",
255
+ "-ExecutionPolicy",
256
+ "Bypass",
257
+ "-EncodedCommand",
258
+ Buffer.from(`
259
+ $ErrorActionPreference = 'Stop'
260
+ $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
261
+ $state = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\\dev.fast\\Whiteboard\\CLI')
262
+ try {
263
+ $old = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
264
+ $dir = $env:WHITEBOARD_COMMAND_DIRECTORY
265
+ $entries = @($old.Split(';'))
266
+ $matches = @($entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\') -ieq $dir.TrimEnd('\\') })
267
+ if ($env:WHITEBOARD_REMOVE_COMMAND_PATH -eq '1') {
268
+ if ([string]$state.GetValue('Path', '') -ine $dir) { return }
269
+ $entries = @($entries | Where-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\') -ine $dir.TrimEnd('\\') })
270
+ $key.SetValue('Path', ($entries -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString)
271
+ $state.DeleteValue('Path', $false)
272
+ } else {
273
+ if ($matches.Count -gt 0) { return }
274
+ $state.SetValue('Path', $dir)
275
+ $value = if ($old) { $dir + ';' + $old } else { $dir }
276
+ $key.SetValue('Path', $value, [Microsoft.Win32.RegistryValueKind]::ExpandString)
277
+ }
278
+ } finally { $state.Dispose(); $key.Dispose() }
279
+ Add-Type -Namespace Whiteboard -Name EnvironmentChange -MemberDefinition '[System.Runtime.InteropServices.DllImport("user32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr h, uint m, System.UIntPtr w, string l, uint f, uint t, out System.UIntPtr r);'
280
+ $result = [UIntPtr]::Zero
281
+ [void][Whiteboard.EnvironmentChange]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result)
282
+ `, "utf16le").toString("base64")
283
+ ], {
284
+ windowsHide: true,
285
+ env: {
286
+ ...process.env,
287
+ WHITEBOARD_COMMAND_DIRECTORY: directory,
288
+ WHITEBOARD_REMOVE_COMMAND_PATH: remove ? "1" : "0"
289
+ }
290
+ });
291
+ }
292
+ /**
293
+ * A running process keeps the PATH it started with, so the saved user PATH is
294
+ * what tells whether new terminals will find the command.
295
+ */
296
+ async function windowsUserPathContains(directory, key = "HKCU\\Environment") {
297
+ let stdout;
298
+ try {
299
+ ({stdout} = await execFileAsync("reg.exe", [
300
+ "query",
301
+ key,
302
+ "/v",
303
+ "Path"
304
+ ], { windowsHide: true }));
305
+ } catch {
306
+ return false;
307
+ }
308
+ const saved = stdout.match(/^\s*Path\s+REG_\w+\s*(.*)$/im)?.[1] ?? "";
309
+ const target = path.win32.resolve(directory).toLowerCase();
310
+ return saved.split(";").some((entry) => {
311
+ const expanded = entry.trim().replace(/%([^%]+)%/g, (whole, name) => process.env[name] ?? whole);
312
+ return expanded.length > 0 && path.win32.resolve(expanded).toLowerCase() === target;
313
+ });
314
+ }
315
+ //#endregion
316
+ //#region src/cli-install.ts
317
+ const installErrors = /* @__PURE__ */ new Map();
318
+ const SHIM_MARKER = "Managed by Whiteboard";
319
+ function hasManagedShimMarker(source) {
320
+ return source.includes(SHIM_MARKER) || source.includes("Managed by Review Desktop") || source.includes("Managed by Whiteboard Desktop");
321
+ }
322
+ const PROFILE_MARKER = "# Managed by Review Desktop: review command PATH. Do not edit.";
323
+ const PROFILE_BLOCK = `\n${PROFILE_MARKER}\nexport PATH="\$HOME/.local/bin:\$PATH"\n`;
324
+ const SHELL_PROFILE_NAMES = [".zprofile", ".bash_profile"];
325
+ function cliInstallStampPath(env = process.env) {
326
+ return path.join(reviewDesktopStateDir(env), "cli-install.json");
327
+ }
328
+ /**
329
+ * Present once this build's setup is current. It sits beside the stamp, not in
330
+ * it, because older builds parse the stamp strictly and would drop a stamp
331
+ * carrying a new key.
332
+ */
333
+ function cliInstallUpdateMarkerPath(env = process.env) {
334
+ return path.join(reviewDesktopStateDir(env), "cli-install-updated");
335
+ }
336
+ function pathShimPath(homeDir = os.homedir()) {
337
+ return path.join(homeDir, ".local", "bin", process.platform === "win32" ? "whiteboard.cmd" : "whiteboard");
338
+ }
339
+ async function resolveCliInstallStatus(input) {
340
+ const homeDir = input.homeDir ?? os.homedir();
341
+ const env = input.env ?? process.env;
342
+ const shimPath = pathShimPath(homeDir);
343
+ const cliPath = path.join(input.packageRoot, "dist", "cli.js");
344
+ const [fingerprint, stamp, updated, trace, legacySkills, cliBuilt, hasShim] = await Promise.all([
345
+ installFingerprint(input.packageRoot),
346
+ readCliInstallStamp(cliInstallStampPath(env)),
347
+ isFile(cliInstallUpdateMarkerPath(env)),
348
+ traceMachineStatus({
349
+ homeDir,
350
+ env
351
+ }),
352
+ scanLegacySkills(homeDir),
353
+ isFile(cliPath),
354
+ isOwnedShim(shimPath)
355
+ ]);
356
+ const granted = stamp?.consent === "granted";
357
+ const installerCommand = hasShim ? void 0 : await windowsInstallerCommand(input.packageRoot, env);
358
+ const hasCommand = hasShim || installerCommand !== void 0;
359
+ const status = {
360
+ fingerprint,
361
+ stamp,
362
+ stale: granted && stamp.fingerprint !== fingerprint,
363
+ updateNeeded: legacySkills.length > 0 || granted && !updated,
364
+ shim: installerCommand ?? {
365
+ path: shimPath,
366
+ installed: hasShim,
367
+ profileConfigured: process.platform === "win32" ? hasShim && stamp?.userPath !== void 0 && sameWindowsPath(stamp.userPath, path.dirname(shimPath)) || await windowsUserPathContains(path.dirname(shimPath)) : await isShellProfileConfigured(homeDir),
368
+ onPath: pathContainsDirectory(env.PATH, path.dirname(shimPath))
369
+ },
370
+ trace,
371
+ cli: cliBuilt ? {
372
+ path: cliPath,
373
+ version: readReviewPackageVersion(pathToFileURL(cliPath).href)
374
+ } : null,
375
+ connect: {
376
+ ...reviewMcpLaunch(hasCommand),
377
+ prompts: connectSetupPrompts(),
378
+ plugins: connectPlugins(hasCommand)
379
+ },
380
+ legacySkills: legacySkills.map((skillPath) => ({ path: homeRelative(homeDir, skillPath) }))
381
+ };
382
+ const error = installErrors.get(homeDir);
383
+ if (error) status.error = error;
384
+ return status;
385
+ }
386
+ /**
387
+ * The Windows installer's "Add to PATH" task puts <install dir>\bin, which
388
+ * holds a whiteboard.cmd, on the user or machine PATH.
389
+ */
390
+ async function windowsInstallerCommand(packageRoot, env) {
391
+ if (process.platform !== "win32") return void 0;
392
+ const bin = path.resolve(packageRoot, "..", "..", "..", "bin");
393
+ const command = path.join(bin, "whiteboard.cmd");
394
+ if (!await isOwnedShim(command)) return void 0;
395
+ const profileConfigured = await windowsUserPathContains(bin) || await windowsUserPathContains(bin, "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment");
396
+ const onPath = pathContainsDirectory(env.PATH, bin);
397
+ if (!profileConfigured && !onPath) return void 0;
398
+ return {
399
+ path: command,
400
+ installed: true,
401
+ profileConfigured,
402
+ onPath,
403
+ installer: true
404
+ };
405
+ }
406
+ async function applyCliInstall(input) {
407
+ if (!input.autoUpdate && input.shim !== true && input.trace === void 0) return {
408
+ code: 0,
409
+ output: ""
410
+ };
411
+ const homeDir = input.homeDir ?? os.homedir();
412
+ try {
413
+ const result = await withDesktopInstallLock(input.env, () => input.autoUpdate ? resyncCliInstallUnlocked(input) : applyCliInstallUnlocked(input));
414
+ if (result.code === 0) installErrors.delete(homeDir);
415
+ else installErrors.set(homeDir, result.output);
416
+ return result;
417
+ } catch (error) {
418
+ const output = error instanceof Error ? error.message : String(error);
419
+ installErrors.set(homeDir, output);
420
+ return {
421
+ code: 1,
422
+ output
423
+ };
424
+ }
425
+ }
426
+ async function withDesktopInstallLock(env = process.env, operation) {
427
+ const outcome = await withFileLock(`${cliInstallStampPath(env)}.lock`, {
428
+ retryMs: 50,
429
+ timeoutMs: 3e4,
430
+ staleMs: 3e5,
431
+ unownedGraceMs: 5e3
432
+ }, operation);
433
+ if (!outcome.acquired) throw new Error("Another Whiteboard setup operation is running. Retry shortly.");
434
+ return outcome.result;
435
+ }
436
+ /**
437
+ * Silent app-update resync: rewrites the shim for the new build. Consent is
438
+ * re-read under the lock, so a stale UI snapshot never reinstalls a command
439
+ * the user has since removed or declined.
440
+ */
441
+ async function resyncCliInstallUnlocked(input) {
442
+ const env = input.env ?? process.env;
443
+ const stamp = await readCliInstallStamp(cliInstallStampPath(env));
444
+ const fingerprint = await installFingerprint(input.packageRoot);
445
+ if (stamp?.consent !== "granted" || stamp.fingerprint === fingerprint) return {
446
+ code: 0,
447
+ output: ""
448
+ };
449
+ const chunks = [];
450
+ let shimPath;
451
+ let userPath = stamp.userPath;
452
+ if (stamp.shimPath && !stamp.commandDisabled) {
453
+ const installed = await installShim(input, chunks);
454
+ if (installed.code !== 0) return installed;
455
+ shimPath = installed.shimPath;
456
+ userPath = installed.userPath ?? userPath;
457
+ }
458
+ const next = {
459
+ ...stamp,
460
+ fingerprint,
461
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
462
+ };
463
+ if (userPath) next.userPath = userPath;
464
+ await writePrivateJsonAtomic(cliInstallStampPath(env), next);
465
+ return withShimPath({
466
+ code: 0,
467
+ output: chunks.join("")
468
+ }, shimPath);
469
+ }
470
+ async function applyCliInstallUnlocked(input) {
471
+ const homeDir = input.homeDir ?? os.homedir();
472
+ const env = input.env ?? process.env;
473
+ const previous = await readCliInstallStamp(cliInstallStampPath(env));
474
+ const granted = previous?.consent === "granted";
475
+ const chunks = [];
476
+ let traceEnabled = false;
477
+ if (input.trace !== void 0) try {
478
+ const status = await configureTraceMachine({
479
+ homeDir,
480
+ env,
481
+ credentials: input.trace === true ? void 0 : input.trace
482
+ });
483
+ traceEnabled = status.enabled;
484
+ chunks.push(`[ok] trace capture -> ${status.envPath}\n`);
485
+ if (status.error) chunks.push(`Trace storage check failed: ${status.error}\n`);
486
+ } catch (cause) {
487
+ chunks.push(`${cause instanceof Error ? cause.message : String(cause)}\n`);
488
+ return {
489
+ code: 1,
490
+ output: chunks.join("")
491
+ };
492
+ }
493
+ let shimPath;
494
+ let userPath;
495
+ if (input.shim === true) {
496
+ const installed = await installShim(input, chunks);
497
+ if (installed.code !== 0) return installed;
498
+ shimPath = installed.shimPath;
499
+ userPath = installed.userPath;
500
+ }
501
+ if (traceEnabled) {
502
+ const executable = await isOwnedShim(pathShimPath(homeDir)) ? pathShimPath(homeDir) : void 0;
503
+ const hooks = await installHarnessHooks({
504
+ homeDir,
505
+ env,
506
+ executable
507
+ });
508
+ for (const hook of hooks.installed) chunks.push(`[ok] ${hook.agent} trace hook -> ${hook.path}\n`);
509
+ }
510
+ const stamp = {
511
+ consent: "granted",
512
+ fingerprint: await installFingerprint(input.packageRoot),
513
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
514
+ };
515
+ const stampShimPath = shimPath ?? (granted ? previous.shimPath : void 0);
516
+ if (stampShimPath) stamp.shimPath = stampShimPath;
517
+ if (granted && previous.commandDisabled && input.shim !== true) stamp.commandDisabled = true;
518
+ if (input.trace !== void 0 || granted && previous.traceManaged) stamp.traceManaged = true;
519
+ const stampUserPath = userPath ?? (granted && !shimPath ? previous.userPath : void 0);
520
+ if (stampUserPath) stamp.userPath = stampUserPath;
521
+ await writeCurrentStamp(env, stamp);
522
+ return withShimPath({
523
+ code: 0,
524
+ output: chunks.join("")
525
+ }, shimPath);
526
+ }
527
+ async function installShim(input, chunks) {
528
+ if (!input.cliPath) {
529
+ chunks.push("This server has no built CLI to install the command from.\n");
530
+ return {
531
+ code: 1,
532
+ output: chunks.join("")
533
+ };
534
+ }
535
+ const installed = await installReviewCommand({
536
+ cliPath: input.cliPath,
537
+ cliRuntimePath: input.cliRuntimePath,
538
+ homeDir: input.homeDir,
539
+ env: input.env
540
+ });
541
+ chunks.push(installed.output);
542
+ const result = {
543
+ code: 0,
544
+ output: "",
545
+ shimPath: installed.shimPath
546
+ };
547
+ if (installed.userPath) result.userPath = installed.userPath;
548
+ return result;
549
+ }
550
+ function withShimPath(result, shimPath) {
551
+ if (shimPath) result.shimPath = shimPath;
552
+ return result;
553
+ }
554
+ /** The published plugin per harness; Cursor's link needs the shim it launches. */
555
+ function connectPlugins(hasShim) {
556
+ return {
557
+ claude: process.platform === "win32" ? {
558
+ label: "Add the Claude Code MCP server",
559
+ command: CLAUDE_WINDOWS_MCP_ADD
560
+ } : {
561
+ label: "Install the Claude Code plugin",
562
+ command: "/plugin marketplace add devdotfast/whiteboard\n/plugin install whiteboard@devfast"
563
+ },
564
+ codex: {
565
+ label: "Install the Codex plugin",
566
+ command: "codex plugin marketplace add devdotfast/whiteboard\ncodex plugin add whiteboard@devfast"
567
+ },
568
+ cursor: hasShim ? {
569
+ label: "Install in Cursor",
570
+ url: cursorInstallDeeplink(reviewMcpLaunch(true))
571
+ } : { label: "Install in Cursor" },
572
+ opencode: {
573
+ label: "Add the OpenCode MCP server (OpenCode 2+)",
574
+ command: `opencode mcp add --global whiteboard -- ${launchCommand(reviewMcpLaunch(hasShim))}`
575
+ },
576
+ pi: {
577
+ label: "Add the Pi MCP server (Pi 0.99+)",
578
+ command: `pi mcp add whiteboard -- ${launchCommand(reviewMcpLaunch(hasShim))}`
579
+ },
580
+ omp: {
581
+ label: "Add the oh-my-pi MCP server",
582
+ command: `Add ${JSON.stringify({ whiteboard: reviewMcpLaunch(hasShim) })}\nto "mcpServers" in ~/.omp/agent/mcp.json, then run /mcp reload.`
583
+ },
584
+ copilot: process.platform === "win32" ? {
585
+ label: "Add the Copilot CLI MCP server",
586
+ command: COPILOT_WINDOWS_MCP_ADD
587
+ } : {
588
+ label: "Install the Copilot CLI plugin",
589
+ command: "copilot plugin marketplace add devdotfast/whiteboard\ncopilot plugin install whiteboard@devfast"
590
+ }
591
+ };
592
+ }
593
+ /** Removes the skills Whiteboard Desktop installed before it connected over MCP. */
594
+ async function removeLegacyReviewSkills(input = {}) {
595
+ return withDesktopInstallLock(input.env, () => removeLegacySkills(input.homeDir ?? os.homedir()));
596
+ }
597
+ /** Records setup completion; remaining legacy skills still require the update screen. */
598
+ async function finishCliInstallUpdate(env = process.env) {
599
+ await withDesktopInstallLock(env, async () => {
600
+ if ((await readCliInstallStamp(cliInstallStampPath(env)))?.consent !== "granted") return;
601
+ await writeUpdateMarker(env);
602
+ });
603
+ }
604
+ async function declineCliInstall(env = process.env) {
605
+ await withDesktopInstallLock(env, () => writeCurrentStamp(env, {
606
+ consent: "declined",
607
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
608
+ }));
609
+ }
610
+ async function skipCliInstall(env = process.env) {
611
+ await withDesktopInstallLock(env, async () => {
612
+ if (await readCliInstallStamp(cliInstallStampPath(env))) return;
613
+ await writeCurrentStamp(env, {
614
+ consent: "skipped",
615
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
616
+ });
617
+ });
618
+ }
619
+ /** Removes the stamp entirely, so the next app launch prompts again. */
620
+ async function resetCliInstall(env = process.env) {
621
+ await withDesktopInstallLock(env, async () => {
622
+ await rm(cliInstallStampPath(env), { force: true });
623
+ await rm(cliInstallUpdateMarkerPath(env), { force: true });
624
+ });
625
+ }
626
+ /** Writes a stamp this build decided, which also makes the setup current. */
627
+ async function writeCurrentStamp(env, stamp) {
628
+ await writePrivateJsonAtomic(cliInstallStampPath(env), stamp);
629
+ await writeUpdateMarker(env);
630
+ }
631
+ async function writeUpdateMarker(env) {
632
+ await writeFileAtomicAsync(cliInstallUpdateMarkerPath(env), "", {
633
+ encoding: "utf8",
634
+ mode: 384
635
+ });
636
+ }
637
+ async function removeCliInstall(input) {
638
+ return withDesktopInstallLock(input.env, () => removeCliInstallUnlocked(input));
639
+ }
640
+ async function removeCliInstallUnlocked(input) {
641
+ const homeDir = input.homeDir ?? os.homedir();
642
+ const env = input.env ?? process.env;
643
+ const chunks = [];
644
+ const expectedTraceCommand = await isOwnedShim(pathShimPath(homeDir)) ? pathShimPath(homeDir) : "";
645
+ const previous = await readCliInstallStamp(cliInstallStampPath(env));
646
+ if (input.shim) {
647
+ const shimPath = pathShimPath(homeDir);
648
+ const contents = await readTextIfExists(shimPath);
649
+ if (contents.includes(SHIM_MARKER)) {
650
+ await rm(shimPath, { force: true });
651
+ if (process.platform === "win32") {
652
+ const bashShim = shimPath.replace(/\.cmd$/i, "");
653
+ if (await isOwnedShim(bashShim)) await rm(bashShim, { force: true });
654
+ }
655
+ chunks.push(`[ok] removed whiteboard command ${shimPath}\n`);
656
+ } else if (contents) chunks.push(`${shimPath} was not installed by Whiteboard Desktop; left in place.\n`);
657
+ for (const profilePath of await removeShellProfilePath(homeDir)) chunks.push(`[ok] removed Whiteboard PATH entry from ${profilePath}\n`);
658
+ }
659
+ if (input.trace) {
660
+ const { kept } = await disableAllTraceRepositories(traceScope({
661
+ homeDir,
662
+ env
663
+ }), expectedTraceCommand);
664
+ for (const agent of AGENT_TRACE_HOOK_AGENTS) await removeAgentTraceHook(agent, homeDir, env, expectedTraceCommand);
665
+ const remaining = await describeTraceHookOwners(homeDir, env);
666
+ if (kept.length || Object.values(remaining).some(Boolean)) chunks.push("[skip] kept trace capture for another Whiteboard installation\n");
667
+ else {
668
+ await disableTraceMachine({
669
+ homeDir,
670
+ env
671
+ });
672
+ chunks.push("[ok] disabled Whiteboard trace capture\n");
673
+ }
674
+ }
675
+ if (previous?.consent === "granted") {
676
+ const stamp = {
677
+ consent: "granted",
678
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
679
+ };
680
+ if (previous.fingerprint) stamp.fingerprint = previous.fingerprint;
681
+ if (!input.shim && previous.shimPath) stamp.shimPath = previous.shimPath;
682
+ if (input.shim || previous.commandDisabled) stamp.commandDisabled = true;
683
+ if (!input.trace && previous.traceManaged) stamp.traceManaged = true;
684
+ if (!input.shim && previous.userPath) stamp.userPath = previous.userPath;
685
+ await writeCurrentStamp(env, stamp);
686
+ }
687
+ return { output: chunks.join("") };
688
+ }
689
+ /**
690
+ * Fingerprint of the package manifest and built CLI. Content-based so it
691
+ * works identically in a dev checkout and a packaged review-runtime, with no
692
+ * build-time stamping.
693
+ */
694
+ async function installFingerprint(packageRoot) {
695
+ const hash = createHash("sha256");
696
+ hash.update(await readTextIfExists(path.join(packageRoot, "package.json")));
697
+ hash.update("dist/cli.js\0");
698
+ hash.update(await readTextIfExists(path.join(packageRoot, "dist", "cli.js")));
699
+ return hash.digest("hex").slice(0, 20);
700
+ }
701
+ async function readCliInstallStamp(stampPath) {
702
+ let value;
703
+ try {
704
+ value = JSON.parse(await readFile(stampPath, "utf8"));
705
+ } catch {
706
+ return null;
707
+ }
708
+ const parsed = ReviewCliInstallStampSchema.safeParse(value);
709
+ return parsed.success ? parsed.data : null;
710
+ }
711
+ async function isOwnedShim(shimPath) {
712
+ return hasManagedShimMarker(await readTextIfExists(shimPath));
713
+ }
714
+ /**
715
+ * The shim is POSIX sh, so running `review` needs no Node.js at all to start.
716
+ * It prefers the CLI and runtime the running Whiteboard Desktop advertises in its
717
+ * discovery file, falls back to the paths baked in by the app that wrote it,
718
+ * and runs the CLI under the app's Electron binary as Node
719
+ * (ELECTRON_RUN_AS_NODE) — the exact runtime the server uses. System Node is
720
+ * the last resort and gets a clear version check instead of a cryptic crash.
721
+ */
722
+ async function writePathShim(shimPath, cliPath, runtimePath, devHome) {
723
+ if (process.platform === "win32") {
724
+ await writeFileAtomicAsync(shimPath, windowsCliShim(cliPath, runtimePath ?? process.execPath, devHome), { replaceSymlink: true });
725
+ shimPath = shimPath.replace(/\.cmd$/i, "");
726
+ }
727
+ const source = `#!/bin/sh
728
+ # Managed by Whiteboard Desktop ("Whiteboard: Install CLI in PATH"). Do not edit.
729
+ FALLBACK_CLI=${shSingleQuote(cliPath)}
730
+ FALLBACK_RUNTIME=${shSingleQuote(runtimePath ?? "")}
731
+ DEFAULT_HOME=${shSingleQuote(devHome)}
732
+ export DEV_REVIEW_HOME="\${DEV_REVIEW_HOME:-$DEFAULT_HOME}"
733
+ DESKTOP="$DEV_REVIEW_HOME/review-desktop"
734
+
735
+ # A record counts only while its server process is alive.
736
+ live() {
737
+ [ -f "$1" ] || return 1
738
+ pid=$(sed -n 's/.*"serverPid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' "$1" | head -n 1)
739
+ [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
740
+ }
741
+
742
+ # Same order as the CLI: DEV_REVIEW_INSTANCE, the machine default, the only
743
+ # live Desktop, then stable. A stable Desktop that predates instances wrote
744
+ # only server.json; it never stands in for any other key.
745
+ LEGACY="$DESKTOP/server.json"
746
+ STABLE="$DESKTOP/instances/stable.json"
747
+ [ -f "$STABLE" ] || STABLE="$LEGACY"
748
+ key="\${DEV_REVIEW_INSTANCE:-}"
749
+ if [ -z "$key" ] && [ -f "$DESKTOP/default-instance" ]; then
750
+ key=$(head -n 1 "$DESKTOP/default-instance" | tr -d '[:space:]')
751
+ fi
752
+ DISCOVERY=""
753
+ case "$key" in
754
+ # Keys name files; the CLI rejects anything else.
755
+ *[!A-Za-z0-9_.-]*) ;;
756
+ stable) DISCOVERY="$STABLE" ;;
757
+ ?*) DISCOVERY="$DESKTOP/instances/$key.json" ;;
758
+ *)
759
+ for record in "$DESKTOP"/instances/*.json "$LEGACY"; do
760
+ if [ "$record" = "$LEGACY" ] && [ "$STABLE" != "$LEGACY" ]; then continue; fi
761
+ if live "$record"; then
762
+ if [ -n "$DISCOVERY" ]; then DISCOVERY=""; break; fi
763
+ DISCOVERY="$record"
764
+ fi
765
+ done
766
+ [ -n "$DISCOVERY" ] || DISCOVERY="$STABLE"
767
+ ;;
768
+ esac
769
+
770
+ cli=""
771
+ runtime=""
772
+ delegated=""
773
+ manage_instances=""
774
+ for arg in "$@"; do
775
+ if [ "$arg" = "instances" ]; then manage_instances=1; break; fi
776
+ done
777
+ if [ -z "$manage_instances" ] && [ -z "\${DEV_FAST_REVIEW_CLI_NO_DELEGATE:-}" ] && live "$DISCOVERY"; then
778
+ cli=$(sed -n 's/.*"cliPath"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$DISCOVERY" | head -n 1)
779
+ delegated="1"
780
+ runtime=$(sed -n 's/.*"cliRuntimePath"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$DISCOVERY" | head -n 1)
781
+ fi
782
+ if [ -z "$cli" ] || [ ! -f "$cli" ] || { [ -n "$runtime" ] && [ ! -x "$runtime" ]; }; then
783
+ delegated=""
784
+ cli="$FALLBACK_CLI"
785
+ runtime="$FALLBACK_RUNTIME"
786
+ fi
787
+
788
+ if [ ! -f "$cli" ]; then
789
+ echo "Whiteboard CLI not found at $cli. Start Whiteboard Desktop, or run npx @dev.fast/whiteboard instead." >&2
790
+ exit 1
791
+ fi
792
+
793
+ # Prevent bootstrap from overriding this selection.
794
+ export DEV_FAST_REVIEW_CLI_NO_DELEGATE=1
795
+ export DEV_FAST_REVIEW_CLI_DELEGATED="$delegated"
796
+
797
+ # The app's Electron binary runs as plain Node.js and matches the server's
798
+ # runtime exactly; no system Node is required on this path.
799
+ if [ -n "$runtime" ] && [ -x "$runtime" ]; then
800
+ export ELECTRON_RUN_AS_NODE=1
801
+ exec "$runtime" "$cli" "$@"
802
+ fi
803
+
804
+ if ! command -v node >/dev/null 2>&1; then
805
+ echo "Whiteboard needs Node.js 24 or newer and none was found. Install Node 24, or install Whiteboard Desktop." >&2
806
+ exit 1
807
+ fi
808
+ major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
809
+ case "$major" in *[!0-9]*) major=0;; esac
810
+ if [ "$major" -lt 24 ]; then
811
+ echo "Whiteboard needs Node.js 24 or newer; found $(node -v 2>/dev/null). Update Node, or install Whiteboard Desktop." >&2
812
+ exit 1
813
+ fi
814
+ exec node "$cli" "$@"
815
+ `;
816
+ await writeFileAtomicAsync(shimPath, source, {
817
+ encoding: "utf8",
818
+ mode: 493,
819
+ replaceSymlink: true
820
+ });
821
+ await chmod(shimPath, 493);
822
+ }
823
+ async function installReviewCommand(input) {
824
+ const homeDir = input.homeDir ?? os.homedir();
825
+ const env = input.env ?? process.env;
826
+ const shimPath = pathShimPath(homeDir);
827
+ if (await isFile(shimPath) && !await isOwnedShim(shimPath)) return {
828
+ shimPath,
829
+ output: `[skip] kept the existing whiteboard command at ${shimPath}\n`
830
+ };
831
+ const shadowingCommand = await resolvePathCommand("whiteboard", shimPath, env);
832
+ await writePathShim(shimPath, input.cliPath, input.cliRuntimePath, devReviewHome(env, homeDir));
833
+ if (await traceMachineEnabled({
834
+ homeDir,
835
+ env
836
+ })) {
837
+ const scope = traceScope({
838
+ homeDir,
839
+ env
840
+ });
841
+ for (const cwd of await listTraceRepositoryRoots(homeDir)) {
842
+ if (!await isDirectory(cwd)) continue;
843
+ if ((await traceRepositoryStatus(cwd)).enabled) await enableTraceRepository({
844
+ cwd,
845
+ scope,
846
+ reviewCommand: shimPath,
847
+ replaceCommand: true
848
+ });
849
+ }
850
+ }
851
+ const legacyShim = path.join(path.dirname(shimPath), "review");
852
+ if (await isOwnedShim(legacyShim)) await rm(legacyShim, { force: true });
853
+ const output = `[ok] whiteboard command -> ${shimPath}\n${await ensureShellProfilePath({
854
+ homeDir,
855
+ env
856
+ })}${shadowingCommand ? `Warning: ${shadowingCommand} currently shadows ${shimPath}. Remove that PATH entry or put ${path.dirname(shimPath)} before it.\n` : ""}`;
857
+ return process.platform === "win32" ? {
858
+ shimPath,
859
+ output,
860
+ userPath: path.dirname(shimPath)
861
+ } : {
862
+ shimPath,
863
+ output
864
+ };
865
+ }
866
+ async function ensureShellProfilePath(input) {
867
+ const shimDirectory = path.dirname(pathShimPath(input.homeDir));
868
+ if (pathContainsDirectory(input.env.PATH, shimDirectory)) return "";
869
+ if (process.platform === "win32") {
870
+ await updateWindowsUserPath(shimDirectory);
871
+ return `[ok] added ${shimDirectory} to your user PATH; open a new terminal\n`;
872
+ }
873
+ const shell = path.basename(input.env.SHELL?.trim() ?? "");
874
+ let profileName;
875
+ if (shell === "bash") profileName = ".bash_profile";
876
+ else if (shell === "zsh" || shell !== "fish" && process.platform === "darwin") profileName = ".zprofile";
877
+ if (!profileName) return "Whiteboard did not update PATH for this shell. Add ~/.local/bin to PATH. Fish users can run: fish_add_path ~/.local/bin\n";
878
+ const profilePath = path.join(input.homeDir, profileName);
879
+ const source = await readTextIfExists(profilePath);
880
+ if (source.includes(PROFILE_MARKER) || source.includes(".local/bin")) return "";
881
+ await writeTextAtomic(profilePath, `${source}${PROFILE_BLOCK}`);
882
+ return `[ok] added ${shimDirectory} to PATH in ${profilePath}\n`;
883
+ }
884
+ async function removeShellProfilePath(homeDir) {
885
+ if (process.platform === "win32") {
886
+ await updateWindowsUserPath(path.dirname(pathShimPath(homeDir)), true);
887
+ return ["user PATH"];
888
+ }
889
+ const removed = [];
890
+ for (const profileName of SHELL_PROFILE_NAMES) {
891
+ const profilePath = path.join(homeDir, profileName);
892
+ const source = await readTextIfExists(profilePath);
893
+ if (!source.includes(PROFILE_BLOCK)) continue;
894
+ await writeTextAtomic(profilePath, source.replaceAll(PROFILE_BLOCK, ""));
895
+ removed.push(profilePath);
896
+ }
897
+ return removed;
898
+ }
899
+ function shSingleQuote(value) {
900
+ return `'${value.replaceAll("'", "'\\''")}'`;
901
+ }
902
+ async function resolvePathCommand(command, shimPath, env) {
903
+ const entries = (env.PATH ?? "").split(path.delimiter);
904
+ const shimDirectory = path.resolve(path.dirname(shimPath));
905
+ const shimIndex = entries.findIndex((entry) => path.resolve(entry || ".") === shimDirectory);
906
+ for (let index = 0; index < entries.length; index += 1) {
907
+ const candidate = path.join(entries[index] || ".", command);
908
+ if (!await isExecutableFile(candidate)) continue;
909
+ if ((await readTextIfExists(candidate)).includes(SHIM_MARKER)) return;
910
+ return shimIndex === -1 || index < shimIndex ? path.resolve(candidate) : void 0;
911
+ }
912
+ }
913
+ function sameWindowsPath(left, right) {
914
+ return path.win32.resolve(left).toLowerCase() === path.win32.resolve(right).toLowerCase();
915
+ }
916
+ function pathContainsDirectory(pathValue, directory) {
917
+ return (pathValue ?? "").split(path.delimiter).some((entry) => entry.length > 0 && path.resolve(entry) === path.resolve(directory));
918
+ }
919
+ async function isShellProfileConfigured(homeDir) {
920
+ return (await Promise.all(SHELL_PROFILE_NAMES.map((profileName) => readTextIfExists(path.join(homeDir, profileName))))).some((source) => source.includes(PROFILE_MARKER));
921
+ }
922
+ async function writeTextAtomic(filePath, source) {
923
+ let mode = 420;
924
+ try {
925
+ mode = (await stat(filePath)).mode & 511;
926
+ } catch {}
927
+ await writeFileAtomicAsync(filePath, source, {
928
+ encoding: "utf8",
929
+ mode
930
+ });
931
+ }
932
+ async function readTextIfExists(filePath) {
933
+ try {
934
+ return await readFile(filePath, "utf8");
935
+ } catch {
936
+ return "";
937
+ }
938
+ }
939
+ async function isExecutableFile(target) {
940
+ if (!await isFile(target)) return false;
941
+ try {
942
+ await access(target, constants.X_OK);
943
+ return true;
944
+ } catch {
945
+ return false;
946
+ }
947
+ }
948
+ /** `~/…` for paths under the home directory; the card shows these, removal rescans. */
949
+ function homeRelative(homeDir, target) {
950
+ const relative = path.relative(homeDir, target);
951
+ return relative.startsWith("..") || path.isAbsolute(relative) ? target : `~/${relative.split(path.sep).join("/")}`;
952
+ }
953
+ //#endregion
954
+ //#region src/server/account-alias.ts
955
+ /**
956
+ * After a login, link this install to the signed-in account by a hash of its
957
+ * account id. Best effort and never throws: the login already succeeded.
958
+ */
959
+ async function aliasInstallationToAccount(telemetry, env = process.env, fetchImpl) {
960
+ try {
961
+ const auth = await readStoreAuth(env);
962
+ if (!auth) return;
963
+ await telemetry.captureAccountAlias(async () => {
964
+ return (await new StoreClient({
965
+ origin: auth.origin,
966
+ token: auth.token,
967
+ fetch: fetchImpl
968
+ }).session()).user.id;
969
+ });
970
+ } catch {}
971
+ }
972
+ //#endregion
973
+ export { isOwnedShim as a, removeLegacyReviewSkills as c, skipCliInstall as d, windowsInstallerCommand as f, finishCliInstallUpdate as i, resetCliInstall as l, connectPrompts as m, applyCliInstall as n, pathShimPath as o, scanLegacySkills as p, declineCliInstall as r, removeCliInstall as s, aliasInstallationToAccount as t, resolveCliInstallStatus as u };