@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,351 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, stat } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+ import { Readable, Writable } from "node:stream";
8
+
9
+ import {
10
+ type ClientApp,
11
+ type ClientConnection,
12
+ type McpServer,
13
+ type NewSessionRequest,
14
+ ndJsonStream,
15
+ } from "@agentclientprotocol/sdk";
16
+ import { piTakesMcp, piWithMcp } from "@review/ask/pi-mcp.js";
17
+ import { type AskAgentId, askAgentIds } from "@review/ask/thread-state.js";
18
+
19
+ /** How Whiteboard runs an agent that speaks ACP on stdio. */
20
+ type AskAgentLaunch =
21
+ /** An ACP adapter's executable entry, run with this server's Node, which
22
+ * drives the user's CLI with the user's login. */
23
+ | {
24
+ adapter: string;
25
+ /** How the adapter is told to use the user's CLI instead of a bundled one. */
26
+ executableEnv: string;
27
+ }
28
+ /** The user's CLI speaks ACP itself, given these arguments. */
29
+ | { args: string[] };
30
+
31
+ interface AskAgentSpec {
32
+ name: string;
33
+ /** The user's own CLI: the names it goes by on PATH, most specific first. */
34
+ commands: string[];
35
+ /** Where its installer puts it when that is not on PATH, under home. */
36
+ installDirs?: string[];
37
+ launch: AskAgentLaunch;
38
+ /** The session mode Whiteboard starts the agent in, when it has one that
39
+ * keeps the checkout as it is. Read-only is best effort: an agent without
40
+ * one still answers. */
41
+ readOnlyMode?: string;
42
+ /** Adapter-specific session settings, sent as the session's `_meta`. */
43
+ sessionMeta?: NewSessionRequest["_meta"];
44
+ /** Settings for the agent's process. */
45
+ env?: Record<string, string>;
46
+ /** Signs the user's CLI in again, run in a terminal. */
47
+ signIn: string;
48
+ /** How its model gets the MCP servers a session is given, where its
49
+ * adapter keeps them; without it, the adapter passes them on. */
50
+ mcp?: AskAgentMcp;
51
+ /** How it edits and runs commands without asking, in place of its
52
+ * read-only mode and settings, when the reviewer bypasses permissions.
53
+ * Whiteboard also allows whatever it still asks. */
54
+ bypass?: AskAgentBypass;
55
+ }
56
+
57
+ interface AskAgentMcp {
58
+ /** Whether this install can take them. */
59
+ supported(executable: string): Promise<boolean>;
60
+ /** What the adapter runs in place of the user's CLI to give them, with
61
+ * what it needs in `env`. */
62
+ launch(
63
+ executable: string,
64
+ servers: McpServer[],
65
+ env: NodeJS.ProcessEnv,
66
+ ): Promise<string>;
67
+ }
68
+
69
+ export interface AskAgentBypass {
70
+ mode?: string;
71
+ sessionMeta?: NewSessionRequest["_meta"];
72
+ env?: Record<string, string>;
73
+ }
74
+
75
+ export const askAgents: Record<AskAgentId, AskAgentSpec> = {
76
+ claude: {
77
+ name: "Claude Code",
78
+ commands: ["claude"],
79
+ launch: {
80
+ adapter: "@agentclientprotocol/claude-agent-acp/dist/index.js",
81
+ executableEnv: "CLAUDE_CODE_EXECUTABLE",
82
+ },
83
+ signIn: "claude auth login",
84
+ // Plan mode would end each answer asking to leave it, and it blocks the
85
+ // Whiteboard tools that edit the review. Claude instead runs in its
86
+ // default mode without its file tools, and without bypass, so commands
87
+ // still ask; the review is edited through Whiteboard's MCP server.
88
+ readOnlyMode: "default",
89
+ sessionMeta: {
90
+ claudeCode: {
91
+ options: {
92
+ disallowedTools: [
93
+ "Edit",
94
+ "MultiEdit",
95
+ "Write",
96
+ "NotebookEdit",
97
+ "EnterPlanMode",
98
+ "ExitPlanMode",
99
+ ],
100
+ allowDangerouslySkipPermissions: false,
101
+ },
102
+ },
103
+ },
104
+ // Its file tools back, and its own bypass, where its settings allow it.
105
+ bypass: {
106
+ mode: "bypassPermissions",
107
+ sessionMeta: {
108
+ claudeCode: {
109
+ options: { disallowedTools: ["EnterPlanMode", "ExitPlanMode"] },
110
+ },
111
+ },
112
+ },
113
+ },
114
+ codex: {
115
+ name: "Codex",
116
+ commands: ["codex"],
117
+ launch: {
118
+ adapter: "@agentclientprotocol/codex-acp/dist/index.js",
119
+ executableEnv: "CODEX_PATH",
120
+ },
121
+ signIn: "codex login",
122
+ readOnlyMode: "read-only",
123
+ bypass: { mode: "agent-full-access" },
124
+ },
125
+ cursor: {
126
+ name: "Cursor",
127
+ // Its installer adds `agent` too, but Homebrew's has only this name,
128
+ // and `agent` alone could be anything.
129
+ commands: ["cursor-agent"],
130
+ installDirs: [".local/bin"],
131
+ launch: { args: ["acp"] },
132
+ signIn: "cursor-agent login",
133
+ // Ask mode answers without editing or running commands.
134
+ readOnlyMode: "ask",
135
+ // Agent mode asks before it edits or runs a command.
136
+ bypass: { mode: "agent" },
137
+ },
138
+ opencode: {
139
+ name: "OpenCode",
140
+ commands: ["opencode"],
141
+ installDirs: [".opencode/bin"],
142
+ launch: { args: ["acp"] },
143
+ signIn: "opencode auth login",
144
+ // Every OpenCode agent may edit and run commands without asking; its
145
+ // plan agent only stops edits. These permissions, over the user's own
146
+ // config, refuse edits and ask before commands in any of them. Its
147
+ // build agent then answers as usual, where plan's would plan.
148
+ readOnlyMode: "build",
149
+ env: {
150
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
151
+ permission: { edit: "deny", bash: "ask", webfetch: "ask" },
152
+ }),
153
+ },
154
+ bypass: {
155
+ mode: "build",
156
+ env: {
157
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
158
+ permission: { edit: "allow", bash: "allow", webfetch: "allow" },
159
+ }),
160
+ },
161
+ },
162
+ },
163
+ pi: {
164
+ name: "Pi",
165
+ commands: ["pi"],
166
+ launch: {
167
+ adapter: "pi-acp/dist/index.js",
168
+ executableEnv: "PI_ACP_PI_COMMAND",
169
+ },
170
+ // Pi signs in from its own prompt, with /login.
171
+ signIn: "pi",
172
+ mcp: { supported: piTakesMcp, launch: piWithMcp },
173
+ // Pi never asks before it edits or runs a command, and has no mode that
174
+ // stops it.
175
+ },
176
+ };
177
+
178
+ export interface AskAgentStatus {
179
+ id: AskAgentId;
180
+ name: string;
181
+ available: boolean;
182
+ /** Whether it has a mode that keeps the checkout as it is. */
183
+ readOnly: boolean;
184
+ /** Whether Whiteboard can have it edit and run commands without asking. */
185
+ bypass: boolean;
186
+ }
187
+
188
+ /** An Ask agent process, connected as the given ACP client. */
189
+ export interface AskAgentProcess {
190
+ connect(client: ClientApp): ClientConnection;
191
+ /** Recent stderr, for explaining a failed start. */
192
+ diagnostics(): string;
193
+ stop(): void;
194
+ }
195
+
196
+ export type AskAgentLauncher = (
197
+ agent: AskAgentId,
198
+ cwd: string,
199
+ options?: {
200
+ bypass?: boolean;
201
+ /** The MCP servers its sessions are given. */
202
+ mcpServers?: McpServer[];
203
+ },
204
+ ) => Promise<AskAgentProcess>;
205
+
206
+ /** Desktop inherits the login shell's PATH, so this sees what a terminal sees.
207
+ * Package-manager bin directories are skipped: the adapters' own dependencies
208
+ * put `codex` there, and that is not the user's install. */
209
+ export async function findExecutable(
210
+ command: string,
211
+ env: NodeJS.ProcessEnv = process.env,
212
+ ): Promise<string | undefined> {
213
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
214
+ if (!directory || directory.split(path.sep).includes("node_modules"))
215
+ continue;
216
+ const candidate = path.join(directory, command);
217
+
218
+ try {
219
+ if (!(await stat(candidate)).isFile()) continue;
220
+ await access(candidate, constants.X_OK);
221
+
222
+ return candidate;
223
+ } catch {
224
+ /* Not in this directory. */
225
+ }
226
+ }
227
+
228
+ return undefined;
229
+ }
230
+
231
+ /** The user's install of an agent's CLI: on PATH, else where its installer
232
+ * puts it. */
233
+ async function findAgent(
234
+ spec: AskAgentSpec,
235
+ env: NodeJS.ProcessEnv = process.env,
236
+ ): Promise<string | undefined> {
237
+ const installed = (spec.installDirs ?? []).map((directory) =>
238
+ path.join(homedir(), directory),
239
+ );
240
+
241
+ for (const command of spec.commands) {
242
+ const found =
243
+ (await findExecutable(command, env)) ??
244
+ (await findExecutable(command, { PATH: installed.join(path.delimiter) }));
245
+
246
+ if (found) return found;
247
+ }
248
+
249
+ return undefined;
250
+ }
251
+
252
+ /** Whether the agent's model gets the MCP servers its sessions are given. */
253
+ export async function askAgentTakesMcp(agent: AskAgentId): Promise<boolean> {
254
+ const spec = askAgents[agent];
255
+
256
+ if (!spec.mcp) return true;
257
+ const executable = await findAgent(spec);
258
+
259
+ return executable !== undefined && spec.mcp.supported(executable);
260
+ }
261
+
262
+ export async function detectAskAgents(
263
+ env: NodeJS.ProcessEnv = process.env,
264
+ ): Promise<AskAgentStatus[]> {
265
+ return Promise.all(
266
+ askAgentIds.map(async (id) => ({
267
+ id,
268
+ name: askAgents[id].name,
269
+ available: (await findAgent(askAgents[id], env)) !== undefined,
270
+ readOnly: askAgents[id].readOnlyMode !== undefined,
271
+ bypass: askAgents[id].bypass !== undefined,
272
+ })),
273
+ );
274
+ }
275
+
276
+ const STDERR_LIMIT = 8_000;
277
+
278
+ /** Runs an adapter with this server's runtime, Desktop's being Electron, or
279
+ * the user's CLI itself. */
280
+ export const launchAskAgent: AskAgentLauncher = async (
281
+ agent,
282
+ cwd,
283
+ { bypass = false, mcpServers = [] } = {},
284
+ ) => {
285
+ const spec = askAgents[agent];
286
+ const executable = await findAgent(spec);
287
+
288
+ if (!executable) throw new Error(`${spec.name} is not installed.`);
289
+
290
+ const env: NodeJS.ProcessEnv = {
291
+ ...process.env,
292
+ ...((bypass && spec.bypass?.env) || spec.env),
293
+ };
294
+
295
+ // A server started from inside a Claude Code session must not look nested.
296
+ delete env.CLAUDECODE;
297
+ delete env.CLAUDE_CODE_ENTRYPOINT;
298
+
299
+ const { launch } = spec;
300
+
301
+ let command = executable;
302
+ let args: string[];
303
+
304
+ if ("adapter" in launch) {
305
+ env[launch.executableEnv] =
306
+ spec.mcp && mcpServers.length && (await spec.mcp.supported(executable))
307
+ ? await spec.mcp.launch(executable, mcpServers, env)
308
+ : executable;
309
+
310
+ if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = "1";
311
+ command = process.execPath;
312
+ args = [createRequire(import.meta.url).resolve(launch.adapter)];
313
+ } else args = launch.args;
314
+
315
+ const child = spawn(command, args, {
316
+ cwd,
317
+ env,
318
+ stdio: ["pipe", "pipe", "pipe"],
319
+ });
320
+
321
+ let stderr = "";
322
+
323
+ child.stderr.setEncoding("utf8");
324
+ child.stderr.on("data", (chunk: string) => {
325
+ stderr = (stderr + chunk).slice(-STDERR_LIMIT);
326
+ });
327
+
328
+ await new Promise<void>((resolve, reject) => {
329
+ child.once("spawn", resolve);
330
+ child.once("error", reject);
331
+ });
332
+
333
+ const stream = ndJsonStream(
334
+ Writable.toWeb(child.stdin),
335
+ // SAFETY: stdout has no encoding set, so it yields Buffers (Uint8Arrays).
336
+ Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
337
+ );
338
+
339
+ return {
340
+ connect: (client) => {
341
+ const connection = client.connect(stream);
342
+ child.once("exit", () => connection.close());
343
+
344
+ return connection;
345
+ },
346
+ diagnostics: () => stderr,
347
+ stop: () => {
348
+ if (child.exitCode === null) child.kill();
349
+ },
350
+ };
351
+ };
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ /** The checkout's files relative to its root: tracked ones, and new ones git
7
+ * does not ignore. None where git cannot list them. */
8
+ export async function checkoutFiles(root: string): Promise<string[]> {
9
+ try {
10
+ const { stdout } = await execFileAsync(
11
+ "git",
12
+ [
13
+ "-C",
14
+ root,
15
+ "ls-files",
16
+ "-z",
17
+ "--cached",
18
+ "--others",
19
+ "--exclude-standard",
20
+ ],
21
+ { maxBuffer: 64 * 1024 * 1024 },
22
+ );
23
+
24
+ return [...new Set(stdout.split("\0").filter(Boolean))];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /** How long a listing serves mentions before git is asked again. */
31
+ const LISTING_MS = 10_000;
32
+
33
+ const listings = new Map<string, { at: number; files: Promise<string[]> }>();
34
+
35
+ /** The checkout's files for a mention picker, which asks on each keystroke:
36
+ * listed again only once the last listing is a few seconds old. */
37
+ export function mentionableFiles(root: string): Promise<string[]> {
38
+ const now = Date.now();
39
+ const listed = listings.get(root);
40
+
41
+ if (listed && now - listed.at < LISTING_MS) return listed.files;
42
+ const files = checkoutFiles(root);
43
+
44
+ listings.set(root, { at: now, files });
45
+
46
+ return files;
47
+ }
@@ -0,0 +1,111 @@
1
+ /** A file an agent's answer names, as it wrote it: `src/app.ts`,
2
+ * `src/app.ts:42:7`, `/checkout/src/app.ts#L42`, `file:///checkout/app.ts`. */
3
+ export interface AskFileRef {
4
+ path: string;
5
+ line?: number;
6
+ }
7
+
8
+ /** `:42`, `:42:7`, `:42-50`, `#L42`, `#L42C7`, `#L42-L50`. */
9
+ const lineSuffix = /(?::(\d+)(?::\d+)?(?:-\d+)?|#L(\d+)(?:C\d+)?(?:-L?\d+)?)$/;
10
+
11
+ /** A scheme, as in `https://`; a Windows drive letter is not one. */
12
+ const urlScheme = /^[a-z][a-z\d+.-]+:/i;
13
+
14
+ /** What a piece of inline code or a link target names, if it could be a
15
+ * file: a path with a directory or an extension, and no spaces. Whether the
16
+ * file exists is the checkout's to say. */
17
+ export function parseFileRef(text: string): AskFileRef | undefined {
18
+ let value = text.trim();
19
+
20
+ if (value.startsWith("file://")) {
21
+ try {
22
+ value = decodeURIComponent(value.slice("file://".length));
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ if (
29
+ !value ||
30
+ value.length > 400 ||
31
+ /[\s`'"<>|*?{}$]/.test(value) ||
32
+ urlScheme.test(value) ||
33
+ value.startsWith("-")
34
+ )
35
+ return undefined;
36
+
37
+ const match = lineSuffix.exec(value);
38
+
39
+ const path = (match ? value.slice(0, match.index) : value).replace(
40
+ /^\.\//,
41
+ "",
42
+ );
43
+
44
+ const name = path.split("/").at(-1) ?? "";
45
+
46
+ if (!name || !(path.includes("/") || /\.[a-z][a-z\d]*$/i.test(name)))
47
+ return undefined;
48
+
49
+ const line = Number(match?.[1] ?? match?.[2]);
50
+
51
+ return line > 0 ? { path, line } : { path };
52
+ }
53
+
54
+ /** Resolves the paths an answer names to files in the checkout, relative to
55
+ * its root. Agents write paths from the checkout root, as absolute paths, or
56
+ * relative to the package they are in; a path that could be several files
57
+ * resolves to the one the agent's tools touched, else to none. */
58
+ export function resolveFileRefs(
59
+ root: string,
60
+ files: readonly string[],
61
+ paths: readonly string[],
62
+ /** Paths the agent's tool calls named. */
63
+ touched: readonly string[] = [],
64
+ ): Map<string, string> {
65
+ const known = new Set(files);
66
+
67
+ const inRoot = (path: string) =>
68
+ path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
69
+
70
+ const named = (path: string) =>
71
+ known.has(path)
72
+ ? [path]
73
+ : files.filter((file) => file.endsWith(`/${path}`));
74
+
75
+ const touchedFiles = new Set(
76
+ touched.flatMap((path) => {
77
+ const relative = inRoot(path);
78
+
79
+ return relative.startsWith("/") ? [] : named(relative);
80
+ }),
81
+ );
82
+
83
+ const resolved = new Map<string, string>();
84
+
85
+ for (const asked of paths) {
86
+ const path = inRoot(asked);
87
+
88
+ // Absolute, but not in this checkout.
89
+ if (path.startsWith("/")) continue;
90
+
91
+ // `a/` and `b/` are a diff's prefixes, not directories.
92
+ for (const candidate of [path, path.replace(/^[ab]\//, "")]) {
93
+ const matches = named(candidate);
94
+ const touchedMatches = matches.filter((file) => touchedFiles.has(file));
95
+
96
+ const match =
97
+ matches.length === 1
98
+ ? matches[0]
99
+ : touchedMatches.length === 1
100
+ ? touchedMatches[0]
101
+ : undefined;
102
+
103
+ if (match) {
104
+ resolved.set(asked, match);
105
+ break;
106
+ }
107
+ }
108
+ }
109
+
110
+ return resolved;
111
+ }
@@ -0,0 +1,154 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import type { McpServer } from "@agentclientprotocol/sdk";
7
+ import { devReviewHome } from "@dev.fast/trace-core";
8
+
9
+ /**
10
+ * pi-acp keeps the MCP servers a session is given without passing them to
11
+ * Pi. Pi 0.99.0 and later take them from an extension, so Ask starts Pi
12
+ * through a wrapper that loads one, registering the servers for that session
13
+ * only. Older Pi, and Windows, which would need a different wrapper, answer
14
+ * without them.
15
+ */
16
+
17
+ /** The first Pi with `pi.registerMcpServer()`. */
18
+ const PI_MCP_VERSION = [0, 99, 0] as const;
19
+
20
+ /** How long a version read is trusted: a session's first prompt and its
21
+ * launch ask moments apart, and an update during a review is seen soon. */
22
+ const VERSION_TTL_MS = 30_000;
23
+
24
+ const SERVERS_ENV = "WHITEBOARD_ASK_MCP";
25
+
26
+ const PI_ENV = "WHITEBOARD_ASK_PI";
27
+
28
+ const EXTENSION_ENV = "WHITEBOARD_ASK_PI_EXTENSION";
29
+
30
+ const EXTENSION = `// Managed by Whiteboard: gives a Pi session that Ask starts the MCP servers
31
+ // Whiteboard hands it. Do not edit.
32
+ export default function (pi: {
33
+ registerMcpServer(name: string, config: object): void;
34
+ }) {
35
+ const servers = JSON.parse(process.env.${SERVERS_ENV} ?? "{}");
36
+
37
+ for (const [name, config] of Object.entries(servers))
38
+ pi.registerMcpServer(name, config as object);
39
+ }
40
+ `;
41
+
42
+ const WRAPPER = `#!/bin/sh
43
+ # Managed by Whiteboard: starts Pi for Ask with Whiteboard's extension.
44
+ exec "$${PI_ENV}" -e "$${EXTENSION_ENV}" "$@"
45
+ `;
46
+
47
+ const versions = new Map<
48
+ string,
49
+ { read: number; version: Promise<number[] | undefined> }
50
+ >();
51
+
52
+ function piVersion(executable: string) {
53
+ const cached = versions.get(executable);
54
+
55
+ if (cached && Date.now() - cached.read < VERSION_TTL_MS)
56
+ return cached.version;
57
+
58
+ const version = promisify(execFile)(executable, ["--version"], {
59
+ timeout: 5_000,
60
+ })
61
+ .then(({ stdout }) =>
62
+ /(\d+)\.(\d+)\.(\d+)/u.exec(stdout)?.slice(1).map(Number),
63
+ )
64
+ .catch(() => undefined);
65
+
66
+ versions.set(executable, { read: Date.now(), version });
67
+
68
+ return version;
69
+ }
70
+
71
+ /** Whether this Pi can be given MCP servers. */
72
+ export async function piTakesMcp(executable: string): Promise<boolean> {
73
+ if (process.platform === "win32") return false;
74
+ const version = await piVersion(executable);
75
+
76
+ if (!version) return false;
77
+
78
+ for (const [index, least] of PI_MCP_VERSION.entries()) {
79
+ const part = version[index] ?? 0;
80
+
81
+ if (part !== least) return part > least;
82
+ }
83
+
84
+ return true;
85
+ }
86
+
87
+ /** Writes a file only when it changed, replacing it whole, since another
88
+ * Ask session may be running it. */
89
+ async function keep(file: string, content: string, mode: number) {
90
+ const current = await readFile(file, "utf8").catch(() => undefined);
91
+
92
+ if (current === content) return;
93
+ const staged = `${file}.${process.pid}.tmp`;
94
+
95
+ await writeFile(staged, content, { mode });
96
+ await chmod(staged, mode);
97
+ await rename(staged, file);
98
+ }
99
+
100
+ /** Pi's shape for each server, under its name; it reaches every tool
101
+ * without searching for it. */
102
+ /** A server as Pi's `mcp.json` has it. */
103
+ type PiMcpServer = { exposure: "direct" } & (
104
+ | { command: string; args: string[]; env: Record<string, string> }
105
+ | { url: string; headers: Record<string, string> }
106
+ );
107
+
108
+ function piServers(servers: McpServer[]) {
109
+ const piServers: Record<string, PiMcpServer> = {};
110
+
111
+ const record = (pairs: { name: string; value: string }[]) =>
112
+ Object.fromEntries(pairs.map(({ name, value }) => [name, value]));
113
+
114
+ for (const server of servers) {
115
+ if ("command" in server)
116
+ piServers[server.name] = {
117
+ command: server.command,
118
+ args: server.args,
119
+ env: record(server.env),
120
+ exposure: "direct",
121
+ };
122
+ // Pi speaks neither SSE nor MCP over ACP.
123
+ else if (server.type === "http")
124
+ piServers[server.name] = {
125
+ url: server.url,
126
+ headers: record(server.headers),
127
+ exposure: "direct",
128
+ };
129
+ }
130
+
131
+ return piServers;
132
+ }
133
+
134
+ /** The command pi-acp runs in place of Pi: Pi with the extension that
135
+ * registers `servers`, which go to it in `env`. */
136
+ export async function piWithMcp(
137
+ executable: string,
138
+ servers: McpServer[],
139
+ env: NodeJS.ProcessEnv,
140
+ ): Promise<string> {
141
+ const directory = path.join(devReviewHome(), "ask", "pi");
142
+ const extension = path.join(directory, "whiteboard-mcp.ts");
143
+ const wrapper = path.join(directory, "pi");
144
+
145
+ await mkdir(directory, { recursive: true });
146
+ await keep(extension, EXTENSION, 0o644);
147
+ await keep(wrapper, WRAPPER, 0o755);
148
+
149
+ env[PI_ENV] = executable;
150
+ env[EXTENSION_ENV] = extension;
151
+ env[SERVERS_ENV] = JSON.stringify(piServers(servers));
152
+
153
+ return wrapper;
154
+ }